Merge pull request 'v1.1.0' (#16) from dev into main
Golang lint / lint (push) Successful in 2m0s
Golang lint / lint (push) Successful in 2m0s
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
@@ -6,3 +6,4 @@ test/
|
||||
.codex
|
||||
.agents/
|
||||
.claude/
|
||||
review.md
|
||||
+1
-1
@@ -2,7 +2,7 @@ version: "2"
|
||||
run:
|
||||
timeout: 5m
|
||||
linters:
|
||||
disable-all: true
|
||||
default: none
|
||||
enable:
|
||||
- errcheck
|
||||
- ineffassign
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## 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. 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.
|
||||
- Added `tgapi.InputRichMessageContent` for rich content in inline query results.
|
||||
- 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 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
|
||||
|
||||
### 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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://go.dev/)
|
||||
[](LICENSE)
|
||||

|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://go.dev/)
|
||||
[](LICENSE)
|
||||

|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -121,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
|
||||
@@ -166,21 +168,6 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
limiter := utils.NewRateLimiter()
|
||||
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
|
||||
if opts.MaxWorkers > 0 {
|
||||
workers = opts.MaxWorkers
|
||||
@@ -191,6 +178,25 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
||||
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 := append([]string(nil), opts.Prefixes...)
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{"/"}
|
||||
}
|
||||
|
||||
bot := &Bot[T]{
|
||||
updateOffset: 0,
|
||||
errorTemplate: "%s",
|
||||
@@ -225,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(
|
||||
@@ -472,7 +479,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
|
||||
default:
|
||||
updates, err := bot.Updates(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
retryDelay, ok := pollRetryAfterDelay(err)
|
||||
|
||||
+15
-2
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-3
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
+32
-38
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+66
-1
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+17
-9
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+55
-12
@@ -8,9 +8,9 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgrich"
|
||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||
)
|
||||
|
||||
@@ -550,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)
|
||||
}
|
||||
@@ -821,3 +812,55 @@ func (ctx *MessageContext) UpsertKeyboard(text string, keyboard *InlineKeyboard)
|
||||
func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
|
||||
}
|
||||
|
||||
func (ctx *MessageContext) richAnswer(rich tgapi.InputRichMessage, keyboard *InlineKeyboard) *AnswerMessage {
|
||||
if ctx.Msg == nil {
|
||||
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||
return nil
|
||||
}
|
||||
params := tgapi.SendRichMessage{
|
||||
ChatID: ctx.Msg.Chat.ID,
|
||||
RichMessage: rich,
|
||||
}
|
||||
if keyboard != nil {
|
||||
params.ReplyMarkup = keyboard.Get()
|
||||
}
|
||||
if ctx.Msg.MessageThreadID > 0 {
|
||||
params.MessageThreadID = int64(ctx.Msg.MessageThreadID)
|
||||
}
|
||||
if ctx.Msg.DirectMessageTopic != nil {
|
||||
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
||||
}
|
||||
|
||||
msg, err := ctx.API.SendRichMessageWithContext(ctx.Context(), params)
|
||||
if err != nil {
|
||||
ctx.Logger.Errorln(err)
|
||||
return nil
|
||||
}
|
||||
return &AnswerMessage{
|
||||
MessageID: msg.MessageID, ctx: ctx, Text: rich.HTML, IsMedia: false,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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...)
|
||||
}
|
||||
|
||||
@@ -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"] != "<p><b>ready</b></p>" {
|
||||
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
|
||||
|
||||
|
||||
+27
-1
@@ -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)
|
||||
|
||||
+31
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+17
-9
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+141
@@ -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(),
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditMessageTextMarshalsInputRichMessage(t *testing.T) {
|
||||
params := EditMessageText{
|
||||
ChatID: 1,
|
||||
MessageID: 2,
|
||||
RichMessage: &InputRichMessage{
|
||||
HTML: "<p>hi</p>",
|
||||
SkipEntityDetection: true,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
for _, want := range []string{`"rich_message":{"html":`, `"skip_entity_detection":true`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %s in editMessageText JSON: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"blocks"`) {
|
||||
t.Fatalf("rich_message must be an InputRichMessage, not a block tree: %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"text"`) {
|
||||
t.Fatalf("empty text must be omitted when editing rich content: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRichMessageDraftMarshal(t *testing.T) {
|
||||
params := SendRichMessageDraft{
|
||||
ChatID: 1,
|
||||
DraftID: 7,
|
||||
RichMessage: InputRichMessage{Markdown: "*hi*"},
|
||||
}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
for _, want := range []string{`"chat_id":1`, `"draft_id":7`, `"rich_message":{"markdown":"*hi*"}`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %s in sendRichMessageDraft JSON: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRichMessageContentMarshal(t *testing.T) {
|
||||
content := InputRichMessageContent{
|
||||
RichMessage: InputRichMessage{HTML: "<p>hi</p>"},
|
||||
}
|
||||
data, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
if got := string(data); !strings.Contains(got, `"rich_message":{"html":`) {
|
||||
t.Fatalf("unexpected InputRichMessageContent JSON: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRichMessageMediaMarshal(t *testing.T) {
|
||||
message := InputRichMessage{
|
||||
HTML: `<video src="tg://video?id=intro"></video>`,
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
got := string(data)
|
||||
if got != `{"type":"link","url":"https://example.com"}` {
|
||||
t.Fatalf("unexpected link media JSON: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollMediaUnmarshalLink(t *testing.T) {
|
||||
var media PollMedia
|
||||
if err := json.Unmarshal([]byte(`{"link":{"url":"https://example.com"}}`), &media); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if media.Link == nil || media.Link.URL != "https://example.com" {
|
||||
t.Fatalf("unexpected poll media link: %+v", media.Link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatJoinRequestUnmarshalQueryID(t *testing.T) {
|
||||
payload := `{"chat":{"id":1},"from":{"id":2,"first_name":"A"},"user_chat_id":2,"date":3,"query_id":"q42"}`
|
||||
var req ChatJoinRequest
|
||||
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if req.QueryID == nil || *req.QueryID != "q42" {
|
||||
t.Fatalf("unexpected query_id: %+v", req.QueryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerChatJoinRequestQueryResultValues(t *testing.T) {
|
||||
if JoinRequestApprove != "approve" || JoinRequestDecline != "decline" || JoinRequestQueue != "queue" {
|
||||
t.Fatalf("unexpected join request query result values: %q %q %q",
|
||||
JoinRequestApprove, JoinRequestDecline, JoinRequestQueue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUnmarshalSupportsJoinRequestQueries(t *testing.T) {
|
||||
var user User
|
||||
if err := json.Unmarshal([]byte(`{"id":1,"first_name":"A","supports_join_request_queries":true}`), &user); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if user.SupportsJoinRequestQueries == nil || !*user.SupportsJoinRequestQueries {
|
||||
t.Fatalf("unexpected supports_join_request_queries: %+v", user.SupportsJoinRequestQueries)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
+37
-12
@@ -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
|
||||
)
|
||||
|
||||
@@ -166,11 +170,14 @@ type PollOption struct {
|
||||
}
|
||||
|
||||
// InputPollOptionMedia describes the media to attach to a poll option.
|
||||
// For type "link" set URL instead of Media.
|
||||
// Since: Bot API 10.0
|
||||
// See https://core.telegram.org/bots/api#inputpolloptionmedia
|
||||
type InputPollOptionMedia struct {
|
||||
Type string `json:"type"`
|
||||
Media string `json:"media"`
|
||||
Media string `json:"media,omitempty"`
|
||||
// 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.
|
||||
@@ -224,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
|
||||
}
|
||||
@@ -258,12 +265,22 @@ type Poll struct {
|
||||
Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0
|
||||
}
|
||||
|
||||
// Link represents an HTTP link.
|
||||
// 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"`
|
||||
Photo []PhotoSize `json:"photo,omitempty"`
|
||||
@@ -342,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
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -481,6 +481,77 @@ func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params De
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// ChatJoinRequestQueryResult is the verdict passed to answerChatJoinRequestQuery.
|
||||
// Since: Bot API 10.1
|
||||
type ChatJoinRequestQueryResult string
|
||||
|
||||
const (
|
||||
// JoinRequestApprove allows the user to join the chat.
|
||||
JoinRequestApprove ChatJoinRequestQueryResult = "approve"
|
||||
// JoinRequestDecline disallows the user to join the chat.
|
||||
JoinRequestDecline ChatJoinRequestQueryResult = "decline"
|
||||
// JoinRequestQueue leaves the decision to other administrators.
|
||||
JoinRequestQueue ChatJoinRequestQueryResult = "queue"
|
||||
)
|
||||
|
||||
// AnswerChatJoinRequestQuery holds parameters for the answerChatJoinRequestQuery method.
|
||||
// Since: Bot API 10.1
|
||||
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||
type AnswerChatJoinRequestQuery struct {
|
||||
// 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.
|
||||
// Since: Bot API 10.1
|
||||
// Returns True on success.
|
||||
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||
func (api *API) AnswerChatJoinRequestQuery(params AnswerChatJoinRequestQuery) (bool, error) {
|
||||
req := NewRequest[bool]("answerChatJoinRequestQuery", params)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// AnswerChatJoinRequestQueryWithContext is the context-aware variant of AnswerChatJoinRequestQuery.
|
||||
// Since: Bot API 10.1
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||
func (api *API) AnswerChatJoinRequestQueryWithContext(ctx context.Context, params AnswerChatJoinRequestQuery) (bool, error) {
|
||||
req := NewRequest[bool]("answerChatJoinRequestQuery", params)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// SendChatJoinRequestWebApp holds parameters for the sendChatJoinRequestWebApp method.
|
||||
// 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 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
|
||||
// join request query; resolve the query with AnswerChatJoinRequestQuery based
|
||||
// on the Mini App interaction.
|
||||
// Since: Bot API 10.1
|
||||
// Returns True on success.
|
||||
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
|
||||
func (api *API) SendChatJoinRequestWebApp(params SendChatJoinRequestWebApp) (bool, error) {
|
||||
req := NewRequest[bool]("sendChatJoinRequestWebApp", params)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// SendChatJoinRequestWebAppWithContext is the context-aware variant of SendChatJoinRequestWebApp.
|
||||
// Since: Bot API 10.1
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
|
||||
func (api *API) SendChatJoinRequestWebAppWithContext(ctx context.Context, params SendChatJoinRequestWebApp) (bool, error) {
|
||||
req := NewRequest[bool]("sendChatJoinRequestWebApp", params)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// SetChatPhoto holds parameters for the setChatPhoto method.
|
||||
// Since: Bot API 3.1
|
||||
// See https://core.telegram.org/bots/api#setchatphoto
|
||||
|
||||
@@ -91,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.
|
||||
@@ -317,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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,16 @@ type InlineQueryResultsButton struct {
|
||||
StartParameter string `json:"start_parameter,omitempty"`
|
||||
}
|
||||
|
||||
// InputRichMessageContent represents the content of a rich message to be
|
||||
// sent as the result of an inline query. Use it as the input_message_content
|
||||
// value of an InlineQueryResult.
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user.
|
||||
// Since: Bot API 8.0
|
||||
// See https://core.telegram.org/bots/api#sentwebappmessage
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const redactedLogValue = "<REDACTED>"
|
||||
|
||||
var sensitiveLogFields = map[string]struct{}{
|
||||
"callback_data": {},
|
||||
"credentials": {},
|
||||
"data": {},
|
||||
"invoice_payload": {},
|
||||
"payload": {},
|
||||
"provider_data": {},
|
||||
"provider_token": {},
|
||||
"secret": {},
|
||||
"secret_token": {},
|
||||
"token": {},
|
||||
"web_app_query_id": {},
|
||||
}
|
||||
|
||||
func redactRequestLog(data []byte) string {
|
||||
var value any
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
return fmt.Sprintf("<invalid JSON omitted: %d bytes>", len(data))
|
||||
}
|
||||
redactLogValue(value)
|
||||
redacted, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("<unavailable JSON omitted: %d bytes>", len(data))
|
||||
}
|
||||
return string(redacted)
|
||||
}
|
||||
|
||||
func redactLogValue(value any) {
|
||||
switch value := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range value {
|
||||
if _, sensitive := sensitiveLogFields[strings.ToLower(key)]; sensitive {
|
||||
value[key] = redactedLogValue
|
||||
continue
|
||||
}
|
||||
redactLogValue(item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
redactLogValue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responseLogSummary(method string, size int) string {
|
||||
return fmt.Sprintf("method=%s bytes=%d body=omitted", method, size)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactRequestLogRemovesSensitiveValues(t *testing.T) {
|
||||
const input = `{"secret_token":"webhook-secret","provider_token":"payment-token","nested":{"data":"passport-data","callback_data":"callback-secret"},"chat_id":42}`
|
||||
got := redactRequestLog([]byte(input))
|
||||
|
||||
for _, secret := range []string{"webhook-secret", "payment-token", "passport-data", "callback-secret"} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("redacted request contains %q: %s", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, `"chat_id":42`) {
|
||||
t.Errorf("redacted request lost non-sensitive field: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactRequestLogOmitsInvalidJSON(t *testing.T) {
|
||||
const secret = "not-json-secret"
|
||||
got := redactRequestLog([]byte(secret))
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("invalid JSON was logged verbatim: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseLogSummaryNeverContainsBody(t *testing.T) {
|
||||
const token = "managed-bot-token"
|
||||
got := responseLogSummary("getManagedBotToken", len(token))
|
||||
if strings.Contains(got, token) {
|
||||
t.Fatalf("response summary contains response body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "body=omitted") {
|
||||
t.Fatalf("response summary does not explain omission: %s", got)
|
||||
}
|
||||
}
|
||||
+272
-4
@@ -10,6 +10,10 @@ type SendMessage struct {
|
||||
ChatID int64 `json:"chat_id"`
|
||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||
DirectMessagesTopicID int64 `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
|
||||
|
||||
Text string `json:"text"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
@@ -29,7 +33,7 @@ type SendMessage struct {
|
||||
// Since: Bot API 1.0
|
||||
// See https://core.telegram.org/bots/api#sendmessage
|
||||
func (api *API) SendMessage(params SendMessage) (Message, error) {
|
||||
req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
|
||||
req := NewRequestWithChatID[Message]("sendMessage", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
@@ -38,7 +42,7 @@ func (api *API) SendMessage(params SendMessage) (Message, error) {
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendmessage
|
||||
func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) (Message, error) {
|
||||
req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
|
||||
req := NewRequestWithChatID[Message]("sendMessage", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
@@ -200,6 +204,10 @@ type SendLocation 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
|
||||
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
@@ -243,6 +251,10 @@ type SendVenue 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
|
||||
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
@@ -288,6 +300,10 @@ type SendContact 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
|
||||
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
FirstName string `json:"first_name"`
|
||||
@@ -547,11 +563,13 @@ type EditMessageText struct {
|
||||
ChatID int64 `json:"chat_id,omitempty"`
|
||||
MessageID int `json:"message_id,omitempty"`
|
||||
InlineMessageID string `json:"inline_message_id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text,omitempty"` // required unless RichMessage is set
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
|
||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||
// RichMessage contains structured rich-message content.
|
||||
RichMessage *InputRichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1; required if Text is not specified
|
||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// EditMessageText edits text messages.
|
||||
@@ -1089,3 +1107,253 @@ func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params Del
|
||||
req := NewRequest[bool]("deleteMessageReaction", params)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// SendRichMessage holds parameters for the sendRichMessage method.
|
||||
// Since: Bot API 10.1
|
||||
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||
type SendRichMessage struct {
|
||||
// BusinessConnectionID identifies the business connection used to send the message.
|
||||
BusinessConnectionID string `json:"business_connection_id,omitempty"`
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// MessageThreadID identifies the target message thread.
|
||||
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
||||
// DirectMessagesTopicID identifies the target direct-messages topic.
|
||||
DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"`
|
||||
|
||||
// RichMessage contains structured rich-message content.
|
||||
RichMessage InputRichMessage `json:"rich_message"`
|
||||
// DisableNotification requests delivery without a notification sound.
|
||||
DisableNotification bool `json:"disable_notification,omitempty"`
|
||||
// ProtectContent prevents forwarding and saving the sent content.
|
||||
ProtectContent bool `json:"protect_content,omitempty"`
|
||||
// AllowPaidBroadcast permits high-throughput delivery using paid broadcast capacity.
|
||||
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
|
||||
// MessageEffectID identifies the message effect to apply.
|
||||
MessageEffectID string `json:"message_effect_id,omitempty"`
|
||||
// SuggestedPostParameters contains parameters for a suggested channel post.
|
||||
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
|
||||
// ReplyParameters describes the message being replied to.
|
||||
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
|
||||
// ReplyMarkup defines the message's inline keyboard.
|
||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// SendRichMessage sends a rich formatted message.
|
||||
// Since: Bot API 10.1
|
||||
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||
func (api *API) SendRichMessage(params SendRichMessage) (Message, error) {
|
||||
req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// SendRichMessageWithContext is the context-aware variant of SendRichMessage.
|
||||
// Since: Bot API 10.1
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||
func (api *API) SendRichMessageWithContext(ctx context.Context, params SendRichMessage) (Message, error) {
|
||||
req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// SendRichMessageDraft holds parameters for the sendRichMessageDraft method.
|
||||
// Since: Bot API 10.1
|
||||
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||
type SendRichMessageDraft struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// MessageThreadID identifies the target message thread.
|
||||
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
||||
|
||||
// DraftID must be non-zero; changes to drafts with the same identifier are animated.
|
||||
DraftID int64 `json:"draft_id"`
|
||||
// RichMessage contains structured rich-message content.
|
||||
RichMessage InputRichMessage `json:"rich_message"`
|
||||
}
|
||||
|
||||
// SendRichMessageDraft streams a partial rich message to a private chat while
|
||||
// the message is being generated. The draft is an ephemeral ~30-second
|
||||
// preview; call SendRichMessage with the complete message to persist it.
|
||||
// Since: Bot API 10.1
|
||||
// Returns True on success.
|
||||
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||
func (api *API) SendRichMessageDraft(params SendRichMessageDraft) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// SendRichMessageDraftWithContext is the context-aware variant of SendRichMessageDraft.
|
||||
// Since: Bot API 10.1
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||
func (api *API) SendRichMessageDraftWithContext(ctx context.Context, params SendRichMessageDraft) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageText holds parameters for editing an ephemeral text message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type EditEphemeralMessageText struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||
// Text contains the formatted or plain text content.
|
||||
Text string `json:"text"`
|
||||
|
||||
// ParseMode selects the formatting syntax used by the text or caption.
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
// Entities describes explicit formatting entities in Text.
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
// LinkPreviewOptions controls link preview generation for Text.
|
||||
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
|
||||
// ReplyMarkup defines the message's inline keyboard.
|
||||
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// EditEphemeralMessageText edits an ephemeral text message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageText(params EditEphemeralMessageText) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageText", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageTextWithContext is the context-aware variant of EditEphemeralMessageText.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageTextWithContext(ctx context.Context, params EditEphemeralMessageText) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageText", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageMedia holds parameters for editing ephemeral message media.
|
||||
// New files cannot be uploaded; use a file ID or URL.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type EditEphemeralMessageMedia struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||
// Media contains or identifies media associated with the value.
|
||||
Media InputMedia `json:"media"`
|
||||
// ReplyMarkup defines the message's inline keyboard.
|
||||
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// EditEphemeralMessageMedia edits the media of an ephemeral message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageMedia(params EditEphemeralMessageMedia) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageMedia", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageMediaWithContext is the context-aware variant of EditEphemeralMessageMedia.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageMediaWithContext(ctx context.Context, params EditEphemeralMessageMedia) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageMedia", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageCaption holds parameters for editing an ephemeral message caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type EditEphemeralMessageCaption struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||
|
||||
// Caption contains the media or block caption.
|
||||
Caption string `json:"caption,omitempty"`
|
||||
// ParseMode selects the formatting syntax used by the text or caption.
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
// CaptionEntities describes formatting entities in Caption.
|
||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||
// ReplyMarkup defines the message's inline keyboard.
|
||||
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// EditEphemeralMessageCaption edits an ephemeral message caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageCaption(params EditEphemeralMessageCaption) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageCaption", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageCaptionWithContext is the context-aware variant of EditEphemeralMessageCaption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageCaptionWithContext(ctx context.Context, params EditEphemeralMessageCaption) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageCaption", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageReplyMarkup holds parameters for editing an ephemeral message's inline keyboard.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type EditEphemeralMessageReplyMarkup struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||
// ReplyMarkup defines the message's inline keyboard.
|
||||
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// EditEphemeralMessageReplyMarkup edits an ephemeral message's inline keyboard.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageReplyMarkup(params EditEphemeralMessageReplyMarkup) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageReplyMarkup", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// EditEphemeralMessageReplyMarkupWithContext is the context-aware variant of EditEphemeralMessageReplyMarkup.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) EditEphemeralMessageReplyMarkupWithContext(ctx context.Context, params EditEphemeralMessageReplyMarkup) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("editEphemeralMessageReplyMarkup", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
// DeleteEphemeralMessage holds parameters for deleting an ephemeral message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type DeleteEphemeralMessage struct {
|
||||
// ChatID identifies the target chat.
|
||||
ChatID int64 `json:"chat_id"`
|
||||
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||
}
|
||||
|
||||
// DeleteEphemeralMessage deletes an ephemeral message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) DeleteEphemeralMessage(params DeleteEphemeralMessage) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("deleteEphemeralMessage", params, params.ChatID)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
// DeleteEphemeralMessageWithContext is the context-aware variant of DeleteEphemeralMessage.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (api *API) DeleteEphemeralMessageWithContext(ctx context.Context, params DeleteEphemeralMessage) (bool, error) {
|
||||
req := NewRequestWithChatID[bool]("deleteEphemeralMessage", params, params.ChatID)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
+64
-11
@@ -23,10 +23,14 @@ type DirectMessageTopic struct {
|
||||
type MessageOriginType string
|
||||
|
||||
const (
|
||||
MessageOriginUserType = "user"
|
||||
// MessageOriginUserType identifies a known user origin.
|
||||
MessageOriginUserType = "user"
|
||||
// MessageOriginHiddenUserType identifies a hidden user origin.
|
||||
MessageOriginHiddenUserType = "hidden_user"
|
||||
MessageOriginChatType = "chat"
|
||||
MessageOriginChannel = "channel"
|
||||
// MessageOriginChatType identifies a chat origin.
|
||||
MessageOriginChatType = "chat"
|
||||
// MessageOriginChannel identifies a channel origin.
|
||||
MessageOriginChannel = "channel"
|
||||
)
|
||||
|
||||
// MessageOrigin describes the origin of a message.
|
||||
@@ -115,10 +119,14 @@ type Message struct {
|
||||
DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` // Since: Bot API 9.2
|
||||
From *User `json:"from,omitempty"`
|
||||
|
||||
SenderChat *Chat `json:"sender_chat,omitempty"` // Since: Bot API 5.0
|
||||
SenderBoostCount int `json:"sender_boost_count,omitempty"` // Since: Bot API 7.1
|
||||
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` // Since: Bot API 7.2
|
||||
SenderTag string `json:"sender_tag,omitempty"` // Since: Bot API 9.5
|
||||
SenderChat *Chat `json:"sender_chat,omitempty"` // Since: Bot API 5.0
|
||||
SenderBoostCount int `json:"sender_boost_count,omitempty"` // Since: Bot API 7.1
|
||||
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` // Since: Bot API 7.2
|
||||
SenderTag string `json:"sender_tag,omitempty"` // Since: Bot API 9.5
|
||||
// ReceiverUser identifies the user who can see the ephemeral message.
|
||||
ReceiverUser *User `json:"receiver_user,omitempty"` // Since: Bot API 10.2
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id,omitempty"` // Since: Bot API 10.2
|
||||
Date int `json:"date"`
|
||||
GuestQueryID string `json:"guest_query_id,omitempty"` // Since: Bot API 10.0
|
||||
BusinessConnectionID string `json:"business_connection_id,omitempty"` // Since: Bot API 7.2
|
||||
@@ -151,7 +159,9 @@ type Message struct {
|
||||
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1
|
||||
EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4
|
||||
|
||||
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
||||
// RichMessage contains structured rich-message content.
|
||||
RichMessage *RichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1
|
||||
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
||||
Audio *Audio `json:"audio,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6
|
||||
@@ -205,8 +215,12 @@ type Message struct {
|
||||
BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` // Since: Bot API 7.1
|
||||
ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` // Since: Bot API 7.5
|
||||
|
||||
ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"` // Since: Bot API 9.1
|
||||
ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"` // Since: Bot API 9.1
|
||||
ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"` // Since: Bot API 9.1
|
||||
ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"` // Since: Bot API 9.1
|
||||
// CommunityChatAdded describes a community chat addition service message.
|
||||
CommunityChatAdded *CommunityChatAdded `json:"community_chat_added,omitempty"` // Since: Bot API 10.2
|
||||
// CommunityChatRemoved describes a community chat removal service message.
|
||||
CommunityChatRemoved *CommunityChatRemoved `json:"community_chat_removed,omitempty"` // Since: Bot API 10.2
|
||||
DirectMessagePriceChanged *DirectMessagePriceChanged `json:"direct_message_price_changed,omitempty"` // Since: Bot API 9.1
|
||||
PaidMessagePriceChanged *PaidMessagePriceChanged `json:"paid_message_price_changed,omitempty"` // Since: Bot API 9.x
|
||||
ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` // Since: Bot API 6.3
|
||||
@@ -394,8 +408,10 @@ type MessageEntity struct {
|
||||
// Since: Bot API 7.0
|
||||
// See https://core.telegram.org/bots/api#replyparameters
|
||||
type ReplyParameters struct {
|
||||
MessageID int `json:"message_id"`
|
||||
MessageID int `json:"message_id,omitempty"`
|
||||
ChatID int64 `json:"chat_id,omitempty"`
|
||||
// EphemeralMessageID identifies the ephemeral message.
|
||||
EphemeralMessageID int64 `json:"ephemeral_message_id,omitempty"` // Since: Bot API 10.2
|
||||
|
||||
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
|
||||
Quote string `json:"quote,omitempty"`
|
||||
@@ -748,3 +764,40 @@ type VideoChatParticipantsInvited struct {
|
||||
type SentGuestMessage struct {
|
||||
InlineMessageID string `json:"inline_message_id"`
|
||||
}
|
||||
|
||||
// RichMessage represents a received rich-formatted message.
|
||||
// Since: Bot API 10.1
|
||||
type RichMessage struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
// IsRTL requests right-to-left rich-message layout.
|
||||
IsRTL bool `json:"is_rtl,omitempty"`
|
||||
}
|
||||
|
||||
// InputRichMessageMedia describes media embedded in outgoing rich-message HTML or Markdown.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichMessageMedia struct {
|
||||
// ID uniquely identifies the value within its containing object.
|
||||
ID string `json:"id"`
|
||||
// Media contains or identifies media associated with the value.
|
||||
Media InputMedia `json:"media"`
|
||||
}
|
||||
|
||||
// InputRichMessage describes a rich message to be sent. Exactly one of HTML, Markdown, or Blocks must be used.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type InputRichMessage struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks,omitempty"` // Since: Bot API 10.2
|
||||
// HTML contains rich-message content in Telegram HTML syntax.
|
||||
HTML string `json:"html,omitempty"`
|
||||
// Markdown contains rich-message content in Telegram Markdown syntax.
|
||||
Markdown string `json:"markdown,omitempty"`
|
||||
// Media contains or identifies media associated with the value.
|
||||
Media []InputRichMessageMedia `json:"media,omitempty"` // Since: Bot API 10.2
|
||||
// IsRTL requests right-to-left rich-message layout.
|
||||
IsRTL bool `json:"is_rtl,omitempty"`
|
||||
// SkipEntityDetection disables automatic detection of links, mentions, hashtags, commands, phone numbers, and bank cards.
|
||||
SkipEntityDetection bool `json:"skip_entity_detection,omitempty"`
|
||||
}
|
||||
|
||||
+4
-4
@@ -79,7 +79,7 @@ func (api *API) ReplaceManagedBotTokenWithContext(ctx context.Context, params Re
|
||||
// Returns true on success.
|
||||
// See https://core.telegram.org/bots/api#logout
|
||||
func (api *API) LogOut() (bool, error) {
|
||||
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
|
||||
req := NewRequest[bool]("logOut", NoParams)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (api *API) LogOut() (bool, error) {
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#logout
|
||||
func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
|
||||
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
|
||||
req := NewRequest[bool]("logOut", NoParams)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
|
||||
// Returns true on success.
|
||||
// See https://core.telegram.org/bots/api#close
|
||||
func (api *API) CloseRemote() (bool, error) {
|
||||
req := NewRequest[bool, EmptyParams]("close", NoParams)
|
||||
req := NewRequest[bool]("close", NoParams)
|
||||
return req.Do(api)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func (api *API) CloseRemote() (bool, error) {
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#close
|
||||
func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) {
|
||||
req := NewRequest[bool, EmptyParams]("close", NoParams)
|
||||
req := NewRequest[bool]("close", NoParams)
|
||||
return req.DoWithContext(ctx, api)
|
||||
}
|
||||
|
||||
|
||||
+25
-12
@@ -20,19 +20,32 @@ type PassportFile struct {
|
||||
type PassportElementType string
|
||||
|
||||
const (
|
||||
PassportPersonalDetailsType PassportElementType = "personal_details"
|
||||
PassportPassportType PassportElementType = "passport"
|
||||
PassportDriverLicenseType PassportElementType = "driver_license"
|
||||
PassportIdentityCardType PassportElementType = "identity_card"
|
||||
PassportInternalPassportType PassportElementType = "internal_passport"
|
||||
PassportAddressType PassportElementType = "address"
|
||||
PassportUtilityBillType PassportElementType = "utility_bill"
|
||||
PassportBankStatementType PassportElementType = "bank_statement"
|
||||
PassportRentalAgreementType PassportElementType = "rental_agreement"
|
||||
PassportPassportRegistrationType PassportElementType = "passport_registration"
|
||||
// PassportPersonalDetailsType identifies personal details.
|
||||
PassportPersonalDetailsType PassportElementType = "personal_details"
|
||||
// PassportPassportType identifies an international passport.
|
||||
PassportPassportType PassportElementType = "passport"
|
||||
// PassportDriverLicenseType identifies a driver license.
|
||||
PassportDriverLicenseType PassportElementType = "driver_license"
|
||||
// PassportIdentityCardType identifies an identity card.
|
||||
PassportIdentityCardType PassportElementType = "identity_card"
|
||||
// PassportInternalPassportType identifies an internal passport.
|
||||
PassportInternalPassportType PassportElementType = "internal_passport"
|
||||
// PassportAddressType identifies a residential address.
|
||||
PassportAddressType PassportElementType = "address"
|
||||
// PassportUtilityBillType identifies a utility bill.
|
||||
PassportUtilityBillType PassportElementType = "utility_bill"
|
||||
// PassportBankStatementType identifies a bank statement.
|
||||
PassportBankStatementType PassportElementType = "bank_statement"
|
||||
// PassportRentalAgreementType identifies a rental agreement.
|
||||
PassportRentalAgreementType PassportElementType = "rental_agreement"
|
||||
// PassportPassportRegistrationType identifies a passport registration.
|
||||
PassportPassportRegistrationType PassportElementType = "passport_registration"
|
||||
// PassportTemporaryRegistrationType identifies a temporary registration.
|
||||
PassportTemporaryRegistrationType PassportElementType = "temporary_registration"
|
||||
PassportPhoneNumberType PassportElementType = "phone_number"
|
||||
PassportEmailType PassportElementType = "email"
|
||||
// PassportPhoneNumberType identifies a phone number.
|
||||
PassportPhoneNumberType PassportElementType = "phone_number"
|
||||
// PassportEmailType identifies an email address.
|
||||
PassportEmailType PassportElementType = "email"
|
||||
)
|
||||
|
||||
// EncryptedPassportElement contains information about documents or other Telegram Passport elements.
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
package tgapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RichBlock is a block in a structured rich message.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlock interface {
|
||||
isRichBlock()
|
||||
}
|
||||
|
||||
// RichBlockCaption is the caption of a media block or container.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockCaption struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Credit contains attribution displayed with the block.
|
||||
Credit RichText
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (c RichBlockCaption) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Text RichText `json:"text"`
|
||||
Credit RichText `json:"credit,omitempty"`
|
||||
}{c.Text, c.Credit})
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (c *RichBlockCaption) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Text json.RawMessage `json:"text"`
|
||||
Credit json.RawMessage `json:"credit"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := parseOptRichText(raw.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
credit, err := parseOptRichText(raw.Credit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*c = RichBlockCaption{text, credit}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RichBlockListItem is a single list item. Label is the ready-to-display
|
||||
// visible marker ("1.", "c.", "vii.", "•"): the server renders it itself
|
||||
// when parsing html/markdown.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockListItem struct {
|
||||
// Label contains the list-item label.
|
||||
Label string
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock
|
||||
// HasCheckbox reports whether the list item includes a checkbox.
|
||||
HasCheckbox bool
|
||||
// IsChecked reports whether the list-item checkbox is checked.
|
||||
IsChecked bool
|
||||
Value int // for ordered lists: numeric value of the marker
|
||||
Type RichBlockListItemType // for ordered lists: "a", "A", "i", "I" or "1"
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (i RichBlockListItem) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Label string `json:"label"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
HasCheckbox bool `json:"has_checkbox,omitempty"`
|
||||
IsChecked bool `json:"is_checked,omitempty"`
|
||||
Value int `json:"value,omitempty"`
|
||||
Type RichBlockListItemType `json:"type,omitempty"`
|
||||
}{i.Label, i.Blocks, i.HasCheckbox, i.IsChecked, i.Value, i.Type})
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (i *RichBlockListItem) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Label string `json:"label"`
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
HasCheckbox bool `json:"has_checkbox"`
|
||||
IsChecked bool `json:"is_checked"`
|
||||
Value int `json:"value"`
|
||||
Type RichBlockListItemType `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*i = RichBlockListItem{raw.Label, blocks, raw.HasCheckbox, raw.IsChecked, raw.Value, raw.Type}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RichBlockTableCell is a table cell. An empty Text means an invisible cell.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockTableCell struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// IsHeader marks the table cell as a header cell.
|
||||
IsHeader bool
|
||||
// ColSpan is the number of table columns spanned by the cell.
|
||||
ColSpan int
|
||||
// RowSpan is the number of table rows spanned by the cell.
|
||||
RowSpan int
|
||||
Align string // "left", "center" or "right"
|
||||
VAlign string // "top", "middle" or "bottom"
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (c RichBlockTableCell) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Text RichText `json:"text,omitempty"`
|
||||
IsHeader bool `json:"is_header,omitempty"`
|
||||
Colspan int `json:"colspan,omitempty"`
|
||||
Rowspan int `json:"rowspan,omitempty"`
|
||||
Align string `json:"align,omitempty"`
|
||||
VAlign string `json:"valign,omitempty"`
|
||||
}{c.Text, c.IsHeader, c.ColSpan, c.RowSpan, c.Align, c.VAlign})
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error {
|
||||
var raw struct {
|
||||
Text json.RawMessage `json:"text"`
|
||||
IsHeader bool `json:"is_header"`
|
||||
Colspan int `json:"colspan"`
|
||||
Rowspan int `json:"rowspan"`
|
||||
Align string `json:"align"`
|
||||
VAlign string `json:"valign"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := parseOptRichText(raw.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*c = RichBlockTableCell{text, raw.IsHeader, raw.Colspan, raw.Rowspan, raw.Align, raw.VAlign}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RichBlockWrap covers all blocks that have only a text field.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockWrap struct {
|
||||
// Tag identifies the rich-text formatting wrapper.
|
||||
Tag string
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
}
|
||||
|
||||
func (RichBlockWrap) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockWrap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
}{b.Tag, b.Text})
|
||||
}
|
||||
|
||||
var richBlockWrapTags = map[string]bool{
|
||||
"paragraph": true, "footer": true, "thinking": true,
|
||||
}
|
||||
|
||||
// RichBlockSectionHeading is a section heading block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockSectionHeading struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
Size int // 1-6, 1 is the largest
|
||||
}
|
||||
|
||||
func (RichBlockSectionHeading) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockSectionHeading) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Size int `json:"size"`
|
||||
}{"heading", b.Text, b.Size})
|
||||
}
|
||||
|
||||
// RichBlockPreformatted is a preformatted code block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockPreformatted struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Language identifies the programming language used for syntax highlighting.
|
||||
Language string
|
||||
}
|
||||
|
||||
func (RichBlockPreformatted) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockPreformatted) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}{"pre", b.Text, b.Language})
|
||||
}
|
||||
|
||||
// RichBlockQuotation is a block quotation with block-level content
|
||||
// (officially RichBlockBlockQuotation).
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockQuotation struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock
|
||||
// Credit contains attribution displayed with the block.
|
||||
Credit RichText
|
||||
}
|
||||
|
||||
func (RichBlockQuotation) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockQuotation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
Credit RichText `json:"credit,omitempty"`
|
||||
}{"blockquote", b.Blocks, b.Credit})
|
||||
}
|
||||
|
||||
// RichBlockPullQuotation is a pull quotation with inline content.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockPullQuotation struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Credit contains attribution displayed with the block.
|
||||
Credit RichText
|
||||
}
|
||||
|
||||
func (RichBlockPullQuotation) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockPullQuotation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Credit RichText `json:"credit,omitempty"`
|
||||
}{"pullquote", b.Text, b.Credit})
|
||||
}
|
||||
|
||||
// RichBlockList is a list block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockList struct {
|
||||
// Items contains the list items.
|
||||
Items []RichBlockListItem
|
||||
}
|
||||
|
||||
func (RichBlockList) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockList) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Items []RichBlockListItem `json:"items"`
|
||||
}{"list", b.Items})
|
||||
}
|
||||
|
||||
// RichBlockCollage is a collage of media blocks.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockCollage struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockCollage) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockCollage) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"collage", b.Blocks, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockSlideshow is a slideshow of media blocks.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockSlideshow struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockSlideshow) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockSlideshow) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"slideshow", b.Blocks, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockDetails is an expandable block with an inline summary.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockDetails struct {
|
||||
// Summary contains the visible summary of a details block.
|
||||
Summary RichText
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []RichBlock
|
||||
// IsOpen requests the details block to be expanded initially.
|
||||
IsOpen bool
|
||||
}
|
||||
|
||||
func (RichBlockDetails) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockDetails) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Summary RichText `json:"summary"`
|
||||
Blocks []RichBlock `json:"blocks"`
|
||||
IsOpen bool `json:"is_open,omitempty"`
|
||||
}{"details", b.Summary, b.Blocks, b.IsOpen})
|
||||
}
|
||||
|
||||
// RichBlockTable is a table block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockTable struct {
|
||||
// Cells contains the table rows and cells.
|
||||
Cells [][]RichBlockTableCell
|
||||
// IsBordered requests visible table borders.
|
||||
IsBordered bool
|
||||
// IsStriped requests alternating table row styling.
|
||||
IsStriped bool
|
||||
// Caption contains the media or block caption.
|
||||
Caption RichText
|
||||
}
|
||||
|
||||
func (RichBlockTable) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockTable) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Cells [][]RichBlockTableCell `json:"cells"`
|
||||
IsBordered bool `json:"is_bordered,omitempty"`
|
||||
IsStriped bool `json:"is_striped,omitempty"`
|
||||
Caption RichText `json:"caption,omitempty"`
|
||||
}{"table", b.Cells, b.IsBordered, b.IsStriped, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockMap is a location map block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockMap struct {
|
||||
// Location contains the map location.
|
||||
Location Location
|
||||
Zoom int // 13-20
|
||||
// Width is the requested media or map width in pixels.
|
||||
Width int
|
||||
// Height is the requested media or map height in pixels.
|
||||
Height int
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockMap) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockMap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Location Location `json:"location"`
|
||||
Zoom int `json:"zoom"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"map", b.Location, b.Zoom, b.Width, b.Height, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockPhoto is a photo block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockPhoto struct {
|
||||
// Photo contains or identifies the associated photo.
|
||||
Photo []PhotoSize
|
||||
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||
HasSpoiler bool
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockPhoto) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockPhoto) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Photo []PhotoSize `json:"photo"`
|
||||
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"photo", b.Photo, b.HasSpoiler, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockVideo is a video block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockVideo struct {
|
||||
// Video contains the video rendered by the block.
|
||||
Video Video
|
||||
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||
HasSpoiler bool
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockVideo) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockVideo) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Video Video `json:"video"`
|
||||
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"video", b.Video, b.HasSpoiler, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockAudio is an audio block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockAudio struct {
|
||||
// Audio contains the audio rendered by the block.
|
||||
Audio Audio
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockAudio) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockAudio) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Audio Audio `json:"audio"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"audio", b.Audio, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockAnimation is an animation block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockAnimation struct {
|
||||
// Animation contains the animation rendered by the block.
|
||||
Animation Animation
|
||||
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||
HasSpoiler bool
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockAnimation) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockAnimation) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Animation Animation `json:"animation"`
|
||||
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"animation", b.Animation, b.HasSpoiler, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockVoiceNote is a voice note block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockVoiceNote struct {
|
||||
// VoiceNote contains the voice note rendered by the block.
|
||||
VoiceNote Voice
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption
|
||||
}
|
||||
|
||||
func (RichBlockVoiceNote) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockVoiceNote) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
VoiceNote Voice `json:"voice_note"`
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}{"voice_note", b.VoiceNote, b.Caption})
|
||||
}
|
||||
|
||||
// RichBlockDivider is a horizontal divider block.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockDivider struct{}
|
||||
|
||||
func (RichBlockDivider) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockDivider) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
}{"divider"})
|
||||
}
|
||||
|
||||
// RichBlockMathematicalExpression is a block-level mathematical expression.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockMathematicalExpression struct {
|
||||
// Expression contains the mathematical expression source.
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (RichBlockMathematicalExpression) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Expression string `json:"expression"`
|
||||
}{"mathematical_expression", b.Expression})
|
||||
}
|
||||
|
||||
// RichBlockAnchor is a named anchor block that anchor links can point to.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichBlockAnchor struct {
|
||||
// Name is the user-facing or reference name of the value.
|
||||
Name string
|
||||
}
|
||||
|
||||
func (RichBlockAnchor) isRichBlock() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (b RichBlockAnchor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}{"anchor", b.Name})
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package tgapi
|
||||
|
||||
// InputRichType identifies the JSON type of an input rich block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichType string
|
||||
|
||||
const (
|
||||
// InputRichTypeParagraph identifies a paragraph block.
|
||||
InputRichTypeParagraph InputRichType = "paragraph"
|
||||
// InputRichTypeSectionHeading identifies a section-heading block.
|
||||
InputRichTypeSectionHeading InputRichType = "heading"
|
||||
// InputRichTypePre identifies a preformatted block.
|
||||
InputRichTypePre InputRichType = "pre"
|
||||
// InputRichTypeFooter identifies a footer block.
|
||||
InputRichTypeFooter InputRichType = "footer"
|
||||
// InputRichTypeDivider identifies a divider block.
|
||||
InputRichTypeDivider InputRichType = "divider"
|
||||
// InputRichTypeMathematicalExpression identifies a mathematical-expression block.
|
||||
InputRichTypeMathematicalExpression InputRichType = "mathematical_expression"
|
||||
// InputRichTypeAnchor identifies an anchor block.
|
||||
InputRichTypeAnchor InputRichType = "anchor"
|
||||
// InputRichTypeList identifies a list block.
|
||||
InputRichTypeList InputRichType = "list"
|
||||
// InputRichTypeBlockQuotation identifies a block-quotation block.
|
||||
InputRichTypeBlockQuotation InputRichType = "blockquote"
|
||||
// InputRichTypePullQuotation identifies a pull-quotation block.
|
||||
InputRichTypePullQuotation InputRichType = "pullquote"
|
||||
// InputRichTypeCollage identifies a collage block.
|
||||
InputRichTypeCollage InputRichType = "collage"
|
||||
// InputRichTypeSlideshow identifies a slideshow block.
|
||||
InputRichTypeSlideshow InputRichType = "slideshow"
|
||||
// InputRichTypeTable identifies a table block.
|
||||
InputRichTypeTable InputRichType = "table"
|
||||
// InputRichTypeDetails identifies an expandable details block.
|
||||
InputRichTypeDetails InputRichType = "details"
|
||||
// InputRichTypeMap identifies a map block.
|
||||
InputRichTypeMap InputRichType = "map"
|
||||
// InputRichTypeAnimation identifies an animation block.
|
||||
InputRichTypeAnimation InputRichType = "animation"
|
||||
// InputRichTypeAudio identifies an audio block.
|
||||
InputRichTypeAudio InputRichType = "audio"
|
||||
// InputRichTypePhoto identifies a photo block.
|
||||
InputRichTypePhoto InputRichType = "photo"
|
||||
// InputRichTypeVideo identifies a video block.
|
||||
InputRichTypeVideo InputRichType = "video"
|
||||
// InputRichTypeVoiceNote identifies a voice-note block.
|
||||
InputRichTypeVoiceNote InputRichType = "voice_note"
|
||||
// InputRichTypeThinking identifies a thinking block.
|
||||
InputRichTypeThinking InputRichType = "thinking"
|
||||
)
|
||||
|
||||
// InputRichBlock represents a block available to format an outgoing rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlock interface {
|
||||
isInputRichBlock()
|
||||
}
|
||||
|
||||
// InputRichBlockParagraph is a text paragraph corresponding to the HTML <p> 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 <h1> through <h6> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockSectionHeading struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText `json:"text"`
|
||||
// Size selects the section heading level from 1 through 6.
|
||||
Size uint8 `json:"size"`
|
||||
}
|
||||
|
||||
func (InputRichBlockSectionHeading) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockPreformatted is a preformatted text block corresponding to nested HTML <pre> and <code> 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 <footer> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockFooter 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 (InputRichBlockFooter) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockDivider is a divider corresponding to the HTML <hr/> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockDivider struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
}
|
||||
|
||||
func (InputRichBlockDivider) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockMath is a block containing a mathematical expression in LaTeX format,
|
||||
// corresponding to the custom HTML <tg-math-block> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockMath struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Expression contains the mathematical expression source.
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
func (InputRichBlockMath) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockAnchor is a block containing an anchor corresponding to an HTML <a> tag with a name attribute.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockAnchor struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Name is the user-facing or reference name of the value.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (InputRichBlockAnchor) isInputRichBlock() {}
|
||||
|
||||
// RichBlockListItemType identifies an ordered-list label style.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type RichBlockListItemType string
|
||||
|
||||
const (
|
||||
// InputRichBlockListItemTypeLower uses lowercase letters.
|
||||
InputRichBlockListItemTypeLower RichBlockListItemType = "a"
|
||||
// InputRichBlockListItemTypeUpper uses uppercase letters.
|
||||
InputRichBlockListItemTypeUpper RichBlockListItemType = "A"
|
||||
// InputRichBlockListItemTypeRomanLow uses lowercase Roman numerals.
|
||||
InputRichBlockListItemTypeRomanLow RichBlockListItemType = "i"
|
||||
// InputRichBlockListItemTypeRomanUpper uses uppercase Roman numerals.
|
||||
InputRichBlockListItemTypeRomanUpper RichBlockListItemType = "I"
|
||||
// InputRichBlockListItemTypeDecimal uses decimal numbers.
|
||||
InputRichBlockListItemTypeDecimal RichBlockListItemType = "1"
|
||||
)
|
||||
|
||||
// InputRichBlockListItem represents an item in an input rich-message list.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockListItem struct {
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks"`
|
||||
// HasCheckbox reports whether the list item includes a checkbox.
|
||||
HasCheckbox bool `json:"has_checkbox,omitempty"`
|
||||
// IsChecked reports whether the list-item checkbox is checked.
|
||||
IsChecked bool `json:"is_checked,omitempty"`
|
||||
// Value sets the numeric marker value for an ordered list item.
|
||||
Value int `json:"value,omitempty"`
|
||||
// Type is the Bot API type discriminator.
|
||||
Type RichBlockListItemType `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// NewInputRichBlockListItem creates a list item containing blocks.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func NewInputRichBlockListItem(blocks ...InputRichBlock) *InputRichBlockListItem {
|
||||
return &InputRichBlockListItem{Blocks: blocks}
|
||||
}
|
||||
|
||||
// SetCheckbox configures whether the list item has a checkbox.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *InputRichBlockListItem) SetCheckbox(hasCheckbox bool) *InputRichBlockListItem {
|
||||
i.HasCheckbox = hasCheckbox
|
||||
return i
|
||||
}
|
||||
|
||||
// Check marks the list item's checkbox as checked.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *InputRichBlockListItem) Check() *InputRichBlockListItem {
|
||||
i.IsChecked = true
|
||||
return i
|
||||
}
|
||||
|
||||
// SetValue sets the numeric value of an ordered-list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *InputRichBlockListItem) SetValue(val int) *InputRichBlockListItem {
|
||||
i.Value = val
|
||||
return i
|
||||
}
|
||||
|
||||
// SetType sets the label style of an ordered-list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *InputRichBlockListItem) SetType(t RichBlockListItemType) *InputRichBlockListItem {
|
||||
i.Type = t
|
||||
return i
|
||||
}
|
||||
|
||||
// InputRichBlockList is a list of input rich-message blocks.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockList struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Items contains the list items.
|
||||
Items []InputRichBlockListItem `json:"items"`
|
||||
}
|
||||
|
||||
func (InputRichBlockList) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockBlockQuotation is a block quotation in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockBlockQuotation struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks"`
|
||||
// Credit contains attribution displayed with the block.
|
||||
Credit *RichText `json:"credit,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockBlockQuotation) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockPullQuotation is a centered quotation in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockPullQuotation struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText `json:"text"`
|
||||
// Credit contains attribution displayed with the block.
|
||||
Credit *RichText `json:"credit,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockPullQuotation) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockCollage is a collage in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockCollage struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockCollage) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockSlideshow is a slideshow in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockSlideshow struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockSlideshow) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockTable is a table in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockTable struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Cells contains the table rows and cells.
|
||||
Cells [][]RichBlockTableCell `json:"cells"`
|
||||
// IsBordered requests visible table borders.
|
||||
IsBordered bool `json:"is_bordered,omitempty"`
|
||||
// IsStriped requests alternating table row styling.
|
||||
IsStriped bool `json:"is_striped,omitempty"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichText `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockTable) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockDetails is an expandable block in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockDetails struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Summary contains the visible summary of a details block.
|
||||
Summary RichText `json:"summary"`
|
||||
// Blocks contains the nested rich-message blocks.
|
||||
Blocks []InputRichBlock `json:"blocks"`
|
||||
// IsOpen requests the details block to be expanded initially.
|
||||
IsOpen bool `json:"is_open,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockDetails) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockMap is a location map in an input rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockMap struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Location contains the map location.
|
||||
Location Location `json:"location"`
|
||||
// Zoom sets the map zoom level.
|
||||
Zoom uint8 `json:"zoom,omitempty"`
|
||||
// Width is the requested media or map width in pixels.
|
||||
Width uint16 `json:"width,omitempty"`
|
||||
// Height is the requested media or map height in pixels.
|
||||
Height uint16 `json:"height,omitempty"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockMap) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockAnimation is an animation block corresponding to the HTML <video> tag.
|
||||
// The animation caption is ignored; use Caption instead.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockAnimation struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Animation contains the animation rendered by the block.
|
||||
Animation InputMedia `json:"animation"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockAnimation) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockAudio is a music-file block corresponding to the HTML <audio> tag.
|
||||
// The audio caption is ignored; use Caption instead.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockAudio struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Audio contains the audio rendered by the block.
|
||||
Audio InputMedia `json:"audio"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockAudio) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockPhoto is a photo block corresponding to the HTML <img> tag.
|
||||
// The photo caption is ignored; use Caption instead.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockPhoto struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Photo contains or identifies the associated photo.
|
||||
Photo InputMedia `json:"photo"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockPhoto) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockVideo is a video block corresponding to the HTML <video> tag.
|
||||
// The video caption is ignored; use Caption instead.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockVideo struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// Video contains the video rendered by the block.
|
||||
Video InputMedia `json:"video"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockVideo) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockVoiceNote is a voice-note block corresponding to the HTML <audio> tag.
|
||||
// The voice-note caption is ignored; use Caption instead.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockVoiceNote struct {
|
||||
// Type is the Bot API type discriminator.
|
||||
Type InputRichType `json:"type"`
|
||||
// VoiceNote contains the voice note rendered by the block.
|
||||
VoiceNote InputMedia `json:"voice_note"`
|
||||
// Caption contains the media or block caption.
|
||||
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||
}
|
||||
|
||||
func (InputRichBlockVoiceNote) isInputRichBlock() {}
|
||||
|
||||
// InputRichBlockThinking is a block for displaying a thinking state.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type InputRichBlockThinking 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 (InputRichBlockThinking) isInputRichBlock() {}
|
||||
@@ -0,0 +1,65 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInputRichMediaBlocksMarshal(t *testing.T) {
|
||||
caption := RichBlockCaption{Text: RichTextPlain("caption")}
|
||||
cases := []struct {
|
||||
name string
|
||||
block InputRichBlock
|
||||
blockType InputRichType
|
||||
mediaKey string
|
||||
mediaType InputMediaType
|
||||
}{
|
||||
{"animation", InputRichBlockAnimation{Type: InputRichTypeAnimation, Animation: InputMedia{Type: InputMediaTypeAnimation, Media: "animation-id"}, Caption: &caption}, InputRichTypeAnimation, "animation", InputMediaTypeAnimation},
|
||||
{"audio", InputRichBlockAudio{Type: InputRichTypeAudio, Audio: InputMedia{Type: InputMediaTypeAudio, Media: "audio-id"}, Caption: &caption}, InputRichTypeAudio, "audio", InputMediaTypeAudio},
|
||||
{"photo", InputRichBlockPhoto{Type: InputRichTypePhoto, Photo: InputMedia{Type: InputMediaTypePhoto, Media: "photo-id"}, Caption: &caption}, InputRichTypePhoto, "photo", InputMediaTypePhoto},
|
||||
{"video", InputRichBlockVideo{Type: InputRichTypeVideo, Video: InputMedia{Type: InputMediaTypeVideo, Media: "video-id"}, Caption: &caption}, InputRichTypeVideo, "video", InputMediaTypeVideo},
|
||||
{"voice note", InputRichBlockVoiceNote{Type: InputRichTypeVoiceNote, VoiceNote: InputMedia{Type: InputMediaTypeVoiceNote, Media: "voice-id"}, Caption: &caption}, InputRichTypeVoiceNote, "voice_note", InputMediaTypeVoiceNote},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(InputRichMessage{Blocks: []InputRichBlock{tt.block}})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
var message struct {
|
||||
Blocks []map[string]json.RawMessage `json:"blocks"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &message); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if len(message.Blocks) != 1 {
|
||||
t.Fatalf("got %d blocks, want 1", len(message.Blocks))
|
||||
}
|
||||
|
||||
var blockType InputRichType
|
||||
if err := json.Unmarshal(message.Blocks[0]["type"], &blockType); err != nil {
|
||||
t.Fatalf("unmarshal block type: %v", err)
|
||||
}
|
||||
if blockType != tt.blockType {
|
||||
t.Errorf("block type = %q, want %q", blockType, tt.blockType)
|
||||
}
|
||||
|
||||
var media InputMedia
|
||||
if err := json.Unmarshal(message.Blocks[0][tt.mediaKey], &media); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", tt.mediaKey, err)
|
||||
}
|
||||
if media.Type != tt.mediaType {
|
||||
t.Errorf("media type = %q, want %q", media.Type, tt.mediaType)
|
||||
}
|
||||
if message.Blocks[0]["caption"] == nil {
|
||||
t.Error("caption is missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRichBlockMapImplementsInputRichBlock(t *testing.T) {
|
||||
var _ InputRichBlock = InputRichBlockMap{}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package tgapi
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RichText is a node of the rich formatted text tree: a plain string, an
|
||||
// array, or one of the typed objects below.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichText interface {
|
||||
isRichText()
|
||||
}
|
||||
|
||||
// RichTextPlain is a plain text leaf.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextPlain string
|
||||
|
||||
func (RichTextPlain) isRichText() {}
|
||||
|
||||
// RichTextArray is a concatenation of rich text nodes.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextArray []RichText
|
||||
|
||||
func (RichTextArray) isRichText() {}
|
||||
|
||||
// RichTextWrap covers all "pure" wrapper nodes with a single type.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextWrap struct {
|
||||
// Tag identifies the rich-text formatting wrapper.
|
||||
Tag string
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
}
|
||||
|
||||
func (RichTextWrap) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (w RichTextWrap) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
}{w.Tag, w.Text})
|
||||
}
|
||||
|
||||
var richTextWrapTags = map[string]bool{
|
||||
"bold": true, "italic": true, "underline": true,
|
||||
"strikethrough": true, "spoiler": true, "subscript": true,
|
||||
"superscript": true, "marked": true, "code": true,
|
||||
}
|
||||
|
||||
// RichTextURL is rich text linking to a URL.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextURL struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// URL contains the HTTP URL.
|
||||
URL string
|
||||
}
|
||||
|
||||
func (RichTextURL) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextURL) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
URL string `json:"url"`
|
||||
}{"url", v.Text, v.URL})
|
||||
}
|
||||
|
||||
// RichTextEmailAddress is rich text linking to an email address.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextEmailAddress struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// EmailAddress is the email address associated with the text.
|
||||
EmailAddress string
|
||||
}
|
||||
|
||||
func (RichTextEmailAddress) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
EmailAddress string `json:"email_address"`
|
||||
}{"email_address", v.Text, v.EmailAddress})
|
||||
}
|
||||
|
||||
// RichTextPhoneNumber is rich text linking to a phone number.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextPhoneNumber struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// PhoneNumber is the phone number associated with the text.
|
||||
PhoneNumber string
|
||||
}
|
||||
|
||||
func (RichTextPhoneNumber) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}{"phone_number", v.Text, v.PhoneNumber})
|
||||
}
|
||||
|
||||
// RichTextBankCardNumber is rich text marked as a bank card number.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextBankCardNumber struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// BankCardNumber is the bank card number associated with the text.
|
||||
BankCardNumber string
|
||||
}
|
||||
|
||||
func (RichTextBankCardNumber) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BankCardNumber string `json:"bank_card_number"`
|
||||
}{"bank_card_number", v.Text, v.BankCardNumber})
|
||||
}
|
||||
|
||||
// RichTextMention is rich text mentioning a user by username.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextMention struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Username is the username associated with the mention.
|
||||
Username string
|
||||
}
|
||||
|
||||
func (RichTextMention) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextMention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Username string `json:"username"`
|
||||
}{"mention", v.Text, v.Username})
|
||||
}
|
||||
|
||||
// RichTextHashtag is rich text marked as a hashtag.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextHashtag struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Hashtag is the hashtag associated with the text.
|
||||
Hashtag string
|
||||
}
|
||||
|
||||
func (RichTextHashtag) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextHashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Hashtag string `json:"hashtag"`
|
||||
}{"hashtag", v.Text, v.Hashtag})
|
||||
}
|
||||
|
||||
// RichTextCashtag is rich text marked as a cashtag.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextCashtag struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Cashtag is the cashtag associated with the text.
|
||||
Cashtag string
|
||||
}
|
||||
|
||||
func (RichTextCashtag) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextCashtag) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Cashtag string `json:"cashtag"`
|
||||
}{"cashtag", v.Text, v.Cashtag})
|
||||
}
|
||||
|
||||
// RichTextBotCommand is rich text marked as a bot command.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextBotCommand struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// BotCommand is the bot command associated with the text.
|
||||
BotCommand string
|
||||
}
|
||||
|
||||
func (RichTextBotCommand) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextBotCommand) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
BotCommand string `json:"bot_command"`
|
||||
}{"bot_command", v.Text, v.BotCommand})
|
||||
}
|
||||
|
||||
// RichTextAnchorLink is rich text linking to a named anchor in the same message.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextAnchorLink struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// AnchorName names the anchor targeted by the link.
|
||||
AnchorName string
|
||||
}
|
||||
|
||||
func (RichTextAnchorLink) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
AnchorName string `json:"anchor_name"`
|
||||
}{"anchor_link", v.Text, v.AnchorName})
|
||||
}
|
||||
|
||||
// RichTextReference is rich text marked as a named reference target.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextReference struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// Name is the user-facing or reference name of the value.
|
||||
Name string
|
||||
}
|
||||
|
||||
func (RichTextReference) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextReference) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
Name string `json:"name"`
|
||||
}{"reference", v.Text, v.Name})
|
||||
}
|
||||
|
||||
// RichTextReferenceLink is rich text linking to a named reference.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextReferenceLink struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// ReferenceName names the reference targeted by the link.
|
||||
ReferenceName string
|
||||
}
|
||||
|
||||
func (RichTextReferenceLink) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
ReferenceName string `json:"reference_name"`
|
||||
}{"reference_link", v.Text, v.ReferenceName})
|
||||
}
|
||||
|
||||
// RichTextDateTime is rich text bound to a point in time with a display format.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextDateTime struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// UnixTime is the Unix timestamp associated with the text.
|
||||
UnixTime int64
|
||||
// DateTimeFormat controls how the associated Unix time is displayed.
|
||||
DateTimeFormat string
|
||||
}
|
||||
|
||||
func (RichTextDateTime) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextDateTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
UnixTime int64 `json:"unix_time"`
|
||||
DateTimeFormat string `json:"date_time_format"`
|
||||
}{"date_time", v.Text, v.UnixTime, v.DateTimeFormat})
|
||||
}
|
||||
|
||||
// RichTextTextMention is rich text mentioning a user without a username.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextTextMention struct {
|
||||
// Text contains the formatted or plain text content.
|
||||
Text RichText
|
||||
// User contains the user associated with the value.
|
||||
User User
|
||||
}
|
||||
|
||||
func (RichTextTextMention) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextTextMention) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Text RichText `json:"text"`
|
||||
User User `json:"user"`
|
||||
}{"text_mention", v.Text, v.User})
|
||||
}
|
||||
|
||||
// RichTextCustomEmoji is a custom emoji leaf with alternative text.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextCustomEmoji struct {
|
||||
// CustomEmojiID identifies the custom emoji.
|
||||
CustomEmojiID string
|
||||
// AlternativeText is shown when the custom emoji can't be rendered.
|
||||
AlternativeText string
|
||||
}
|
||||
|
||||
func (RichTextCustomEmoji) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
CustomEmojiID string `json:"custom_emoji_id"`
|
||||
AlternativeText string `json:"alternative_text"`
|
||||
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
||||
}
|
||||
|
||||
// RichTextMathematicalExpression is an inline mathematical expression leaf.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextMathematicalExpression struct {
|
||||
// Expression contains the mathematical expression source.
|
||||
Expression string
|
||||
}
|
||||
|
||||
func (RichTextMathematicalExpression) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Expression string `json:"expression"`
|
||||
}{"mathematical_expression", v.Expression})
|
||||
}
|
||||
|
||||
// RichTextAnchor is a named anchor leaf that anchor links can point to.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
type RichTextAnchor struct {
|
||||
// Name is the user-facing or reference name of the value.
|
||||
Name string
|
||||
}
|
||||
|
||||
func (RichTextAnchor) isRichText() {}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (v RichTextAnchor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}{"anchor", v.Name})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundtripRichText(t *testing.T, in RichText) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
out, err := UnmarshalRichText(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", b, err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextRoundtrip(t *testing.T) {
|
||||
cases := []RichText{
|
||||
RichTextPlain("hello"),
|
||||
RichTextArray{RichTextPlain("a "), RichTextWrap{"bold", RichTextPlain("b")}, RichTextPlain(" c")},
|
||||
RichTextWrap{"bold", RichTextWrap{"italic", RichTextPlain("nested")}},
|
||||
RichTextURL{RichTextPlain("Anthropic"), "https://anthropic.com"},
|
||||
RichTextCustomEmoji{"5368324170671202286", "👍"},
|
||||
RichTextMathematicalExpression{"x^2 + y^2"},
|
||||
RichTextAnchor{"chapter-1"},
|
||||
RichTextDateTime{RichTextPlain("22:45 tomorrow"), 1647531900, "wDT"},
|
||||
RichTextTextMention{RichTextPlain("Bob"), User{ID: 42, FirstName: "Bob"}},
|
||||
RichTextAnchorLink{RichTextPlain("back to top"), ""},
|
||||
RichTextReference{RichTextPlain("ref"), "note-1"},
|
||||
// deep nesting
|
||||
RichTextWrap{"bold", RichTextArray{
|
||||
RichTextPlain("bold and "),
|
||||
RichTextWrap{"italic", RichTextWrap{"underline", RichTextPlain("deep")}},
|
||||
RichTextWrap{"spoiler", RichTextCustomEmoji{"1", "x"}},
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
roundtripRichText(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPlainFormsAreBare(t *testing.T) {
|
||||
b, _ := json.Marshal(RichTextPlain("hi"))
|
||||
if string(b) != `"hi"` {
|
||||
t.Fatalf("string should be bare: %s", b)
|
||||
}
|
||||
b, _ = json.Marshal(RichTextArray{RichTextPlain("a"), RichTextPlain("b")})
|
||||
if string(b) != `["a","b"]` {
|
||||
t.Fatalf("array should be bare: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextLeafHasNoText(t *testing.T) {
|
||||
b, _ := json.Marshal(RichTextAnchor{"x"})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["text"]; ok {
|
||||
t.Fatalf("anchor must not have text field: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalRichTextRejectsInvalidValues(t *testing.T) {
|
||||
tests := []string{
|
||||
`null`,
|
||||
`{"type":"date_time","text":"now","unix_time":"soon"}`,
|
||||
`{"type":"custom_emoji","custom_emoji_id":42}`,
|
||||
}
|
||||
for _, raw := range tests {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
if _, err := UnmarshalRichText([]byte(raw)); err == nil {
|
||||
t.Fatal("expected malformed rich text to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UnmarshalRichText parses a RichText tree from JSON: a string, an array, or
|
||||
// a typed object. Unknown object types that carry a text field are preserved
|
||||
// as RichTextWrap so their nested text remains usable; unmodeled fields are
|
||||
// discarded.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func UnmarshalRichText(data []byte) (RichText, error) {
|
||||
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||
return nil, fmt.Errorf("richtext: null is not a rich text value")
|
||||
}
|
||||
// 1. string
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
return RichTextPlain(s), nil
|
||||
}
|
||||
// 2. array
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err == nil {
|
||||
arr := make(RichTextArray, len(raw))
|
||||
for i, it := range raw {
|
||||
rt, err := UnmarshalRichText(it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr[i] = rt
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
// 3. object -> dispatch on type, grabbing the raw text along the way
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &head); err != nil {
|
||||
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
|
||||
}
|
||||
|
||||
// Recursively parse the nested text, if any.
|
||||
var inner RichText
|
||||
if len(head.Text) > 0 {
|
||||
var err error
|
||||
if inner, err = UnmarshalRichText(head.Text); err != nil {
|
||||
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
|
||||
}
|
||||
}
|
||||
|
||||
if richTextWrapTags[head.Type] {
|
||||
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||
}
|
||||
|
||||
switch head.Type {
|
||||
case "url":
|
||||
var v struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextURL{inner, v.URL}, nil
|
||||
case "email_address":
|
||||
var v struct {
|
||||
V string `json:"email_address"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextEmailAddress{inner, v.V}, nil
|
||||
case "phone_number":
|
||||
var v struct {
|
||||
V string `json:"phone_number"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextPhoneNumber{inner, v.V}, nil
|
||||
case "bank_card_number":
|
||||
var v struct {
|
||||
V string `json:"bank_card_number"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextBankCardNumber{inner, v.V}, nil
|
||||
case "mention":
|
||||
var v struct {
|
||||
V string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextMention{inner, v.V}, nil
|
||||
case "hashtag":
|
||||
var v struct {
|
||||
V string `json:"hashtag"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextHashtag{inner, v.V}, nil
|
||||
case "cashtag":
|
||||
var v struct {
|
||||
V string `json:"cashtag"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextCashtag{inner, v.V}, nil
|
||||
case "bot_command":
|
||||
var v struct {
|
||||
V string `json:"bot_command"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextBotCommand{inner, v.V}, nil
|
||||
case "anchor_link":
|
||||
var v struct {
|
||||
V string `json:"anchor_name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextAnchorLink{inner, v.V}, nil
|
||||
case "reference":
|
||||
var v struct {
|
||||
V string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextReference{inner, v.V}, nil
|
||||
case "reference_link":
|
||||
var v struct {
|
||||
V string `json:"reference_name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextReferenceLink{inner, v.V}, nil
|
||||
case "date_time":
|
||||
var v struct {
|
||||
UnixTime int64 `json:"unix_time"`
|
||||
DateTimeFormat string `json:"date_time_format"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
||||
case "text_mention":
|
||||
var v struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextTextMention{inner, v.User}, nil
|
||||
|
||||
// --- leaves without text ---
|
||||
case "custom_emoji":
|
||||
var v struct {
|
||||
ID string `json:"custom_emoji_id"`
|
||||
Alt string `json:"alternative_text"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextCustomEmoji{v.ID, v.Alt}, nil
|
||||
case "mathematical_expression":
|
||||
var v struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextMathematicalExpression{v.Expression}, nil
|
||||
case "anchor":
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichTextAnchor{v.Name}, nil
|
||||
|
||||
default:
|
||||
// forward-compat: keep an unknown tag with a text field as
|
||||
// RichTextWrap; without text it is an error (the shape cannot be guessed).
|
||||
if inner != nil {
|
||||
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("richtext: unknown type %q", head.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the
|
||||
// type tag. Unknown types that carry a text field are decoded as RichBlockWrap
|
||||
// so their nested text remains usable; unmodeled fields are discarded.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func UnmarshalRichBlock(data []byte) (RichBlock, error) {
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &head); err != nil {
|
||||
return nil, fmt.Errorf("richblock: %w", err)
|
||||
}
|
||||
|
||||
if richBlockWrapTags[head.Type] {
|
||||
text, err := parseOptRichText(head.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockWrap{Tag: head.Type, Text: text}, nil
|
||||
}
|
||||
|
||||
switch head.Type {
|
||||
case "heading":
|
||||
var v struct {
|
||||
Size int `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := parseOptRichText(head.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockSectionHeading{text, v.Size}, nil
|
||||
|
||||
case "pre":
|
||||
var v struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := parseOptRichText(head.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockPreformatted{text, v.Language}, nil
|
||||
|
||||
case "blockquote":
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
Credit json.RawMessage `json:"credit"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credit, err := parseOptRichText(raw.Credit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockQuotation{blocks, credit}, nil
|
||||
|
||||
case "pullquote":
|
||||
var raw struct {
|
||||
Credit json.RawMessage `json:"credit"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, err := parseOptRichText(head.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||
}
|
||||
credit, err := parseOptRichText(raw.Credit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockPullQuotation{text, credit}, nil
|
||||
|
||||
case "list":
|
||||
var v struct {
|
||||
Items []RichBlockListItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockList{v.Items}, nil
|
||||
|
||||
case "collage":
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockCollage{blocks, raw.Caption}, nil
|
||||
|
||||
case "slideshow":
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockSlideshow{blocks, raw.Caption}, nil
|
||||
|
||||
case "details":
|
||||
var raw struct {
|
||||
Summary json.RawMessage `json:"summary"`
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
IsOpen bool `json:"is_open"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := parseOptRichText(raw.Summary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: summary: %w", head.Type, err)
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockDetails{summary, blocks, raw.IsOpen}, nil
|
||||
|
||||
case "table":
|
||||
var raw struct {
|
||||
Cells [][]RichBlockTableCell `json:"cells"`
|
||||
IsBordered bool `json:"is_bordered"`
|
||||
IsStriped bool `json:"is_striped"`
|
||||
Caption json.RawMessage `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caption, err := parseOptRichText(raw.Caption)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("richblock %q: caption: %w", head.Type, err)
|
||||
}
|
||||
return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, caption}, nil
|
||||
|
||||
case "map":
|
||||
var v struct {
|
||||
Location Location `json:"location"`
|
||||
Zoom int `json:"zoom"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockMap{v.Location, v.Zoom, v.Width, v.Height, v.Caption}, nil
|
||||
|
||||
case "photo":
|
||||
var v struct {
|
||||
Photo []PhotoSize `json:"photo"`
|
||||
HasSpoiler bool `json:"has_spoiler"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockPhoto{v.Photo, v.HasSpoiler, v.Caption}, nil
|
||||
|
||||
case "video":
|
||||
var v struct {
|
||||
Video Video `json:"video"`
|
||||
HasSpoiler bool `json:"has_spoiler"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil
|
||||
|
||||
case "audio":
|
||||
var v struct {
|
||||
Audio Audio `json:"audio"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockAudio{v.Audio, v.Caption}, nil
|
||||
|
||||
case "animation":
|
||||
var v struct {
|
||||
Animation Animation `json:"animation"`
|
||||
HasSpoiler bool `json:"has_spoiler"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil
|
||||
|
||||
case "voice_note":
|
||||
var v struct {
|
||||
VoiceNote Voice `json:"voice_note"`
|
||||
Caption *RichBlockCaption `json:"caption"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil
|
||||
|
||||
case "divider":
|
||||
return RichBlockDivider{}, nil
|
||||
|
||||
case "mathematical_expression":
|
||||
var v struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockMathematicalExpression{v.Expression}, nil
|
||||
|
||||
case "anchor":
|
||||
var v struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RichBlockAnchor{v.Name}, nil
|
||||
|
||||
default:
|
||||
// forward-compat: unknown type with text -> RichBlockWrap, without text -> error.
|
||||
if text, err := parseOptRichText(head.Text); err == nil && text != nil {
|
||||
return RichBlockWrap{Tag: head.Type, Text: text}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("richblock: unknown type %q", head.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalRichMessage parses a root RichMessage from JSON.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func UnmarshalRichMessage(data []byte) (RichMessage, error) {
|
||||
var raw struct {
|
||||
Blocks json.RawMessage `json:"blocks"`
|
||||
IsRTL bool `json:"is_rtl"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return RichMessage{}, fmt.Errorf("richmessage: %w", err)
|
||||
}
|
||||
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||
if err != nil {
|
||||
return RichMessage{}, err
|
||||
}
|
||||
return RichMessage{blocks, raw.IsRTL}, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func (m *RichMessage) UnmarshalJSON(data []byte) error {
|
||||
parsed, err := UnmarshalRichMessage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*m = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Optional RichText fields treat absent and null values as nil.
|
||||
func parseOptRichText(raw json.RawMessage) (RichText, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
return UnmarshalRichText(raw)
|
||||
}
|
||||
|
||||
func unmarshalRichBlocks(raw json.RawMessage) ([]RichBlock, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var raws []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &raws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks := make([]RichBlock, len(raws))
|
||||
for i, r := range raws {
|
||||
b, err := UnmarshalRichBlock(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks[i] = b
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func roundtripRichBlock(t *testing.T, in RichBlock) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
out, err := UnmarshalRichBlock(b)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", b, err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
|
||||
func par(s string) RichBlockWrap { return RichBlockWrap{"paragraph", RichTextPlain(s)} }
|
||||
|
||||
func TestRichBlockRoundtrip(t *testing.T) {
|
||||
cases := []RichBlock{
|
||||
// wrap blocks
|
||||
par("Hello, world"),
|
||||
RichBlockWrap{"footer", RichTextPlain("© 2024")},
|
||||
RichBlockWrap{"thinking", RichTextPlain("Let me reason step by step.")},
|
||||
|
||||
// heading
|
||||
RichBlockSectionHeading{RichTextWrap{"bold", RichTextPlain("Chapter 1")}, 1},
|
||||
RichBlockSectionHeading{RichTextPlain("smallest"), 6},
|
||||
|
||||
// preformatted
|
||||
RichBlockPreformatted{RichTextPlain(`fmt.Println("hi")`), "go"},
|
||||
RichBlockPreformatted{Text: RichTextPlain("no language")},
|
||||
|
||||
// quotations
|
||||
RichBlockQuotation{[]RichBlock{par("To be or not to be")}, RichTextPlain("Shakespeare")},
|
||||
RichBlockQuotation{Blocks: []RichBlock{par("anonymous"), par("second block")}},
|
||||
RichBlockPullQuotation{Text: RichTextPlain("Pull me")},
|
||||
RichBlockPullQuotation{RichTextPlain("Wisdom"), RichTextWrap{"italic", RichTextPlain("someone")}},
|
||||
|
||||
// list: label is the ready-made marker, numbering lives on the items
|
||||
RichBlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Label: "c.", Blocks: []RichBlock{par("item 3")}, Value: 3, Type: "a"},
|
||||
{Label: "vii.", Blocks: []RichBlock{par("item 7")}, Value: 7, Type: "i"},
|
||||
},
|
||||
},
|
||||
RichBlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Label: "•", Blocks: []RichBlock{par("todo")}, HasCheckbox: true},
|
||||
{Label: "•", Blocks: []RichBlock{par("done")}, HasCheckbox: true, IsChecked: true},
|
||||
},
|
||||
},
|
||||
|
||||
// collage and slideshow
|
||||
RichBlockCollage{
|
||||
Blocks: []RichBlock{RichBlockPhoto{Photo: []PhotoSize{{FileID: "abc123", Width: 100, Height: 100}}}},
|
||||
Caption: &RichBlockCaption{Text: RichTextPlain("A photo")},
|
||||
},
|
||||
RichBlockSlideshow{
|
||||
Blocks: []RichBlock{
|
||||
RichBlockVideo{Video: Video{FileID: "vid1", Width: 640, Height: 480, Duration: 10}},
|
||||
},
|
||||
},
|
||||
|
||||
// details
|
||||
RichBlockDetails{
|
||||
Summary: RichTextPlain("Spoiler"),
|
||||
Blocks: []RichBlock{par("Hidden content")},
|
||||
},
|
||||
RichBlockDetails{
|
||||
Summary: RichTextWrap{"bold", RichTextPlain("Open details")},
|
||||
Blocks: []RichBlock{RichBlockDivider{}, par("content")},
|
||||
IsOpen: true,
|
||||
},
|
||||
|
||||
// table: text cells, headers, spans, alignment, invisible cell
|
||||
RichBlockTable{
|
||||
Cells: [][]RichBlockTableCell{
|
||||
{
|
||||
{Text: RichTextPlain("Name"), IsHeader: true, Align: "center"},
|
||||
{Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"},
|
||||
},
|
||||
{
|
||||
{Text: RichTextPlain("Alice"), ColSpan: 2},
|
||||
},
|
||||
{
|
||||
{}, // invisible cell
|
||||
{Text: RichTextPlain("42"), RowSpan: 2},
|
||||
},
|
||||
},
|
||||
IsBordered: true,
|
||||
Caption: RichTextPlain("Results"),
|
||||
},
|
||||
|
||||
// map
|
||||
RichBlockMap{
|
||||
Location: Location{Latitude: 55.7558, Longitude: 37.6173},
|
||||
Zoom: 13, Width: 800, Height: 400,
|
||||
Caption: &RichBlockCaption{Text: RichTextPlain("Moscow"), Credit: RichTextPlain("OpenStreetMap")},
|
||||
},
|
||||
|
||||
// media
|
||||
RichBlockPhoto{
|
||||
Photo: []PhotoSize{{FileID: "p1", Width: 1280, Height: 720}},
|
||||
HasSpoiler: true,
|
||||
Caption: &RichBlockCaption{Text: RichTextPlain("A cat"), Credit: RichTextWrap{"italic", RichTextPlain("photographer")}},
|
||||
},
|
||||
RichBlockVideo{Video: Video{FileID: "v1", Width: 1920, Height: 1080, Duration: 30}, HasSpoiler: true},
|
||||
RichBlockAudio{
|
||||
Audio: Audio{FileID: "a1", Duration: 60},
|
||||
Caption: &RichBlockCaption{Text: RichTextPlain("Podcast ep. 1")},
|
||||
},
|
||||
RichBlockAnimation{Animation: Animation{FileID: "g1", Width: 320, Height: 240, Duration: 2}},
|
||||
RichBlockVoiceNote{VoiceNote: Voice{FileID: "vn1", Duration: 5}},
|
||||
|
||||
// leaves
|
||||
RichBlockDivider{},
|
||||
RichBlockMathematicalExpression{Expression: "E = mc^2"},
|
||||
RichBlockAnchor{Name: "section-2"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
roundtripRichBlock(t, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichMessageRoundtrip(t *testing.T) {
|
||||
for _, msg := range []RichMessage{
|
||||
{
|
||||
Blocks: []RichBlock{
|
||||
RichBlockSectionHeading{RichTextPlain("Title"), 1},
|
||||
RichBlockWrap{"paragraph", RichTextArray{RichTextPlain("Some "), RichTextWrap{"bold", RichTextPlain("bold")}, RichTextPlain(" text")}},
|
||||
RichBlockDivider{},
|
||||
RichBlockList{
|
||||
Items: []RichBlockListItem{
|
||||
{Label: "1.", Blocks: []RichBlock{par("First")}, Value: 1, Type: "1"},
|
||||
{Label: "2.", Blocks: []RichBlock{par("Second")}, Value: 2, Type: "1"},
|
||||
},
|
||||
},
|
||||
RichBlockPhoto{
|
||||
Photo: []PhotoSize{{FileID: "img1", Width: 10, Height: 10}},
|
||||
Caption: &RichBlockCaption{Text: RichTextPlain("Fig. 1")},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Blocks: []RichBlock{par("שלום")},
|
||||
IsRTL: true,
|
||||
},
|
||||
} {
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out RichMessage
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
b2, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatalf("remarshal: %v", err)
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBlockTags(t *testing.T) {
|
||||
// tags per spec: heading, pre, blockquote, pullquote
|
||||
cases := map[string]RichBlock{
|
||||
"heading": RichBlockSectionHeading{RichTextPlain("h"), 2},
|
||||
"pre": RichBlockPreformatted{Text: RichTextPlain("x")},
|
||||
"blockquote": RichBlockQuotation{Blocks: []RichBlock{par("q")}},
|
||||
"pullquote": RichBlockPullQuotation{Text: RichTextPlain("p")},
|
||||
}
|
||||
for want, block := range cases {
|
||||
b, _ := json.Marshal(block)
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["type"] != want {
|
||||
t.Fatalf("expected type %q, got %s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBlockOptionalFieldsOmitted(t *testing.T) {
|
||||
// nil credit/caption and false flags must not appear in the JSON
|
||||
for _, c := range []struct {
|
||||
block RichBlock
|
||||
bad []string
|
||||
}{
|
||||
{RichBlockQuotation{Blocks: []RichBlock{par("q")}}, []string{"credit"}},
|
||||
{RichBlockPullQuotation{Text: RichTextPlain("p")}, []string{"credit"}},
|
||||
{RichBlockPhoto{Photo: []PhotoSize{{FileID: "p"}}}, []string{"caption", "has_spoiler"}},
|
||||
{RichBlockTable{Cells: [][]RichBlockTableCell{}}, []string{"caption", "is_bordered", "is_striped"}},
|
||||
{RichBlockDetails{Summary: RichTextPlain("s")}, []string{"is_open"}},
|
||||
} {
|
||||
b, _ := json.Marshal(c.block)
|
||||
for _, key := range c.bad {
|
||||
if strings.Contains(string(b), `"`+key+`"`) {
|
||||
t.Fatalf("%T: %q must be omitted: %s", c.block, key, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
// same for RichMessage.is_rtl
|
||||
b, _ := json.Marshal(RichMessage{Blocks: []RichBlock{par("x")}})
|
||||
if strings.Contains(string(b), "is_rtl") {
|
||||
t.Fatalf("is_rtl must be omitted: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBlockDividerHasNoContent(t *testing.T) {
|
||||
b, _ := json.Marshal(RichBlockDivider{})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if len(m) != 1 {
|
||||
t.Fatalf("divider must only have type field: %s", b)
|
||||
}
|
||||
if m["type"] != "divider" {
|
||||
t.Fatalf("unexpected type: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) {
|
||||
raw := []byte(`{"type":"future_tag","text":"hello"}`)
|
||||
b, err := UnmarshalRichBlock(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("forward-compat failed: %v", err)
|
||||
}
|
||||
w, ok := b.(RichBlockWrap)
|
||||
if !ok || w.Tag != "future_tag" {
|
||||
t.Fatalf("expected RichBlockWrap{future_tag}, got %T", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichBlockUnknownTypeWithoutTextIsError(t *testing.T) {
|
||||
raw := []byte(`{"type":"mystery_leaf","value":42}`)
|
||||
_, err := UnmarshalRichBlock(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown type without text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalRichBlockRejectsMalformedFields(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"type":"heading","size":"large","text":"hello"}`,
|
||||
`{"type":"blockquote","blocks":[],"credit":{"type":"date_time","text":"now","unix_time":"soon"}}`,
|
||||
`{"type":"table","cells":[],"caption":{"type":"date_time","text":"now","unix_time":"soon"}}`,
|
||||
}
|
||||
for _, raw := range tests {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
if _, err := UnmarshalRichBlock([]byte(raw)); err == nil {
|
||||
t.Fatal("expected malformed rich block to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ type SendSticker 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
|
||||
|
||||
Sticker string `json:"sticker"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
|
||||
+51
-4
@@ -63,6 +63,11 @@ const (
|
||||
|
||||
// UpdateTypeGuestMessage is a guest message update.
|
||||
UpdateTypeGuestMessage UpdateType = "guest_message"
|
||||
|
||||
// UpdateTypeSubscription is a bot subscription update.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
UpdateTypeSubscription UpdateType = "subscription"
|
||||
)
|
||||
|
||||
// Update represents an incoming update from Telegram.
|
||||
@@ -101,6 +106,8 @@ type Update struct {
|
||||
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` // Since: Bot API 7.0
|
||||
|
||||
ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"` // Since: Bot API 9.6
|
||||
// Subscription contains a bot subscription update.
|
||||
Subscription *BotSubscriptionUpdated `json:"subscription,omitempty"` // Since: Bot API 10.2
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an update and derives its Type from the populated payload field.
|
||||
@@ -168,6 +175,8 @@ func (u *Update) UnmarshalJSON(data []byte) error {
|
||||
u.Type = UpdateTypeRemovedChatBoost
|
||||
case u.ManagedBot != nil:
|
||||
u.Type = UpdateTypeManagedBot
|
||||
case u.Subscription != nil:
|
||||
u.Type = UpdateTypeSubscription
|
||||
default:
|
||||
u.Type = UpdateTypeUnknown
|
||||
}
|
||||
@@ -255,6 +264,11 @@ type ChatJoinRequest struct {
|
||||
Date int64 `json:"date"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
InviteLink *ChatInviteLink `json:"invite_link,omitempty"`
|
||||
|
||||
// QueryID identifies the join request query; present only for bots
|
||||
// assigned to process join requests. When set, the bot must call
|
||||
// SendChatJoinRequestWebApp or AnswerChatJoinRequestQuery within 10 seconds.
|
||||
QueryID *string `json:"query_id,omitempty"` // Since: Bot API 10.1
|
||||
}
|
||||
|
||||
// Location represents a point on the map.
|
||||
@@ -552,8 +566,11 @@ type WriteAccessAllowed struct {
|
||||
type BackgroundFillType string
|
||||
|
||||
const (
|
||||
BackgroundFillSolidType BackgroundFillType = "solid"
|
||||
BackgroundFillGradientType BackgroundFillType = "gradient"
|
||||
// BackgroundFillSolidType identifies a solid fill.
|
||||
BackgroundFillSolidType BackgroundFillType = "solid"
|
||||
// BackgroundFillGradientType identifies a two-color gradient.
|
||||
BackgroundFillGradientType BackgroundFillType = "gradient"
|
||||
// BackgroundFillFreeformGradientType identifies a freeform gradient.
|
||||
BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient"
|
||||
)
|
||||
|
||||
@@ -576,9 +593,13 @@ type BackgroundFill struct {
|
||||
type BackgroundTypeType string
|
||||
|
||||
const (
|
||||
BackgroundTypeFillType BackgroundTypeType = "fill"
|
||||
// BackgroundTypeFillType identifies a generated fill.
|
||||
BackgroundTypeFillType BackgroundTypeType = "fill"
|
||||
// BackgroundTypeWallpaperType identifies a wallpaper.
|
||||
BackgroundTypeWallpaperType BackgroundTypeType = "wallpaper"
|
||||
BackgroundTypePatternType BackgroundTypeType = "pattern"
|
||||
// BackgroundTypePatternType identifies a pattern.
|
||||
BackgroundTypePatternType BackgroundTypeType = "pattern"
|
||||
// BackgroundTypeChatThemeType identifies a chat theme.
|
||||
BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme"
|
||||
)
|
||||
|
||||
@@ -599,3 +620,29 @@ type BackgroundType struct {
|
||||
|
||||
ThemeName string `json:"theme_name,omitempty"`
|
||||
}
|
||||
|
||||
// BotSubscriptionState identifies the state of a user's subscription to the bot.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type BotSubscriptionState string
|
||||
|
||||
const (
|
||||
// BotSubscriptionCanceledState indicates that the user canceled the subscription.
|
||||
BotSubscriptionCanceledState BotSubscriptionState = "canceled"
|
||||
// BotSubscriptionActiveState indicates that the user re-enabled the subscription.
|
||||
BotSubscriptionActiveState BotSubscriptionState = "active"
|
||||
// BotSubscriptionFailedState indicates that subscription payment failed.
|
||||
BotSubscriptionFailedState BotSubscriptionState = "failed"
|
||||
)
|
||||
|
||||
// BotSubscriptionUpdated describes a change to a user's payment subscription to the bot.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type BotSubscriptionUpdated struct {
|
||||
// User contains the user associated with the value.
|
||||
User User `json:"user"`
|
||||
// InvoicePayload contains the bot-defined subscription invoice payload.
|
||||
InvoicePayload string `json:"invoice_payload"`
|
||||
// State is the new subscription state.
|
||||
State BotSubscriptionState `json:"state"`
|
||||
}
|
||||
|
||||
@@ -72,6 +72,18 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
||||
}`,
|
||||
want: UpdateTypeManagedBot,
|
||||
},
|
||||
{
|
||||
name: "subscription",
|
||||
body: `{
|
||||
"update_id": 6,
|
||||
"subscription": {
|
||||
"user": {"id": 13, "is_bot": false, "first_name": "Subscriber"},
|
||||
"invoice_payload": "monthly",
|
||||
"state": "active"
|
||||
}
|
||||
}`,
|
||||
want: UpdateTypeSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -89,6 +101,9 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
||||
if tt.want == UpdateTypeManagedBot && update.ManagedBot.Bot.ID != 12 {
|
||||
t.Fatalf("unexpected managed bot id: got %d want %d", update.ManagedBot.Bot.ID, 12)
|
||||
}
|
||||
if tt.want == UpdateTypeSubscription && update.Subscription.User.ID != 13 {
|
||||
t.Fatalf("unexpected subscription user id: got %d want %d", update.Subscription.User.ID, 13)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -61,6 +61,15 @@ func (f UploaderFile) SetType(t UploaderFileType) UploaderFile {
|
||||
return f
|
||||
}
|
||||
|
||||
// SetAttachName sets the multipart field name used by an attach:// reference.
|
||||
// The name must match the suffix of the corresponding InputMedia.Media value.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (f UploaderFile) SetAttachName(name string) UploaderFile {
|
||||
f.field = UploaderFileType(name)
|
||||
return f
|
||||
}
|
||||
|
||||
// Uploader is a Telegram Bot API client specialized for multipart file uploads.
|
||||
//
|
||||
// Use Uploader methods when you need to upload binary files directly
|
||||
@@ -155,7 +164,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
up.logger.Debugln("UPLOADER RES", url, string(body))
|
||||
up.logger.Debugln("UPLOADER RES", responseLogSummary(r.method, len(body)))
|
||||
|
||||
response, err := parseBody[R](body)
|
||||
if err != nil {
|
||||
@@ -167,7 +176,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
||||
after := *response.Parameters.RetryAfter
|
||||
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID)
|
||||
if up.api.Limiter != nil {
|
||||
if r.chatID > 0 {
|
||||
if r.chatID != 0 {
|
||||
up.api.Limiter.SetChatLock(r.chatID, after)
|
||||
} else {
|
||||
up.api.Limiter.SetGlobalLock(after)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tgapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -105,6 +106,17 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploaderRejectsDirectRichMessageDraftUpload(t *testing.T) {
|
||||
uploader := &Uploader{}
|
||||
_, err := uploader.SendRichMessageDraft(
|
||||
SendRichMessageDraft{ChatID: 42, DraftID: 1},
|
||||
NewUploaderFile("photo.jpg", []byte("photo")),
|
||||
)
|
||||
if !errors.Is(err, ErrRichMessageDraftUploadUnsupported) {
|
||||
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) {
|
||||
const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
|
||||
|
||||
@@ -177,6 +189,141 @@ func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploaderSendLivePhotoUsesRequiredMultipartFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
send func(*Uploader) (Message, error)
|
||||
}{
|
||||
{
|
||||
name: "background context",
|
||||
send: func(uploader *Uploader) (Message, error) {
|
||||
return uploader.SendLivePhoto(
|
||||
UploadLivePhoto{ChatID: 42},
|
||||
NewUploaderFile("live.mp4", []byte("video")),
|
||||
NewUploaderFile("photo.jpg", []byte("image")),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit context",
|
||||
send: func(uploader *Uploader) (Message, error) {
|
||||
return uploader.SendLivePhotoWithContext(
|
||||
context.Background(),
|
||||
UploadLivePhoto{ChatID: 42},
|
||||
NewUploaderFile("live.mp4", []byte("video")),
|
||||
NewUploaderFile("photo.jpg", []byte("image")),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var (
|
||||
gotPath string
|
||||
gotFiles map[string]multipartFile
|
||||
parseErr error
|
||||
)
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
gotFiles, parseErr = readMultipartFiles(req)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
api := NewAPI(
|
||||
NewAPIOpts("token").
|
||||
SetAPIURL("https://example.test").
|
||||
SetHTTPClient(client),
|
||||
)
|
||||
defer func() {
|
||||
if err := api.Close(); err != nil {
|
||||
t.Errorf("Close API returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
uploader := NewUploader(api)
|
||||
defer func() {
|
||||
if err := uploader.Close(); err != nil {
|
||||
t.Errorf("Close uploader returned error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tt.send(uploader); err != nil {
|
||||
t.Fatalf("SendLivePhoto returned error: %v", err)
|
||||
}
|
||||
if parseErr != nil {
|
||||
t.Fatalf("multipart parse failed: %v", parseErr)
|
||||
}
|
||||
if gotPath != "/bottoken/sendLivePhoto" {
|
||||
t.Fatalf("unexpected request path: %q", gotPath)
|
||||
}
|
||||
assertMultipartFile(t, gotFiles, "live_photo", "live.mp4", "video")
|
||||
assertMultipartFile(t, gotFiles, "photo", "photo.jpg", "image")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMultipartRichMessageUsesAttachName(t *testing.T) {
|
||||
params := SendRichMessage{
|
||||
ChatID: 42,
|
||||
RichMessage: InputRichMessage{Blocks: []InputRichBlock{
|
||||
InputRichBlockAnimation{
|
||||
Type: InputRichTypeAnimation,
|
||||
Animation: InputMedia{Type: InputMediaTypeAnimation, Media: "attach://animation"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
buf, contentType, err := prepareMultipart(
|
||||
[]UploaderFile{NewUploaderFile("animation.mp4", []byte("animation")).SetAttachName("animation")},
|
||||
params,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareMultipart returned error: %v", err)
|
||||
}
|
||||
|
||||
_, contentTypeParams, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType returned error: %v", err)
|
||||
}
|
||||
reader := multipart.NewReader(buf, contentTypeParams["boundary"])
|
||||
|
||||
parts := make(map[string]string)
|
||||
var fileData []byte
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart returned error: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll returned error: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
if part.FormName() != "animation" {
|
||||
t.Errorf("file form name = %q, want animation", part.FormName())
|
||||
}
|
||||
fileData = data
|
||||
continue
|
||||
}
|
||||
parts[part.FormName()] = string(data)
|
||||
}
|
||||
|
||||
if string(fileData) != "animation" {
|
||||
t.Errorf("file data = %q, want animation", fileData)
|
||||
}
|
||||
if got := parts["rich_message"]; !strings.Contains(got, `"media":"attach://animation"`) {
|
||||
t.Errorf("rich_message = %s, want attach reference", got)
|
||||
}
|
||||
}
|
||||
|
||||
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
|
||||
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
@@ -209,3 +356,48 @@ func readMultipartRequest(req *http.Request) (map[string]string, string, []byte,
|
||||
fields[part.FormName()] = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
type multipartFile struct {
|
||||
name string
|
||||
data string
|
||||
}
|
||||
|
||||
func readMultipartFiles(req *http.Request) (map[string]multipartFile, error) {
|
||||
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := multipart.NewReader(req.Body, params["boundary"])
|
||||
files := make(map[string]multipartFile)
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
return files, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if part.FileName() == "" {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[part.FormName()] = multipartFile{name: part.FileName(), data: string(data)}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMultipartFile(t *testing.T, files map[string]multipartFile, field, name, data string) {
|
||||
t.Helper()
|
||||
file, ok := files[field]
|
||||
if !ok {
|
||||
t.Fatalf("multipart field %q is missing", field)
|
||||
}
|
||||
if file.name != name {
|
||||
t.Errorf("multipart field %q filename = %q, want %q", field, file.name, name)
|
||||
}
|
||||
if file.data != data {
|
||||
t.Errorf("multipart field %q data = %q, want %q", field, file.data, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,45 @@ package tgapi
|
||||
|
||||
import "context"
|
||||
|
||||
// SendRichMessage uploads files referenced by attach:// names in params.RichMessage
|
||||
// and sends the rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (u *Uploader) SendRichMessage(params SendRichMessage, files ...UploaderFile) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message]("sendRichMessage", params, params.ChatID, files...)
|
||||
return req.Do(u)
|
||||
}
|
||||
|
||||
// SendRichMessageWithContext uploads files referenced by attach:// names in params.RichMessage
|
||||
// and sends the rich message using ctx for cancellation and deadlines.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (u *Uploader) SendRichMessageWithContext(ctx context.Context, params SendRichMessage, files ...UploaderFile) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message]("sendRichMessage", params, params.ChatID, files...)
|
||||
return req.DoWithContext(ctx, u)
|
||||
}
|
||||
|
||||
// SendRichMessageDraft streams a rich-message draft without direct file uploads.
|
||||
// It returns ErrRichMessageDraftUploadUnsupported when files is non-empty.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (u *Uploader) SendRichMessageDraft(params SendRichMessageDraft, files ...UploaderFile) (bool, error) {
|
||||
if len(files) > 0 {
|
||||
return false, ErrRichMessageDraftUploadUnsupported
|
||||
}
|
||||
return u.api.SendRichMessageDraft(params)
|
||||
}
|
||||
|
||||
// SendRichMessageDraftWithContext is the context-aware variant of SendRichMessageDraft.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (u *Uploader) SendRichMessageDraftWithContext(ctx context.Context, params SendRichMessageDraft, files ...UploaderFile) (bool, error) {
|
||||
if len(files) > 0 {
|
||||
return false, ErrRichMessageDraftUploadUnsupported
|
||||
}
|
||||
return u.api.SendRichMessageDraftWithContext(ctx, params)
|
||||
}
|
||||
|
||||
// UploadPhoto holds parameters for uploading a photo using the Uploader.
|
||||
// Since: Bot API 1.0
|
||||
// See https://core.telegram.org/bots/api#sendphoto
|
||||
@@ -10,6 +49,10 @@ type UploadPhoto 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
|
||||
|
||||
Caption string `json:"caption,omitempty"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
@@ -53,6 +96,10 @@ type UploadAudio 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
|
||||
|
||||
Caption string `json:"caption,omitempty"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
@@ -98,6 +145,10 @@ type UploadDocument 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
|
||||
|
||||
Caption string `json:"caption,omitempty"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
@@ -140,6 +191,10 @@ type UploadVideo 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
|
||||
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
@@ -189,6 +244,10 @@ type UploadAnimation 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
|
||||
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
@@ -236,6 +295,10 @@ type UploadVoice 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
|
||||
|
||||
Caption string `json:"caption,omitempty"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
@@ -278,6 +341,10 @@ type UploadVideoNote 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
|
||||
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Length int `json:"length,omitempty"`
|
||||
@@ -375,6 +442,10 @@ type UploadLivePhoto struct {
|
||||
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
|
||||
Caption string `json:"caption,omitempty"`
|
||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||
@@ -391,20 +462,30 @@ type UploadLivePhoto struct {
|
||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// SendLivePhoto uploads a live photo via multipart and sends it as a message.
|
||||
// SendLivePhoto uploads a live-photo video and its static image via multipart.
|
||||
// livePhoto is sent in the live_photo field and photo in the photo field.
|
||||
// Since: Bot API 10.0
|
||||
// file is the live photo file to upload.
|
||||
// See https://core.telegram.org/bots/api#sendlivephoto
|
||||
func (u *Uploader) SendLivePhoto(params UploadLivePhoto, file UploaderFile) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType))
|
||||
func (u *Uploader) SendLivePhoto(params UploadLivePhoto, livePhoto, photo UploaderFile) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message](
|
||||
"sendLivePhoto", params, params.ChatID,
|
||||
livePhoto.SetType(UploaderLivePhotoType),
|
||||
photo.SetType(UploaderPhotoType),
|
||||
)
|
||||
return req.Do(u)
|
||||
}
|
||||
|
||||
// SendLivePhotoWithContext is the context-aware variant of SendLivePhoto.
|
||||
// SendLivePhotoWithContext uploads a live-photo video and its static image via
|
||||
// multipart using ctx for cancellation and deadlines.
|
||||
// Since: Bot API 10.0
|
||||
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||
// See https://core.telegram.org/bots/api#sendlivephoto
|
||||
func (u *Uploader) SendLivePhotoWithContext(ctx context.Context, params UploadLivePhoto, file UploaderFile) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType))
|
||||
func (u *Uploader) SendLivePhotoWithContext(
|
||||
ctx context.Context, params UploadLivePhoto, livePhoto, photo UploaderFile,
|
||||
) (Message, error) {
|
||||
req := NewUploaderRequestWithChatID[Message](
|
||||
"sendLivePhoto", params, params.ChatID,
|
||||
livePhoto.SetType(UploaderLivePhotoType),
|
||||
photo.SetType(UploaderPhotoType),
|
||||
)
|
||||
return req.DoWithContext(ctx, u)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ type User struct {
|
||||
AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` // Since: Bot API 9.4
|
||||
CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6
|
||||
SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0
|
||||
|
||||
// SupportsJoinRequestQueries reports that the bot supports join request
|
||||
// queries and can be assigned to process them. Returned only in getMe.
|
||||
SupportsJoinRequestQueries *bool `json:"supports_join_request_queries,omitempty"` // Since: Bot API 10.1
|
||||
}
|
||||
|
||||
// UserProfilePhotos represents a user's profile photos.
|
||||
|
||||
+12
-41
@@ -2,7 +2,6 @@ package tgfmt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTML is an escaped Telegram HTML fragment.
|
||||
@@ -11,43 +10,25 @@ import (
|
||||
type HTML string
|
||||
|
||||
// EscapeHTML escapes special characters for Telegram HTML parse mode.
|
||||
func EscapeHTML(s string) HTML {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return HTML(s)
|
||||
}
|
||||
func EscapeHTML(s string) HTML { return HTML(escapeHTML(s)) }
|
||||
|
||||
// Bold returns h wrapped as bold Telegram HTML text.
|
||||
func (h HTML) Bold() HTML {
|
||||
return "<b>" + h + "</b>"
|
||||
}
|
||||
func (h HTML) Bold() HTML { return "<b>" + h + "</b>" }
|
||||
|
||||
// Italic returns h wrapped as italic Telegram HTML text.
|
||||
func (h HTML) Italic() HTML {
|
||||
return "<i>" + h + "</i>"
|
||||
}
|
||||
func (h HTML) Italic() HTML { return "<i>" + h + "</i>" }
|
||||
|
||||
// Underline returns h wrapped as underlined Telegram HTML text.
|
||||
func (h HTML) Underline() HTML {
|
||||
return "<u>" + h + "</u>"
|
||||
}
|
||||
func (h HTML) Underline() HTML { return "<u>" + h + "</u>" }
|
||||
|
||||
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
|
||||
func (h HTML) Strikethrough() HTML {
|
||||
return "<s>" + h + "</s>"
|
||||
}
|
||||
func (h HTML) Strikethrough() HTML { return "<s>" + h + "</s>" }
|
||||
|
||||
// Spoiler returns h wrapped as spoiler Telegram HTML text.
|
||||
func (h HTML) Spoiler() HTML {
|
||||
return "<tg-spoiler>" + h + "</tg-spoiler>"
|
||||
}
|
||||
func (h HTML) Spoiler() HTML { return "<tg-spoiler>" + h + "</tg-spoiler>" }
|
||||
|
||||
// Link returns h as a Telegram HTML text link.
|
||||
func (h HTML) Link(url string) HTML {
|
||||
return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>"
|
||||
}
|
||||
func (h HTML) Link(url string) HTML { return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>" }
|
||||
|
||||
// Mention returns h as a Telegram HTML user mention.
|
||||
func (h HTML) Mention(userID int64) HTML {
|
||||
@@ -70,14 +51,10 @@ func (h HTML) TimeFormat(unix int64, format string) HTML {
|
||||
}
|
||||
|
||||
// InlineCode returns h wrapped as inline code Telegram HTML text.
|
||||
func (h HTML) InlineCode() HTML {
|
||||
return "<code>" + h + "</code>"
|
||||
}
|
||||
func (h HTML) InlineCode() HTML { return "<code>" + h + "</code>" }
|
||||
|
||||
// BlockCode returns h wrapped as a Telegram HTML code block.
|
||||
func (h HTML) BlockCode() HTML {
|
||||
return "<pre>" + h + "</pre>"
|
||||
}
|
||||
func (h HTML) BlockCode() HTML { return "<pre>" + h + "</pre>" }
|
||||
|
||||
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
|
||||
func (h HTML) BlockCodeLanguage(lang string) HTML {
|
||||
@@ -85,15 +62,9 @@ func (h HTML) BlockCodeLanguage(lang string) HTML {
|
||||
}
|
||||
|
||||
// Quote returns h as a Telegram HTML blockquote.
|
||||
func (h HTML) Quote() HTML {
|
||||
return "<blockquote>" + h + "</blockquote>"
|
||||
}
|
||||
func (h HTML) Quote() HTML { return "<blockquote>" + h + "</blockquote>" }
|
||||
|
||||
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
|
||||
func (h HTML) QuoteExpandable() HTML {
|
||||
return "<blockquote expandable>" + h + "</blockquote>"
|
||||
}
|
||||
func (h HTML) QuoteExpandable() HTML { return "<blockquote expandable>" + h + "</blockquote>" }
|
||||
|
||||
func escapeHTMLAttr(s string) HTML {
|
||||
return EscapeHTML(s)
|
||||
}
|
||||
func escapeHTMLAttr(s string) HTML { return HTML(escapeHTML(s)) }
|
||||
|
||||
+6
-22
@@ -2,7 +2,6 @@ package tgfmt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Markdown is an escaped legacy Telegram Markdown fragment.
|
||||
@@ -13,27 +12,16 @@ type Markdown string
|
||||
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
|
||||
//
|
||||
// Deprecated: Use EscapeMarkdownV2 instead.
|
||||
func EscapeMarkdown(s string) Markdown {
|
||||
s = strings.ReplaceAll(s, "_", `\_`)
|
||||
s = strings.ReplaceAll(s, "*", `\*`)
|
||||
s = strings.ReplaceAll(s, "[", `\[`)
|
||||
return Markdown(strings.ReplaceAll(s, "`", "\\`"))
|
||||
}
|
||||
func EscapeMarkdown(s string) Markdown { return Markdown(escapeMD(s)) }
|
||||
|
||||
// Bold returns s wrapped as bold legacy Telegram Markdown text.
|
||||
func (s Markdown) Bold() Markdown {
|
||||
return "*" + s + "*"
|
||||
}
|
||||
func (s Markdown) Bold() Markdown { return "*" + s + "*" }
|
||||
|
||||
// Italic returns s wrapped as italic legacy Telegram Markdown text.
|
||||
func (s Markdown) Italic() Markdown {
|
||||
return "_" + s + "_"
|
||||
}
|
||||
func (s Markdown) Italic() Markdown { return "_" + s + "_" }
|
||||
|
||||
// Link returns s as a legacy Telegram Markdown text link.
|
||||
func (s Markdown) Link(url string) Markdown {
|
||||
return "[" + s + "](" + Markdown(url) + ")"
|
||||
}
|
||||
func (s Markdown) Link(url string) Markdown { return "[" + s + "](" + Markdown(url) + ")" }
|
||||
|
||||
// Mention returns s as a legacy Telegram Markdown user mention.
|
||||
func (s Markdown) Mention(userID int64) Markdown {
|
||||
@@ -41,14 +29,10 @@ func (s Markdown) Mention(userID int64) Markdown {
|
||||
}
|
||||
|
||||
// InlineCode returns s wrapped as inline code legacy Telegram Markdown text.
|
||||
func (s Markdown) InlineCode() Markdown {
|
||||
return "`" + s + "`"
|
||||
}
|
||||
func (s Markdown) InlineCode() Markdown { return "`" + s + "`" }
|
||||
|
||||
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
|
||||
func (s Markdown) BlockCode() Markdown {
|
||||
return "```\n" + s + "\n```"
|
||||
}
|
||||
func (s Markdown) BlockCode() Markdown { return "```\n" + s + "\n```" }
|
||||
|
||||
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
|
||||
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
|
||||
|
||||
+9
-31
@@ -12,38 +12,22 @@ type MarkdownV2 string
|
||||
|
||||
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
|
||||
// https://core.telegram.org/bots/api#markdownv2-style
|
||||
func EscapeMarkdownV2(s string) MarkdownV2 {
|
||||
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
||||
for _, symbol := range symbols {
|
||||
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||
}
|
||||
return MarkdownV2(s)
|
||||
}
|
||||
func EscapeMarkdownV2(s string) MarkdownV2 { return MarkdownV2(escapeMDv2(s)) }
|
||||
|
||||
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Bold() MarkdownV2 {
|
||||
return "*" + s + "*"
|
||||
}
|
||||
func (s MarkdownV2) Bold() MarkdownV2 { return "*" + s + "*" }
|
||||
|
||||
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Italic() MarkdownV2 {
|
||||
return "_" + s + "_"
|
||||
}
|
||||
func (s MarkdownV2) Italic() MarkdownV2 { return "_" + s + "_" }
|
||||
|
||||
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Underline() MarkdownV2 {
|
||||
return "__" + s + "__"
|
||||
}
|
||||
func (s MarkdownV2) Underline() MarkdownV2 { return "__" + s + "__" }
|
||||
|
||||
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Strikethrough() MarkdownV2 {
|
||||
return "~" + s + "~"
|
||||
}
|
||||
func (s MarkdownV2) Strikethrough() MarkdownV2 { return "~" + s + "~" }
|
||||
|
||||
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) Spoiler() MarkdownV2 {
|
||||
return "||" + s + "||"
|
||||
}
|
||||
func (s MarkdownV2) Spoiler() MarkdownV2 { return "||" + s + "||" }
|
||||
|
||||
// Link returns s as a Telegram MarkdownV2 text link.
|
||||
func (s MarkdownV2) Link(url string) MarkdownV2 {
|
||||
@@ -72,14 +56,10 @@ func (s MarkdownV2) TimeFormat(unix uint64, format string) MarkdownV2 {
|
||||
}
|
||||
|
||||
// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text.
|
||||
func (s MarkdownV2) InlineCode() MarkdownV2 {
|
||||
return "`" + s + "`"
|
||||
}
|
||||
func (s MarkdownV2) InlineCode() MarkdownV2 { return "`" + s + "`" }
|
||||
|
||||
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
|
||||
func (s MarkdownV2) BlockCode() MarkdownV2 {
|
||||
return "```\n" + s + "\n```"
|
||||
}
|
||||
func (s MarkdownV2) BlockCode() MarkdownV2 { return "```\n" + s + "\n```" }
|
||||
|
||||
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
|
||||
func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 {
|
||||
@@ -92,9 +72,7 @@ func (s MarkdownV2) Quote() MarkdownV2 {
|
||||
}
|
||||
|
||||
// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote.
|
||||
func (s MarkdownV2) QuoteExpandable() MarkdownV2 {
|
||||
return "**>" + s
|
||||
}
|
||||
func (s MarkdownV2) QuoteExpandable() MarkdownV2 { return "**>" + s }
|
||||
|
||||
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
|
||||
+26
-1
@@ -1,6 +1,31 @@
|
||||
package tgfmt
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
|
||||
func escapeMD(s string) string {
|
||||
s = strings.ReplaceAll(s, "_", `\_`)
|
||||
s = strings.ReplaceAll(s, "*", `\*`)
|
||||
s = strings.ReplaceAll(s, "[", `\[`)
|
||||
s = strings.ReplaceAll(s, "`", "\\`")
|
||||
return s
|
||||
}
|
||||
func escapeMDv2(s string) string {
|
||||
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
||||
for _, symbol := range symbols {
|
||||
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
||||
func EscapePunctuation(s string) string {
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRichTextChars = 32768
|
||||
maxRichBlocks = 500
|
||||
maxRichDepth = 16
|
||||
maxRichMedia = 50
|
||||
maxTableColumns = 20
|
||||
)
|
||||
|
||||
// Text creates a plain rich-text node.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Text(s string) tgapi.RichText { return tgapi.RichTextPlain(s) }
|
||||
|
||||
// Bold applies bold formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Bold(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "bold", Text: t} }
|
||||
|
||||
// Italic applies italic formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Italic(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "italic", Text: t} }
|
||||
|
||||
// Underline applies underline formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Underline(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "underline", Text: t} }
|
||||
|
||||
// Strikethrough applies strikethrough formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Strikethrough(t tgapi.RichText) tgapi.RichText {
|
||||
return tgapi.RichTextWrap{Tag: "strikethrough", Text: t}
|
||||
}
|
||||
|
||||
// Spoiler hides t behind a spoiler.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Spoiler(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "spoiler", Text: t} }
|
||||
|
||||
// DateTime associates t with ts using Telegram's default date-time format.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func DateTime(t tgapi.RichText, ts time.Time) tgapi.RichText {
|
||||
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix()}
|
||||
}
|
||||
|
||||
// DateTimeWithFormat associates t with ts using format.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func DateTimeWithFormat(t tgapi.RichText, ts time.Time, format string) tgapi.RichText {
|
||||
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix(), DateTimeFormat: format}
|
||||
}
|
||||
|
||||
// TextMention mentions u with the display text t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func TextMention(t tgapi.RichText, u tgapi.User) tgapi.RichText {
|
||||
return tgapi.RichTextTextMention{Text: t, User: u}
|
||||
}
|
||||
|
||||
// Subscript applies subscript formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Subscript(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "subscript", Text: t} }
|
||||
|
||||
// Superscript applies superscript formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Superscript(t tgapi.RichText) tgapi.RichText {
|
||||
return tgapi.RichTextWrap{Tag: "superscript", Text: t}
|
||||
}
|
||||
|
||||
// Marked highlights t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Marked(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "marked", Text: t} }
|
||||
|
||||
// Code applies monospaced formatting to t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Code(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "code", Text: t} }
|
||||
|
||||
// Emoji creates a custom emoji with emojiID and alternative text t.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Emoji(t, emojiID string) tgapi.RichText {
|
||||
return tgapi.RichTextCustomEmoji{CustomEmojiID: emojiID, AlternativeText: t}
|
||||
}
|
||||
|
||||
// MathExpression creates an inline LaTeX expression.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func MathExpression(exp string) tgapi.RichTextMathematicalExpression {
|
||||
return tgapi.RichTextMathematicalExpression{Expression: exp}
|
||||
}
|
||||
|
||||
// URL links t to url.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func URL(t tgapi.RichText, url string) tgapi.RichTextURL { return tgapi.RichTextURL{Text: t, URL: url} }
|
||||
|
||||
// Email marks t as an email address.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Email(t tgapi.RichText, email string) tgapi.RichTextEmailAddress {
|
||||
return tgapi.RichTextEmailAddress{Text: t, EmailAddress: email}
|
||||
}
|
||||
|
||||
// Phone marks t as a phone number.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Phone(t tgapi.RichText, phone string) tgapi.RichTextPhoneNumber {
|
||||
return tgapi.RichTextPhoneNumber{Text: t, PhoneNumber: phone}
|
||||
}
|
||||
|
||||
// BankCardNumber marks t as a bank card number.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func BankCardNumber(t tgapi.RichText, number string) tgapi.RichTextBankCardNumber {
|
||||
return tgapi.RichTextBankCardNumber{Text: t, BankCardNumber: number}
|
||||
}
|
||||
|
||||
// Mention marks t as a mention of username.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Mention(t tgapi.RichText, username string) tgapi.RichTextMention {
|
||||
return tgapi.RichTextMention{Text: t, Username: username}
|
||||
}
|
||||
|
||||
// Hashtag marks t as hashtag.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Hashtag(t tgapi.RichText, hashtag string) tgapi.RichTextHashtag {
|
||||
return tgapi.RichTextHashtag{Text: t, Hashtag: hashtag}
|
||||
}
|
||||
|
||||
// Cashtag marks t as cashtag.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Cashtag(t tgapi.RichText, cashtag string) tgapi.RichTextCashtag {
|
||||
return tgapi.RichTextCashtag{Text: t, Cashtag: cashtag}
|
||||
}
|
||||
|
||||
// BotCommand marks t as command.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func BotCommand(t tgapi.RichText, command string) tgapi.RichTextBotCommand {
|
||||
return tgapi.RichTextBotCommand{Text: t, BotCommand: command}
|
||||
}
|
||||
|
||||
// TextAnchor creates an inline anchor named name.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func TextAnchor(name string) tgapi.RichTextAnchor {
|
||||
return tgapi.RichTextAnchor{Name: name}
|
||||
}
|
||||
|
||||
// AnchorLink links t to the anchor named name.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func AnchorLink(t tgapi.RichText, name string) tgapi.RichTextAnchorLink {
|
||||
return tgapi.RichTextAnchorLink{Text: t, AnchorName: name}
|
||||
}
|
||||
|
||||
// Reference defines t as a named reference target.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Reference(t tgapi.RichText, name string) tgapi.RichTextReference {
|
||||
return tgapi.RichTextReference{Text: t, Name: name}
|
||||
}
|
||||
|
||||
// ReferenceLink links t to the named reference name.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func ReferenceLink(t tgapi.RichText, name string) tgapi.RichTextReferenceLink {
|
||||
return tgapi.RichTextReferenceLink{Text: t, ReferenceName: name}
|
||||
}
|
||||
|
||||
// Concat concatenates rich-text nodes.
|
||||
//
|
||||
// Since: Bot API 10.1
|
||||
func Concat(items ...tgapi.RichText) tgapi.RichText { return tgapi.RichTextArray(items) }
|
||||
@@ -0,0 +1,112 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestRenderText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text tgapi.RichText
|
||||
want string
|
||||
}{
|
||||
{"plain", Text("<text&>"), "<text&>"},
|
||||
{"array", Concat(Bold(Text("bold")), Text(" plain")), "<b>bold</b> plain"},
|
||||
{"spoiler", Spoiler(Text("secret")), "<tg-spoiler>secret</tg-spoiler>"},
|
||||
{"date time", DateTimeWithFormat(Text("tomorrow"), time.Unix(1, 0), `w"DT`), `<tg-time unix="1" format="w"DT">tomorrow</tg-time>`},
|
||||
{"custom emoji", Emoji("🙂", `id"`), `<tg-emoji emoji-id="id"">🙂</tg-emoji>`},
|
||||
{"formula", MathExpression("x < y"), "<tg-math>x < y</tg-math>"},
|
||||
{"automatic entity", Hashtag(Text("#go"), "go"), "#go"},
|
||||
{"reference", Reference(Text("note"), "note-1"), `<tg-reference name="note-1">note</tg-reference>`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := renderText(tt.text, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("renderText() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTextRejectsUnknownWrapper(t *testing.T) {
|
||||
_, err := renderText(tgapi.RichTextWrap{Tag: "unknown", Text: Text("text")}, 0)
|
||||
if !errors.Is(err, ErrRichUnknownTag) {
|
||||
t.Fatalf("renderText() error = %v, want %v", err, ErrRichUnknownTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTextNestingLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
depth int
|
||||
want error
|
||||
}{
|
||||
{"maximum", maxRichDepth, nil},
|
||||
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
text := tgapi.RichText(Text("text"))
|
||||
for range tt.depth {
|
||||
text = Bold(text)
|
||||
}
|
||||
_, err := renderText(text, 0)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("renderText() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBlockNestingLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
depth int
|
||||
want error
|
||||
}{
|
||||
{"maximum", maxRichDepth, nil},
|
||||
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var block tgapi.InputRichBlock = P(Text("text"))
|
||||
for range tt.depth - 1 {
|
||||
block = Details(Text("summary"), block)
|
||||
}
|
||||
_, err := renderBlockHTML(block, 0)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombinedNestingLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
textDepth int
|
||||
want error
|
||||
}{
|
||||
{"maximum", maxRichDepth - 1, nil},
|
||||
{"too deep", maxRichDepth, ErrRichNestingTooDeep},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
text := tgapi.RichText(Text("text"))
|
||||
for range tt.textDepth {
|
||||
text = Bold(text)
|
||||
}
|
||||
|
||||
_, err := renderBlockHTML(P(text), 0)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestBuildHTMLBlocks(t *testing.T) {
|
||||
credit := Text("author")
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
want string
|
||||
}{
|
||||
{"paragraph", P(Bold(Text("text"))), "<p><b>text</b></p>"},
|
||||
{"heading", H2(Text("heading")), "<h2>heading</h2>"},
|
||||
{"pre", CodeBlock(Text("code"), "go"), `<pre><code class="language-go">code</code></pre>`},
|
||||
{"footer", Footer(Text("footer")), "<footer>footer</footer>"},
|
||||
{"divider", Hr(), "<hr/>"},
|
||||
{"math", Math("x < y"), "<tg-math-block>x < y</tg-math-block>"},
|
||||
{"anchor", Anchor(`a"b`), `<a name="a"b"></a>`},
|
||||
{"unordered list", Ul(NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()), "<ul>\n<li><input type=\"checkbox\" checked/><p>item</p></li></ul>"},
|
||||
{"blockquote", BlockQuoteWithCredit(credit, P(Text("quote"))), "<blockquote><p>quote</p><cite>author</cite></blockquote>"},
|
||||
{"pullquote", PullQuoteWithCredit(Text("quote"), credit), "<aside>quote<cite>author</cite></aside>"},
|
||||
{"table", NewTable(Row(CellWithText(Text("value")).Build())).SetCaption(&credit).Build(), "<table><caption>author</caption><tr><td>value</td></tr></table>"},
|
||||
{"details", DetailsOpen(Text("summary"), P(Text("body"))), "<details open><summary>summary</summary><p>body</p></details>"},
|
||||
{"map", Map(tgapi.Location{Latitude: 41.9, Longitude: 12.5}, 14, 640, 320), `<tg-map height="320" lat="41.9" long="12.5" width="640" zoom="14"/>`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message, err := BuildHTML(tt.block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != tt.want {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.want)
|
||||
}
|
||||
if message.SkipEntityDetection {
|
||||
t.Fatal("BuildHTML() must preserve Telegram's automatic entity detection")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingBlockIsDraftOnly(t *testing.T) {
|
||||
block := Thinking(Text("Thinking…"))
|
||||
if _, err := BuildHTML(block); !errors.Is(err, ErrRichThinkingDraftOnly) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, ErrRichThinkingDraftOnly)
|
||||
}
|
||||
message, err := BuildDraftHTML(block)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDraftHTML() returned error: %v", err)
|
||||
}
|
||||
if message.HTML != "<tg-thinking>Thinking…</tg-thinking>" {
|
||||
t.Fatalf("BuildDraftHTML() HTML = %q", message.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLValidatesAutomaticallyDetectedEntities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valid tgapi.RichText
|
||||
invalid tgapi.RichText
|
||||
}{
|
||||
{name: "bank card", valid: BankCardNumber(Text("1234"), "1234"), invalid: BankCardNumber(Text("5678"), "1234")},
|
||||
{name: "mention", valid: Mention(Text("@alice"), "alice"), invalid: Mention(Text("Alice"), "alice")},
|
||||
{name: "hashtag", valid: Hashtag(Text("#go"), "go"), invalid: Hashtag(Text("Go"), "go")},
|
||||
{name: "cashtag", valid: Cashtag(Text("$TON"), "TON"), invalid: Cashtag(Text("Toncoin"), "TON")},
|
||||
{name: "bot command", valid: BotCommand(Text("/start"), "start"), invalid: BotCommand(Text("Start"), "start")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := BuildHTML(P(tt.valid)); err != nil {
|
||||
t.Fatalf("valid entity returned error: %v", err)
|
||||
}
|
||||
if _, err := BuildHTML(P(tt.invalid)); !errors.Is(err, ErrRichEntityMismatch) {
|
||||
t.Fatalf("invalid entity error = %v, want ErrRichEntityMismatch", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDraftHTMLRejectsDirectUpload(t *testing.T) {
|
||||
_, err := BuildDraftHTML(Photo(tgapi.InputMedia{
|
||||
Type: tgapi.InputMediaTypePhoto,
|
||||
Media: "attach://photo",
|
||||
}))
|
||||
if !errors.Is(err, tgapi.ErrRichMessageDraftUploadUnsupported) {
|
||||
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLMedia(t *testing.T) {
|
||||
spoiler := true
|
||||
caption := CaptionWithCredit(Text("caption"), Text("credit"))
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
wantHTML string
|
||||
wantType tgapi.InputMediaType
|
||||
wantMedia string
|
||||
}{
|
||||
{"multipart photo", Photo(tgapi.InputMedia{Media: "attach://photo"}), `<img src="tg://photo?id=media_1"/>`, tgapi.InputMediaTypePhoto, "attach://photo"},
|
||||
{"video", Video(tgapi.InputMedia{Media: "video-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeVideo, "video-id"},
|
||||
{"animation", Animation(tgapi.InputMedia{Media: "animation-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeAnimation, "animation-id"},
|
||||
{"audio", Audio(tgapi.InputMedia{Media: "audio-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeAudio, "audio-id"},
|
||||
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeVoiceNote, "voice-id"},
|
||||
{"caption and spoiler", PhotoWithCaption(tgapi.InputMedia{Media: "photo-id", HasSpoiler: &spoiler}, caption), `<figure><img src="tg://photo?id=media_1" tg-spoiler/><figcaption>caption<cite>credit</cite></figcaption></figure>`, tgapi.InputMediaTypePhoto, "photo-id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
message, err := BuildHTML(tt.block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != tt.wantHTML {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.wantHTML)
|
||||
}
|
||||
if len(message.Media) != 1 {
|
||||
t.Fatalf("BuildHTML() media count = %d, want 1", len(message.Media))
|
||||
}
|
||||
media := message.Media[0]
|
||||
if media.ID != "media_1" || media.Media.Type != tt.wantType || media.Media.Media != tt.wantMedia {
|
||||
t.Fatalf("BuildHTML() media = %#v", media)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLNestedMediaUsesSharedIDs(t *testing.T) {
|
||||
message, err := BuildHTML(
|
||||
Collage(Photo(tgapi.InputMedia{Media: "photo"}), Video(tgapi.InputMedia{Media: "video"})),
|
||||
Audio(tgapi.InputMedia{Media: "audio"}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `<tg-collage><img src="tg://photo?id=media_1"/><video src="tg://video?id=media_2"></video></tg-collage><audio src="tg://audio?id=media_3"></audio>`
|
||||
if message.HTML != want {
|
||||
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, want)
|
||||
}
|
||||
if len(message.Media) != 3 {
|
||||
t.Fatalf("BuildHTML() media count = %d, want 3", len(message.Media))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLLimits(t *testing.T) {
|
||||
media := func() tgapi.InputRichBlock {
|
||||
return Photo(tgapi.InputMedia{Media: "photo"})
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
blocks func() []tgapi.InputRichBlock
|
||||
want error
|
||||
}{
|
||||
{"maximum UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars)))}
|
||||
}, nil},
|
||||
{"too many UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars+1)))}
|
||||
}, ErrRichTextTooLong},
|
||||
{"maximum blocks", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichBlocks)
|
||||
for i := range blocks {
|
||||
blocks[i] = Hr()
|
||||
}
|
||||
return blocks
|
||||
}, nil},
|
||||
{"too many blocks", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichBlocks+1)
|
||||
for i := range blocks {
|
||||
blocks[i] = Hr()
|
||||
}
|
||||
return blocks
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"list items count as blocks", func() []tgapi.InputRichBlock {
|
||||
items := make([]tgapi.InputRichBlockListItem, maxRichBlocks)
|
||||
return []tgapi.InputRichBlock{List(items...)}
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"table rows count as blocks", func() []tgapi.InputRichBlock {
|
||||
rows := make([][]tgapi.RichBlockTableCell, maxRichBlocks)
|
||||
return []tgapi.InputRichBlock{NewTable(rows...).Build()}
|
||||
}, ErrRichTooManyBlocks},
|
||||
{"maximum media", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichMedia)
|
||||
for i := range blocks {
|
||||
blocks[i] = media()
|
||||
}
|
||||
return blocks
|
||||
}, nil},
|
||||
{"too many media", func() []tgapi.InputRichBlock {
|
||||
blocks := make([]tgapi.InputRichBlock, maxRichMedia+1)
|
||||
for i := range blocks {
|
||||
blocks[i] = media()
|
||||
}
|
||||
return blocks
|
||||
}, ErrRichTooManyMedia},
|
||||
{"maximum table width", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns)...)).Build()}
|
||||
}, nil},
|
||||
{"table too wide", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns + 1)...)).Build()}
|
||||
}, ErrRichTableTooWide},
|
||||
{"colspan counts toward width", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{NewTable(Row(Cell().SetSpan(maxTableColumns+1, 1).Build())).Build()}
|
||||
}, ErrRichTableTooWide},
|
||||
{"maximum combined nesting", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth-1))}
|
||||
}, nil},
|
||||
{"combined nesting too deep", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth))}
|
||||
}, ErrRichNestingTooDeep},
|
||||
{"maximum block nesting", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth-1)}
|
||||
}, nil},
|
||||
{"block nesting too deep", func() []tgapi.InputRichBlock {
|
||||
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth)}
|
||||
}, ErrRichNestingTooDeep},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := BuildHTML(tt.blocks()...)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLRejectsInvalidFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
block tgapi.InputRichBlock
|
||||
want error
|
||||
}{
|
||||
{"mixed list", List(NewListItem().SetType(tgapi.InputRichBlockListItemTypeDecimal).Build(), NewListItem().Build()), ErrRichListItemMix},
|
||||
{"unordered value", List(NewListItem().SetValue(2).Build()), ErrRichInvalidListItem},
|
||||
{"invalid list type", List(NewListItem().SetType("x").Build()), ErrRichInvalidListItemType},
|
||||
{"checked without checkbox", List(NewListItem().SetChecked().Build()), ErrRichInvalidCheckbox},
|
||||
{"invalid block type", tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeFooter, Text: Text("text")}, ErrRichInvalidBlockType},
|
||||
{"heading too small", H(Text("heading"), 0), ErrRichInvalidHeading},
|
||||
{"heading too large", H(Text("heading"), 7), ErrRichInvalidHeading},
|
||||
{"media mismatch", tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: tgapi.InputMedia{Type: tgapi.InputMediaTypeVideo, Media: "video"}}, ErrRichInvalidMedia},
|
||||
{"empty media", Photo(tgapi.InputMedia{}), ErrRichInvalidMedia},
|
||||
{"map latitude", Map(tgapi.Location{Latitude: 91}, 0, 0, 0), ErrRichInvalidMap},
|
||||
{"map longitude", Map(tgapi.Location{Longitude: 181}, 0, 0, 0), ErrRichInvalidMap},
|
||||
{"map zoom", Map(tgapi.Location{}, 25, 0, 0), ErrRichInvalidMap},
|
||||
{"map total dimensions", Map(tgapi.Location{}, 0, 9000, 1001), ErrRichInvalidMap},
|
||||
{"map aspect ratio", Map(tgapi.Location{}, 0, 100, 4), ErrRichInvalidMap},
|
||||
{"negative colspan", NewTable(Row(Cell().SetSpan(-1, 0).Build())).Build(), ErrRichInvalidTableCell},
|
||||
{"invalid horizontal alignment", NewTable(Row(Cell().SetAlign("justify").Build())).Build(), ErrRichInvalidTableCell},
|
||||
{"invalid vertical alignment", NewTable(Row(Cell().SetVAlign("center").Build())).Build(), ErrRichInvalidTableCell},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := BuildHTML(tt.block)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML(t *testing.T) {
|
||||
message, err := ToHTML(P(Text("text")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message.HTML != "<p>text</p>" {
|
||||
t.Fatalf("ToHTML() HTML = %q", message.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCells(n int) []tgapi.RichBlockTableCell {
|
||||
return make([]tgapi.RichBlockTableCell, n)
|
||||
}
|
||||
|
||||
func wrapBold(text tgapi.RichText, depth int) tgapi.RichText {
|
||||
for range depth {
|
||||
text = Bold(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func wrapDetails(block tgapi.InputRichBlock, depth int) tgapi.InputRichBlock {
|
||||
for range depth {
|
||||
block = Details(Text("summary"), block)
|
||||
}
|
||||
return block
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package tgrich
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrRichTextTooLong indicates that rich-message text exceeds 32768 UTF-8 characters.
|
||||
ErrRichTextTooLong = errors.New("rich text too long")
|
||||
// ErrRichTooManyBlocks indicates that a rich message exceeds 500 blocks and counted nested items.
|
||||
ErrRichTooManyBlocks = errors.New("rich text too many blocks")
|
||||
// ErrRichNestingTooDeep indicates that rich formatting exceeds 16 nested levels.
|
||||
ErrRichNestingTooDeep = errors.New("rich text nesting deep")
|
||||
// ErrRichTooManyMedia indicates that a rich message contains more than 50 media attachments.
|
||||
ErrRichTooManyMedia = errors.New("rich text too many media")
|
||||
// ErrRichTableTooWide indicates that a table contains more than 20 columns.
|
||||
ErrRichTableTooWide = errors.New("rich text table too wide")
|
||||
// ErrRichUnknownTag indicates that a rich-text or rich-block implementation is unsupported.
|
||||
ErrRichUnknownTag = errors.New("rich unknown tag")
|
||||
// ErrRichListItemMix indicates that ordered and unordered items were mixed in one list.
|
||||
ErrRichListItemMix = errors.New("rich list items mixed: ordered and unordered")
|
||||
// ErrRichInvalidListItem indicates that an unordered item uses ordered-list attributes.
|
||||
ErrRichInvalidListItem = errors.New("rich unordered list item has ordered attributes")
|
||||
// ErrRichInvalidMedia indicates that a media block contains an incompatible media type.
|
||||
ErrRichInvalidMedia = errors.New("rich block has incompatible media type")
|
||||
// ErrRichInvalidBlockType indicates that a block's type discriminator doesn't match its Go type.
|
||||
ErrRichInvalidBlockType = errors.New("rich block has invalid type")
|
||||
// ErrRichInvalidHeading indicates that a heading has a size outside 1-6.
|
||||
ErrRichInvalidHeading = errors.New("rich heading has invalid size")
|
||||
// ErrRichInvalidMap indicates that a map has invalid coordinates, zoom, or dimensions.
|
||||
ErrRichInvalidMap = errors.New("rich map has invalid parameters")
|
||||
// ErrRichInvalidListItemType indicates that an ordered-list marker type is unsupported.
|
||||
ErrRichInvalidListItemType = errors.New("rich list item has invalid type")
|
||||
// ErrRichInvalidCheckbox indicates that a checked list item has no checkbox.
|
||||
ErrRichInvalidCheckbox = errors.New("rich list item is checked without a checkbox")
|
||||
// ErrRichInvalidTableCell indicates that a table cell has invalid spans or alignment.
|
||||
ErrRichInvalidTableCell = errors.New("rich table cell has invalid parameters")
|
||||
// ErrRichThinkingDraftOnly indicates that a thinking block was used outside a draft.
|
||||
ErrRichThinkingDraftOnly = errors.New("thinking blocks are valid only in rich-message drafts")
|
||||
// ErrRichEntityMismatch indicates that HTML conversion would lose an explicit entity value.
|
||||
ErrRichEntityMismatch = errors.New("rich entity value doesn't match visible text")
|
||||
)
|
||||
@@ -0,0 +1,548 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
type buildState struct {
|
||||
media []tgapi.InputRichMessageMedia
|
||||
}
|
||||
|
||||
// BuildHTML converts input rich blocks to an HTML-based rich message and
|
||||
// collects media blocks into InputRichMessage.Media.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func BuildHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||
return buildHTML(false, blocks...)
|
||||
}
|
||||
|
||||
// BuildDraftHTML converts draft-compatible input rich blocks to HTML.
|
||||
// Unlike BuildHTML, it permits the draft-only thinking block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func BuildDraftHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||
return buildHTML(true, blocks...)
|
||||
}
|
||||
|
||||
func buildHTML(allowThinking bool, blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||
if err := validateRichBlocks(blocks, allowThinking); err != nil {
|
||||
return tgapi.InputRichMessage{}, err
|
||||
}
|
||||
state := new(buildState)
|
||||
rendered, err := renderBlocksHTMLState(blocks, 0, state)
|
||||
if err != nil {
|
||||
return tgapi.InputRichMessage{}, err
|
||||
}
|
||||
if allowThinking {
|
||||
for _, item := range state.media {
|
||||
if strings.HasPrefix(item.Media.Media, "attach://") {
|
||||
return tgapi.InputRichMessage{}, tgapi.ErrRichMessageDraftUploadUnsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
return tgapi.InputRichMessage{
|
||||
HTML: strings.Join(rendered, ""),
|
||||
Media: state.media,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToHTML converts one input rich block to an HTML-based rich message.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func ToHTML(block tgapi.InputRichBlock) (tgapi.InputRichMessage, error) { return BuildHTML(block) }
|
||||
|
||||
func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
|
||||
if len(s.media) >= maxRichMedia {
|
||||
return "", ErrRichTooManyMedia
|
||||
}
|
||||
var kind string
|
||||
switch media.Type {
|
||||
case tgapi.InputMediaTypePhoto:
|
||||
kind = "photo"
|
||||
case tgapi.InputMediaTypeAnimation, tgapi.InputMediaTypeVideo:
|
||||
kind = "video"
|
||||
case tgapi.InputMediaTypeAudio, tgapi.InputMediaTypeVoiceNote:
|
||||
kind = "audio"
|
||||
default:
|
||||
return "", ErrRichInvalidMedia
|
||||
}
|
||||
id := "media_" + strconv.Itoa(len(s.media)+1)
|
||||
s.media = append(s.media, tgapi.InputRichMessageMedia{ID: id, Media: media})
|
||||
return "tg://" + kind + "?id=" + id, nil
|
||||
}
|
||||
|
||||
func renderText(t tgapi.RichText, step int) (string, error) {
|
||||
switch el := t.(type) {
|
||||
case tgapi.RichTextPlain:
|
||||
return escapeHTML(string(el)), nil
|
||||
case tgapi.RichTextArray:
|
||||
var b strings.Builder
|
||||
for _, item := range el {
|
||||
part, err := renderText(item, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(part)
|
||||
}
|
||||
return b.String(), nil
|
||||
case tgapi.RichTextWrap:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tagMap := map[string]string{
|
||||
"bold": "b", "italic": "i", "underline": "u", "strikethrough": "s",
|
||||
"subscript": "sub", "superscript": "sup", "marked": "mark",
|
||||
"spoiler": "tg-spoiler", "code": "code",
|
||||
}
|
||||
tag, ok := tagMap[el.Tag]
|
||||
if !ok {
|
||||
return "", ErrRichUnknownTag
|
||||
}
|
||||
return "<" + tag + ">" + s + "</" + tag + ">", nil
|
||||
case tgapi.RichTextDateTime:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
attrs := []string{`unix="` + strconv.FormatInt(el.UnixTime, 10) + `"`}
|
||||
if el.DateTimeFormat != "" {
|
||||
attrs = append(attrs, `format="`+escapeHTML(el.DateTimeFormat)+`"`)
|
||||
}
|
||||
return openTag("tg-time", attrs) + s + "</tg-time>", nil
|
||||
case tgapi.RichTextTextMention:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := []string{`href="` + "tg://user?id=" + strconv.FormatInt(el.User.ID, 10) + `"`}
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
case tgapi.RichTextCustomEmoji:
|
||||
s := escapeHTML(el.AlternativeText)
|
||||
attrs := formatAttrs(map[string]string{"emoji-id": el.CustomEmojiID})
|
||||
return openTag("tg-emoji", attrs) + s + "</tg-emoji>", nil
|
||||
case tgapi.RichTextMathematicalExpression:
|
||||
s := escapeHTML(el.Expression)
|
||||
return "<tg-math>" + s + "</tg-math>", nil
|
||||
case tgapi.RichTextURL:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"href": el.URL})
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
case tgapi.RichTextEmailAddress:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"href": "mailto:" + el.EmailAddress})
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
case tgapi.RichTextPhoneNumber:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"href": "tel:" + el.PhoneNumber})
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
case tgapi.RichTextBankCardNumber:
|
||||
return renderTextChild(el.Text, step)
|
||||
case tgapi.RichTextMention:
|
||||
return renderTextChild(el.Text, step)
|
||||
case tgapi.RichTextHashtag:
|
||||
return renderTextChild(el.Text, step)
|
||||
case tgapi.RichTextCashtag:
|
||||
return renderTextChild(el.Text, step)
|
||||
case tgapi.RichTextBotCommand:
|
||||
return renderTextChild(el.Text, step)
|
||||
case tgapi.RichTextAnchor:
|
||||
attrs := formatAttrs(map[string]string{"name": el.Name})
|
||||
return openTag("a", attrs) + "</a>", nil
|
||||
case tgapi.RichTextAnchorLink:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"href": "#" + el.AnchorName})
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
case tgapi.RichTextReference:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"name": el.Name})
|
||||
return openTag("tg-reference", attrs) + s + "</tg-reference>", nil
|
||||
case tgapi.RichTextReferenceLink:
|
||||
s, err := renderTextChild(el.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := formatAttrs(map[string]string{"href": "#" + el.ReferenceName})
|
||||
return openTag("a", attrs) + s + "</a>", nil
|
||||
}
|
||||
return "", ErrRichUnknownTag
|
||||
}
|
||||
|
||||
func renderTextChild(t tgapi.RichText, step int) (string, error) {
|
||||
if step >= maxRichDepth {
|
||||
return "", ErrRichNestingTooDeep
|
||||
}
|
||||
return renderText(t, step+1)
|
||||
}
|
||||
|
||||
func renderHTMLTable(t tgapi.InputRichBlockTable, step int, state *buildState) (string, error) {
|
||||
attrs := map[string]string{}
|
||||
if t.IsBordered {
|
||||
attrs["bordered"] = ""
|
||||
}
|
||||
if t.IsStriped {
|
||||
attrs["striped"] = ""
|
||||
}
|
||||
|
||||
var rows strings.Builder
|
||||
for _, row := range t.Cells {
|
||||
r, err := renderHTMLTableRow(row, step, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rows.WriteString(r)
|
||||
}
|
||||
caption := ""
|
||||
if t.Caption != nil {
|
||||
text, err := renderText(*t.Caption, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caption = "<caption>" + text + "</caption>"
|
||||
}
|
||||
return openTag("table", formatAttrs(attrs)) + caption + rows.String() + "</table>", nil
|
||||
}
|
||||
func renderHTMLTableRow(rows []tgapi.RichBlockTableCell, step int, state *buildState) (string, error) {
|
||||
var out strings.Builder
|
||||
for _, cell := range rows {
|
||||
row, err := renderHTMLTableCell(cell, step, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(row)
|
||||
}
|
||||
return "<tr>" + out.String() + "</tr>", nil
|
||||
}
|
||||
func renderHTMLTableCell(c tgapi.RichBlockTableCell, step int, _ *buildState) (string, error) {
|
||||
var renderedText string
|
||||
var err error
|
||||
if c.Text != nil {
|
||||
renderedText, err = renderText(c.Text, step+1)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mapAttrs := make(map[string]string)
|
||||
tag := "td"
|
||||
if c.IsHeader {
|
||||
tag = "th"
|
||||
}
|
||||
if c.RowSpan > 0 {
|
||||
mapAttrs["rowspan"] = strconv.Itoa(c.RowSpan)
|
||||
}
|
||||
if c.ColSpan > 0 {
|
||||
mapAttrs["colspan"] = strconv.Itoa(c.ColSpan)
|
||||
}
|
||||
if c.Align != "" {
|
||||
mapAttrs["align"] = c.Align
|
||||
}
|
||||
if c.VAlign != "" {
|
||||
mapAttrs["valign"] = c.VAlign
|
||||
}
|
||||
return openTag(tag, formatAttrs(mapAttrs)) + renderedText + "</" + tag + ">", nil
|
||||
}
|
||||
func renderHTMLList(l tgapi.InputRichBlockList, step int, state *buildState) (string, error) {
|
||||
if len(l.Items) == 0 {
|
||||
return "<ul></ul>", nil
|
||||
}
|
||||
|
||||
hasType := false
|
||||
hasNoType := false
|
||||
|
||||
var items []string
|
||||
for _, item := range l.Items {
|
||||
if item.Type != "" {
|
||||
hasType = true
|
||||
} else {
|
||||
hasNoType = true
|
||||
if item.Value != 0 {
|
||||
return "", ErrRichInvalidListItem
|
||||
}
|
||||
}
|
||||
rendered, err := renderHTMLListItem(item, step, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, rendered)
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasType && !hasNoType:
|
||||
return "<ol>\n" + strings.Join(items, "\n") + "</ol>", nil
|
||||
case hasNoType && !hasType:
|
||||
return "<ul>\n" + strings.Join(items, "\n") + "</ul>", nil
|
||||
}
|
||||
return "", ErrRichListItemMix
|
||||
}
|
||||
|
||||
func renderHTMLCaption(cap tgapi.RichBlockCaption, step int) (string, error) {
|
||||
caption := "<figcaption>"
|
||||
text, err := renderText(cap.Text, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caption += text
|
||||
if cap.Credit != nil {
|
||||
cred, err := renderText(cap.Credit, step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caption += "<cite>" + cred + "</cite>"
|
||||
}
|
||||
caption += "</figcaption>"
|
||||
return caption, nil
|
||||
}
|
||||
func renderHTMLListItem(i tgapi.InputRichBlockListItem, step int, state *buildState) (string, error) {
|
||||
mapAttrs := map[string]string{}
|
||||
|
||||
renderedBlocks, err := renderBlocksHTMLState(i.Blocks, step+1, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
blocks := strings.Join(renderedBlocks, "")
|
||||
if i.HasCheckbox {
|
||||
var input string
|
||||
if i.IsChecked {
|
||||
input = `<input type="checkbox" checked/>`
|
||||
} else {
|
||||
input = `<input type="checkbox"/>`
|
||||
}
|
||||
blocks = input + blocks
|
||||
}
|
||||
if i.Value > 0 {
|
||||
mapAttrs["value"] = strconv.Itoa(i.Value)
|
||||
}
|
||||
if i.Type != "" {
|
||||
mapAttrs["type"] = string(i.Type)
|
||||
}
|
||||
return openTag("li", formatAttrs(mapAttrs)) + blocks + "</li>", nil
|
||||
}
|
||||
|
||||
func renderHTMLMap(block tgapi.InputRichBlockMap, step int) (string, error) {
|
||||
attrs := map[string]string{
|
||||
"lat": strconv.FormatFloat(block.Location.Latitude, 'f', -1, 64),
|
||||
"long": strconv.FormatFloat(block.Location.Longitude, 'f', -1, 64),
|
||||
}
|
||||
if block.Zoom != 0 {
|
||||
attrs["zoom"] = strconv.Itoa(int(block.Zoom))
|
||||
}
|
||||
if block.Width != 0 {
|
||||
attrs["width"] = strconv.Itoa(int(block.Width))
|
||||
}
|
||||
if block.Height != 0 {
|
||||
attrs["height"] = strconv.Itoa(int(block.Height))
|
||||
}
|
||||
media := selfClosingTag("tg-map", formatAttrs(attrs))
|
||||
if block.Caption == nil {
|
||||
return media, nil
|
||||
}
|
||||
caption, err := renderHTMLCaption(*block.Caption, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<figure>" + media + caption + "</figure>", nil
|
||||
}
|
||||
|
||||
func renderHTMLMedia(media tgapi.InputMedia, caption *tgapi.RichBlockCaption, tag string, step int, state *buildState) (string, error) {
|
||||
src, err := state.addMedia(media)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
attrs := map[string]string{"src": src}
|
||||
if media.HasSpoiler != nil && *media.HasSpoiler {
|
||||
attrs["tg-spoiler"] = ""
|
||||
}
|
||||
|
||||
var element string
|
||||
if tag == "img" {
|
||||
element = selfClosingTag(tag, formatAttrs(attrs))
|
||||
} else {
|
||||
element = openTag(tag, formatAttrs(attrs)) + "</" + tag + ">"
|
||||
}
|
||||
if caption == nil {
|
||||
return element, nil
|
||||
}
|
||||
renderedCaption, err := renderHTMLCaption(*caption, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<figure>" + element + renderedCaption + "</figure>", nil
|
||||
}
|
||||
|
||||
// func renderBlocksHTML(blocks []tgapi.InputRichBlock, step int) ([]string, error) {
|
||||
// return renderBlocksHTMLState(blocks, step, new(buildState))
|
||||
// }
|
||||
|
||||
func renderBlocksHTMLState(blocks []tgapi.InputRichBlock, step int, state *buildState) ([]string, error) {
|
||||
var out []string
|
||||
for _, b := range blocks {
|
||||
html, err := renderBlockHTMLState(b, step, state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, html)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func renderBlockHTML(block tgapi.InputRichBlock, step int) (string, error) {
|
||||
return renderBlockHTMLState(block, step, new(buildState))
|
||||
}
|
||||
|
||||
func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildState) (string, error) {
|
||||
if step >= maxRichDepth {
|
||||
return "", ErrRichNestingTooDeep
|
||||
}
|
||||
switch el := block.(type) {
|
||||
case tgapi.InputRichBlockParagraph:
|
||||
t, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<p>" + t + "</p>", nil
|
||||
case tgapi.InputRichBlockSectionHeading:
|
||||
t, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
size := strconv.Itoa(int(el.Size))
|
||||
return "<h" + size + ">" + t + "</h" + size + ">", nil
|
||||
case tgapi.InputRichBlockPreformatted:
|
||||
t, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if el.Language != "" {
|
||||
t = fmt.Sprintf(`<code class="language-%s">%s</code>`, escapeHTML(el.Language), t)
|
||||
}
|
||||
return "<pre>" + t + "</pre>", nil
|
||||
case tgapi.InputRichBlockFooter:
|
||||
t, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<footer>" + t + "</footer>", nil
|
||||
case tgapi.InputRichBlockDivider:
|
||||
return "<hr/>", nil
|
||||
case tgapi.InputRichBlockMath:
|
||||
exp := escapeHTML(el.Expression)
|
||||
return "<tg-math-block>" + exp + "</tg-math-block>", nil
|
||||
case tgapi.InputRichBlockAnchor:
|
||||
return fmt.Sprintf(`<a name="%s"></a>`, escapeHTML(el.Name)), nil
|
||||
case tgapi.InputRichBlockList:
|
||||
return renderHTMLList(el, step, state)
|
||||
case tgapi.InputRichBlockBlockQuotation:
|
||||
content, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credit := ""
|
||||
if el.Credit != nil {
|
||||
credit, err = renderText(*el.Credit, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credit = "<cite>" + credit + "</cite>"
|
||||
}
|
||||
return "<blockquote>" + strings.Join(content, "") + credit + "</blockquote>", nil
|
||||
case tgapi.InputRichBlockPullQuotation:
|
||||
text, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credit := ""
|
||||
if el.Credit != nil {
|
||||
credit, err = renderText(*el.Credit, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
credit = "<cite>" + credit + "</cite>"
|
||||
}
|
||||
return "<aside>" + text + credit + "</aside>", nil
|
||||
case tgapi.InputRichBlockCollage:
|
||||
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caption := ""
|
||||
if el.Caption != nil {
|
||||
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "<tg-collage>" + strings.Join(blocks, "") + caption + "</tg-collage>", err
|
||||
case tgapi.InputRichBlockSlideshow:
|
||||
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
caption := ""
|
||||
if el.Caption != nil {
|
||||
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "<tg-slideshow>" + strings.Join(blocks, "") + caption + "</tg-slideshow>", err
|
||||
case tgapi.InputRichBlockTable:
|
||||
return renderHTMLTable(el, step, state)
|
||||
case tgapi.InputRichBlockDetails:
|
||||
summary, err := renderText(el.Summary, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
attrs := make(map[string]string)
|
||||
if el.IsOpen {
|
||||
attrs["open"] = ""
|
||||
}
|
||||
|
||||
return openTag("details", formatAttrs(attrs)) + "<summary>" + summary + "</summary>" + strings.Join(blocks, "") + "</details>", nil
|
||||
case tgapi.InputRichBlockMap:
|
||||
return renderHTMLMap(el, step)
|
||||
case tgapi.InputRichBlockAnimation:
|
||||
return renderHTMLMedia(el.Animation, el.Caption, "video", step, state)
|
||||
case tgapi.InputRichBlockAudio:
|
||||
return renderHTMLMedia(el.Audio, el.Caption, "audio", step, state)
|
||||
case tgapi.InputRichBlockPhoto:
|
||||
return renderHTMLMedia(el.Photo, el.Caption, "img", step, state)
|
||||
case tgapi.InputRichBlockVideo:
|
||||
return renderHTMLMedia(el.Video, el.Caption, "video", step, state)
|
||||
case tgapi.InputRichBlockVoiceNote:
|
||||
return renderHTMLMedia(el.VoiceNote, el.Caption, "audio", step, state)
|
||||
case tgapi.InputRichBlockThinking:
|
||||
text, err := renderText(el.Text, step+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "<tg-thinking>" + text + "</tg-thinking>", nil
|
||||
}
|
||||
return "", ErrRichUnknownTag
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package tgrich
|
||||
|
||||
import "git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
|
||||
// Caption creates a media-block caption without a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Caption(text tgapi.RichText) tgapi.RichBlockCaption { return tgapi.RichBlockCaption{Text: text} }
|
||||
|
||||
// CaptionWithCredit creates a media-block caption with a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func CaptionWithCredit(text, credit tgapi.RichText) tgapi.RichBlockCaption {
|
||||
return tgapi.RichBlockCaption{Text: text, Credit: credit}
|
||||
}
|
||||
|
||||
// P creates a text paragraph corresponding to the HTML <p> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func P(text tgapi.RichText) tgapi.InputRichBlockParagraph {
|
||||
return tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeParagraph, Text: text}
|
||||
}
|
||||
|
||||
// H creates a section heading with a relative font size from 1 (largest) to 6 (smallest).
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H(text tgapi.RichText, size uint8) tgapi.InputRichBlockSectionHeading {
|
||||
return tgapi.InputRichBlockSectionHeading{
|
||||
Type: tgapi.InputRichTypeSectionHeading,
|
||||
Text: text, Size: size,
|
||||
}
|
||||
}
|
||||
|
||||
// H1 creates a level-one section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H1(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 1) }
|
||||
|
||||
// H2 creates a level-two section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H2(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 2) }
|
||||
|
||||
// H3 creates a level-three section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H3(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 3) }
|
||||
|
||||
// H4 creates a level-four section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H4(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 4) }
|
||||
|
||||
// H5 creates a level-five section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H5(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 5) }
|
||||
|
||||
// H6 creates a level-six section heading.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func H6(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 6) }
|
||||
|
||||
// Pre creates a preformatted text block without a programming language.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Pre(text tgapi.RichText) tgapi.InputRichBlockPreformatted {
|
||||
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text}
|
||||
}
|
||||
|
||||
// CodeBlock creates a preformatted text block with its programming language.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func CodeBlock(text tgapi.RichText, lang string) tgapi.InputRichBlockPreformatted {
|
||||
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text, Language: lang}
|
||||
}
|
||||
|
||||
// Footer creates a footer block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Footer(text tgapi.RichText) tgapi.InputRichBlockFooter {
|
||||
return tgapi.InputRichBlockFooter{Type: tgapi.InputRichTypeFooter, Text: text}
|
||||
}
|
||||
|
||||
// Hr creates a divider corresponding to the HTML <hr/> tag.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Hr() tgapi.InputRichBlockDivider {
|
||||
return tgapi.InputRichBlockDivider{Type: tgapi.InputRichTypeDivider}
|
||||
}
|
||||
|
||||
// Math creates a mathematical expression block from a LaTeX expression.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Math(expression string) tgapi.InputRichBlockMath {
|
||||
return tgapi.InputRichBlockMath{Type: tgapi.InputRichTypeMathematicalExpression, Expression: expression}
|
||||
}
|
||||
|
||||
// Anchor creates a block containing an anchor with the given name.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Anchor(name string) tgapi.InputRichBlockAnchor {
|
||||
return tgapi.InputRichBlockAnchor{Type: tgapi.InputRichTypeAnchor, Name: name}
|
||||
}
|
||||
|
||||
// ListItem builds an input rich-message list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type ListItem struct {
|
||||
blocks []tgapi.InputRichBlock
|
||||
hasCheckbox bool
|
||||
isChecked bool
|
||||
value int
|
||||
t tgapi.RichBlockListItemType
|
||||
}
|
||||
|
||||
// NewListItem creates a list-item builder containing blocks.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func NewListItem(blocks ...tgapi.InputRichBlock) *ListItem {
|
||||
return &ListItem{blocks: blocks}
|
||||
}
|
||||
|
||||
// SetBlocks replaces the blocks in the list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) SetBlocks(blocks ...tgapi.InputRichBlock) *ListItem {
|
||||
i.blocks = blocks
|
||||
return i
|
||||
}
|
||||
|
||||
// SetCheckbox adds an unchecked checkbox to the list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) SetCheckbox() *ListItem {
|
||||
i.hasCheckbox = true
|
||||
return i
|
||||
}
|
||||
|
||||
// SetChecked marks the list item's checkbox as checked.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) SetChecked() *ListItem {
|
||||
i.isChecked = true
|
||||
return i
|
||||
}
|
||||
|
||||
// SetValue sets the explicit number of an ordered-list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) SetValue(val int) *ListItem {
|
||||
i.value = val
|
||||
return i
|
||||
}
|
||||
|
||||
// SetType sets the marker style of an ordered-list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) SetType(t tgapi.RichBlockListItemType) *ListItem {
|
||||
i.t = t
|
||||
return i
|
||||
}
|
||||
|
||||
// Build returns the configured input rich-message list item.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (i *ListItem) Build() tgapi.InputRichBlockListItem {
|
||||
return tgapi.InputRichBlockListItem{
|
||||
Blocks: i.blocks,
|
||||
HasCheckbox: i.hasCheckbox,
|
||||
IsChecked: i.isChecked,
|
||||
Value: i.value,
|
||||
Type: i.t,
|
||||
}
|
||||
}
|
||||
|
||||
// List creates a list block from fully configured items.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func List(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||
return tgapi.InputRichBlockList{Type: tgapi.InputRichTypeList, Items: items}
|
||||
}
|
||||
|
||||
// Ul creates an unordered list and clears ordered-list attributes.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Ul(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||
newItems := make([]tgapi.InputRichBlockListItem, len(items))
|
||||
for index, item := range items {
|
||||
newItems[index] = tgapi.InputRichBlockListItem{
|
||||
Blocks: item.Blocks,
|
||||
HasCheckbox: item.HasCheckbox,
|
||||
IsChecked: item.IsChecked,
|
||||
}
|
||||
}
|
||||
return List(newItems...)
|
||||
}
|
||||
|
||||
// OlOpts configures ordered-list numbering.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type OlOpts struct {
|
||||
// Type is the marker style: "1", "a", "A", "i", or "I".
|
||||
Type tgapi.RichBlockListItemType
|
||||
// Start is the number of the first item; values below 1 use the default.
|
||||
Start int
|
||||
// IsReversed reports whether numbering decreases from Start.
|
||||
IsReversed bool
|
||||
}
|
||||
|
||||
// Ol creates an ordered list using opts for numbering.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Ol(opts OlOpts, items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||
newItems := make([]tgapi.InputRichBlockListItem, len(items))
|
||||
start := opts.Start
|
||||
if start < 1 {
|
||||
start = 1
|
||||
if opts.IsReversed {
|
||||
start = len(items)
|
||||
}
|
||||
}
|
||||
typ := opts.Type
|
||||
if typ == "" {
|
||||
typ = tgapi.InputRichBlockListItemTypeDecimal
|
||||
}
|
||||
for index, item := range items {
|
||||
val := index + start
|
||||
if opts.IsReversed {
|
||||
val = start - index
|
||||
}
|
||||
newItems[index] = tgapi.InputRichBlockListItem{
|
||||
Blocks: item.Blocks,
|
||||
HasCheckbox: item.HasCheckbox,
|
||||
IsChecked: item.IsChecked,
|
||||
Value: val,
|
||||
Type: typ,
|
||||
}
|
||||
}
|
||||
return List(newItems...)
|
||||
}
|
||||
|
||||
// BlockQuote creates a block quotation without a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func BlockQuote(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
|
||||
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks}
|
||||
}
|
||||
|
||||
// BlockQuoteWithCredit creates a block quotation with a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func BlockQuoteWithCredit(credit tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
|
||||
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks, Credit: &credit}
|
||||
}
|
||||
|
||||
// PullQuote creates a centered quotation without a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func PullQuote(text tgapi.RichText) tgapi.InputRichBlockPullQuotation {
|
||||
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text}
|
||||
}
|
||||
|
||||
// PullQuoteWithCredit creates a centered quotation with a credit.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func PullQuoteWithCredit(text tgapi.RichText, credit tgapi.RichText) tgapi.InputRichBlockPullQuotation {
|
||||
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text, Credit: &credit}
|
||||
}
|
||||
|
||||
// Collage creates a media collage without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Collage(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
|
||||
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks}
|
||||
}
|
||||
|
||||
// CollageWithCaption creates a media collage with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func CollageWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
|
||||
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks, Caption: &caption}
|
||||
}
|
||||
|
||||
// Slideshow creates a media slideshow without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Slideshow(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
|
||||
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks}
|
||||
}
|
||||
|
||||
// SlideshowWithCaption creates a media slideshow with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func SlideshowWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
|
||||
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks, Caption: &caption}
|
||||
}
|
||||
|
||||
// TableCell builds a rich-message table cell.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type TableCell struct {
|
||||
text tgapi.RichText
|
||||
isHeader bool
|
||||
colSpan int
|
||||
rowSpan int
|
||||
align string
|
||||
vAlign string
|
||||
}
|
||||
|
||||
// Cell creates an empty table-cell builder.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Cell() *TableCell { return &TableCell{} }
|
||||
|
||||
// CellWithText creates a table-cell builder containing text.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func CellWithText(text tgapi.RichText) *TableCell { return &TableCell{text: text} }
|
||||
|
||||
// SetText replaces the text in the table cell.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) SetText(text tgapi.RichText) *TableCell {
|
||||
c.text = text
|
||||
return c
|
||||
}
|
||||
|
||||
// SetHeader marks the cell as a table header.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) SetHeader() *TableCell {
|
||||
c.isHeader = true
|
||||
return c
|
||||
}
|
||||
|
||||
// SetSpan sets the cell's column and row spans.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) SetSpan(col, row int) *TableCell {
|
||||
c.colSpan = col
|
||||
c.rowSpan = row
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAlign sets horizontal alignment to left, center, or right.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) SetAlign(align string) *TableCell {
|
||||
c.align = align
|
||||
return c
|
||||
}
|
||||
|
||||
// SetVAlign sets vertical alignment to top, middle, or bottom.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) SetVAlign(vAlign string) *TableCell {
|
||||
c.vAlign = vAlign
|
||||
return c
|
||||
}
|
||||
|
||||
// Build returns the configured rich-message table cell.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (c *TableCell) Build() tgapi.RichBlockTableCell {
|
||||
return tgapi.RichBlockTableCell{
|
||||
Text: c.text,
|
||||
IsHeader: c.isHeader,
|
||||
ColSpan: c.colSpan,
|
||||
RowSpan: c.rowSpan,
|
||||
Align: c.align,
|
||||
VAlign: c.vAlign,
|
||||
}
|
||||
}
|
||||
|
||||
// Row creates a table row containing cells.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Row(cells ...tgapi.RichBlockTableCell) []tgapi.RichBlockTableCell {
|
||||
return cells
|
||||
}
|
||||
|
||||
// RichTable builds an input rich-message table block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
type RichTable struct {
|
||||
// Cells contains table rows and their cells.
|
||||
Cells [][]tgapi.RichBlockTableCell
|
||||
// IsBordered reports whether the table has borders.
|
||||
IsBordered bool
|
||||
// IsStriped reports whether the table has striped rows.
|
||||
IsStriped bool
|
||||
// Caption is the optional table caption.
|
||||
Caption *tgapi.RichText
|
||||
}
|
||||
|
||||
// NewTable creates a table builder containing rows.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func NewTable(rows ...[]tgapi.RichBlockTableCell) *RichTable {
|
||||
return &RichTable{Cells: rows}
|
||||
}
|
||||
|
||||
// AddRows appends rows to the table.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (t *RichTable) AddRows(rows ...[]tgapi.RichBlockTableCell) *RichTable {
|
||||
t.Cells = append(t.Cells, rows...)
|
||||
return t
|
||||
}
|
||||
|
||||
// SetBordered controls whether the table has borders.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (t *RichTable) SetBordered(b bool) *RichTable {
|
||||
t.IsBordered = b
|
||||
return t
|
||||
}
|
||||
|
||||
// SetStriped controls whether the table has striped rows.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (t *RichTable) SetStriped(b bool) *RichTable {
|
||||
t.IsStriped = b
|
||||
return t
|
||||
}
|
||||
|
||||
// SetCaption sets the table caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (t *RichTable) SetCaption(cap *tgapi.RichText) *RichTable {
|
||||
t.Caption = cap
|
||||
return t
|
||||
}
|
||||
|
||||
// Build returns the configured input rich-message table.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func (t *RichTable) Build() tgapi.InputRichBlockTable {
|
||||
return tgapi.InputRichBlockTable{
|
||||
Type: tgapi.InputRichTypeTable,
|
||||
Cells: t.Cells,
|
||||
IsBordered: t.IsBordered,
|
||||
IsStriped: t.IsStriped,
|
||||
Caption: t.Caption,
|
||||
}
|
||||
}
|
||||
|
||||
// Details creates a collapsed details block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Details(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
|
||||
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks}
|
||||
}
|
||||
|
||||
// DetailsOpen creates a details block expanded by default.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func DetailsOpen(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
|
||||
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks, IsOpen: true}
|
||||
}
|
||||
|
||||
// Map creates a map block centered on loc.
|
||||
//
|
||||
// Zoom accepts 0-24; width and height accept 0-10000 subject to Telegram's
|
||||
// total-size and aspect-ratio restrictions.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Map(loc tgapi.Location, zoom uint8, width, height uint16) tgapi.InputRichBlockMap {
|
||||
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height}
|
||||
}
|
||||
|
||||
// MapWithCaption creates a map block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func MapWithCaption(loc tgapi.Location, zoom uint8, width, height uint16, caption tgapi.RichBlockCaption) tgapi.InputRichBlockMap {
|
||||
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height, Caption: &caption}
|
||||
}
|
||||
|
||||
// Animation creates an animation block without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Animation(animation tgapi.InputMedia) tgapi.InputRichBlockAnimation {
|
||||
animation.Type = tgapi.InputMediaTypeAnimation
|
||||
return tgapi.InputRichBlockAnimation{Type: tgapi.InputRichTypeAnimation, Animation: animation}
|
||||
}
|
||||
|
||||
// AnimationWithCaption creates an animation block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func AnimationWithCaption(animation tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAnimation {
|
||||
block := Animation(animation)
|
||||
block.Caption = &caption
|
||||
return block
|
||||
}
|
||||
|
||||
// Audio creates a music-file block without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Audio(audio tgapi.InputMedia) tgapi.InputRichBlockAudio {
|
||||
audio.Type = tgapi.InputMediaTypeAudio
|
||||
return tgapi.InputRichBlockAudio{Type: tgapi.InputRichTypeAudio, Audio: audio}
|
||||
}
|
||||
|
||||
// AudioWithCaption creates a music-file block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func AudioWithCaption(audio tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAudio {
|
||||
block := Audio(audio)
|
||||
block.Caption = &caption
|
||||
return block
|
||||
}
|
||||
|
||||
// Photo creates a photo block without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Photo(photo tgapi.InputMedia) tgapi.InputRichBlockPhoto {
|
||||
photo.Type = tgapi.InputMediaTypePhoto
|
||||
return tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: photo}
|
||||
}
|
||||
|
||||
// PhotoWithCaption creates a photo block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func PhotoWithCaption(photo tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockPhoto {
|
||||
block := Photo(photo)
|
||||
block.Caption = &caption
|
||||
return block
|
||||
}
|
||||
|
||||
// Video creates a video block without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Video(video tgapi.InputMedia) tgapi.InputRichBlockVideo {
|
||||
video.Type = tgapi.InputMediaTypeVideo
|
||||
return tgapi.InputRichBlockVideo{Type: tgapi.InputRichTypeVideo, Video: video}
|
||||
}
|
||||
|
||||
// VideoWithCaption creates a video block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func VideoWithCaption(video tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVideo {
|
||||
block := Video(video)
|
||||
block.Caption = &caption
|
||||
return block
|
||||
}
|
||||
|
||||
// VoiceNote creates a voice-note block without a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func VoiceNote(voiceNote tgapi.InputMedia) tgapi.InputRichBlockVoiceNote {
|
||||
voiceNote.Type = tgapi.InputMediaTypeVoiceNote
|
||||
return tgapi.InputRichBlockVoiceNote{Type: tgapi.InputRichTypeVoiceNote, VoiceNote: voiceNote}
|
||||
}
|
||||
|
||||
// VoiceNoteWithCaption creates a voice-note block with a caption.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func VoiceNoteWithCaption(voiceNote tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVoiceNote {
|
||||
block := VoiceNote(voiceNote)
|
||||
block.Caption = &caption
|
||||
return block
|
||||
}
|
||||
|
||||
// Thinking creates a draft-only thinking placeholder block.
|
||||
//
|
||||
// Since: Bot API 10.2
|
||||
func Thinking(text tgapi.RichText) tgapi.InputRichBlockThinking {
|
||||
return tgapi.InputRichBlockThinking{Type: tgapi.InputRichTypeThinking, Text: text}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
func TestMediaConstructorsSetTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
blockType tgapi.InputRichType
|
||||
mediaType tgapi.InputMediaType
|
||||
}{
|
||||
{"animation", Animation(tgapi.InputMedia{Media: "animation"}).Type, Animation(tgapi.InputMedia{Media: "animation"}).Animation.Type},
|
||||
{"audio", Audio(tgapi.InputMedia{Media: "audio"}).Type, Audio(tgapi.InputMedia{Media: "audio"}).Audio.Type},
|
||||
{"photo", Photo(tgapi.InputMedia{Media: "photo"}).Type, Photo(tgapi.InputMedia{Media: "photo"}).Photo.Type},
|
||||
{"video", Video(tgapi.InputMedia{Media: "video"}).Type, Video(tgapi.InputMedia{Media: "video"}).Video.Type},
|
||||
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice"}).Type, VoiceNote(tgapi.InputMedia{Media: "voice"}).VoiceNote.Type},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.blockType) != string(tt.mediaType) {
|
||||
t.Errorf("block type = %q, media type = %q", tt.blockType, tt.mediaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaConstructorWithCaption(t *testing.T) {
|
||||
caption := tgapi.RichBlockCaption{Text: tgapi.RichTextPlain("caption")}
|
||||
if block := AnimationWithCaption(tgapi.InputMedia{Media: "animation"}, caption); block.Caption == nil || *block.Caption != caption {
|
||||
t.Fatal("AnimationWithCaption did not preserve the caption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOlPreservesCheckboxState(t *testing.T) {
|
||||
item := NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()
|
||||
list := Ol(OlOpts{}, item)
|
||||
if len(list.Items) != 1 || !list.Items[0].HasCheckbox || !list.Items[0].IsChecked {
|
||||
t.Fatalf("Ol() item = %#v", list.Items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
func formatAttrs(attrs map[string]string) []string {
|
||||
out := make([]string, 0, len(attrs))
|
||||
for k, v := range attrs {
|
||||
if v == "" {
|
||||
out = append(out, k)
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf(`%s="%s"`, k, escapeHTML(v)))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func openTag(name string, attrs []string) string {
|
||||
if len(attrs) == 0 {
|
||||
return "<" + name + ">"
|
||||
}
|
||||
return "<" + name + " " + strings.Join(attrs, " ") + ">"
|
||||
}
|
||||
|
||||
func selfClosingTag(name string, attrs []string) string {
|
||||
if len(attrs) == 0 {
|
||||
return "<" + name + "/>"
|
||||
}
|
||||
return "<" + name + " " + strings.Join(attrs, " ") + "/>"
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package tgrich
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||
)
|
||||
|
||||
type richValidator struct {
|
||||
chars int
|
||||
blocks int
|
||||
media int
|
||||
allowThinking bool
|
||||
}
|
||||
|
||||
func validateRichBlocks(blocks []tgapi.InputRichBlock, allowThinking bool) error {
|
||||
v := &richValidator{allowThinking: allowThinking}
|
||||
for _, block := range blocks {
|
||||
if err := v.block(block, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *richValidator) addChars(s string) error {
|
||||
v.chars += utf8.RuneCountInString(s)
|
||||
if v.chars > maxRichTextChars {
|
||||
return ErrRichTextTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *richValidator) addBlocks(n int) error {
|
||||
v.blocks += n
|
||||
if v.blocks > maxRichBlocks {
|
||||
return ErrRichTooManyBlocks
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *richValidator) addMedia(media tgapi.InputMedia, want tgapi.InputMediaType) error {
|
||||
if media.Type != want || media.Media == "" {
|
||||
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidMedia, media.Type, want)
|
||||
}
|
||||
v.media++
|
||||
if v.media > maxRichMedia {
|
||||
return ErrRichTooManyMedia
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expectBlockType(got, want tgapi.InputRichType) error {
|
||||
if got != want {
|
||||
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidBlockType, got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validListItemType(t tgapi.RichBlockListItemType) bool {
|
||||
switch t {
|
||||
case tgapi.InputRichBlockListItemTypeLower,
|
||||
tgapi.InputRichBlockListItemTypeUpper,
|
||||
tgapi.InputRichBlockListItemTypeRomanLow,
|
||||
tgapi.InputRichBlockListItemTypeRomanUpper,
|
||||
tgapi.InputRichBlockListItemTypeDecimal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateTableCell(cell tgapi.RichBlockTableCell) error {
|
||||
if cell.ColSpan < 0 || cell.RowSpan < 0 {
|
||||
return ErrRichInvalidTableCell
|
||||
}
|
||||
switch cell.Align {
|
||||
case "", "left", "center", "right":
|
||||
default:
|
||||
return ErrRichInvalidTableCell
|
||||
}
|
||||
switch cell.VAlign {
|
||||
case "", "top", "middle", "bottom":
|
||||
default:
|
||||
return ErrRichInvalidTableCell
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMap(block tgapi.InputRichBlockMap) error {
|
||||
if math.IsNaN(block.Location.Latitude) || math.IsInf(block.Location.Latitude, 0) ||
|
||||
math.IsNaN(block.Location.Longitude) || math.IsInf(block.Location.Longitude, 0) ||
|
||||
block.Location.Latitude < -90 || block.Location.Latitude > 90 ||
|
||||
block.Location.Longitude < -180 || block.Location.Longitude > 180 ||
|
||||
block.Zoom > 24 || block.Width > 10000 || block.Height > 10000 ||
|
||||
uint32(block.Width)+uint32(block.Height) > 10000 {
|
||||
return ErrRichInvalidMap
|
||||
}
|
||||
if block.Width != 0 && block.Height != 0 {
|
||||
longer := float64(block.Width)
|
||||
shorter := float64(block.Height)
|
||||
if longer < shorter {
|
||||
longer, shorter = shorter, longer
|
||||
}
|
||||
if longer/shorter > 20 {
|
||||
return ErrRichInvalidMap
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *richValidator) text(text tgapi.RichText, depth int) error {
|
||||
switch el := text.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case tgapi.RichTextPlain:
|
||||
return v.addChars(string(el))
|
||||
case tgapi.RichTextArray:
|
||||
for _, item := range el {
|
||||
if err := v.text(item, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case tgapi.RichTextCustomEmoji:
|
||||
return v.addChars(el.AlternativeText)
|
||||
case tgapi.RichTextMathematicalExpression:
|
||||
return v.addChars(el.Expression)
|
||||
case tgapi.RichTextAnchor:
|
||||
return nil
|
||||
}
|
||||
|
||||
if depth >= maxRichDepth {
|
||||
return ErrRichNestingTooDeep
|
||||
}
|
||||
|
||||
var child tgapi.RichText
|
||||
switch el := text.(type) {
|
||||
case tgapi.RichTextWrap:
|
||||
case tgapi.RichTextURL:
|
||||
case tgapi.RichTextEmailAddress:
|
||||
case tgapi.RichTextPhoneNumber:
|
||||
case tgapi.RichTextBankCardNumber:
|
||||
if err := validateAutomaticEntity(el.Text, el.BankCardNumber); err != nil {
|
||||
return err
|
||||
}
|
||||
case tgapi.RichTextMention:
|
||||
if err := validateAutomaticEntity(el.Text, "@"+strings.TrimPrefix(el.Username, "@")); err != nil {
|
||||
return err
|
||||
}
|
||||
case tgapi.RichTextHashtag:
|
||||
if err := validateAutomaticEntity(el.Text, "#"+strings.TrimPrefix(el.Hashtag, "#")); err != nil {
|
||||
return err
|
||||
}
|
||||
case tgapi.RichTextCashtag:
|
||||
if err := validateAutomaticEntity(el.Text, "$"+strings.TrimPrefix(el.Cashtag, "$")); err != nil {
|
||||
return err
|
||||
}
|
||||
case tgapi.RichTextBotCommand:
|
||||
if err := validateAutomaticEntity(el.Text, "/"+strings.TrimPrefix(el.BotCommand, "/")); err != nil {
|
||||
return err
|
||||
}
|
||||
case tgapi.RichTextAnchorLink:
|
||||
case tgapi.RichTextReference:
|
||||
case tgapi.RichTextReferenceLink:
|
||||
case tgapi.RichTextDateTime:
|
||||
case tgapi.RichTextTextMention:
|
||||
child = el.Text
|
||||
default:
|
||||
return ErrRichUnknownTag
|
||||
}
|
||||
return v.text(child, depth+1)
|
||||
}
|
||||
|
||||
func validateAutomaticEntity(text tgapi.RichText, semantic string) error {
|
||||
visible, err := visibleRichText(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if visible != semantic {
|
||||
return fmt.Errorf("%w: visible %q, semantic %q", ErrRichEntityMismatch, visible, semantic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func visibleRichText(text tgapi.RichText) (string, error) {
|
||||
switch el := text.(type) {
|
||||
case nil:
|
||||
return "", nil
|
||||
case tgapi.RichTextPlain:
|
||||
return string(el), nil
|
||||
case tgapi.RichTextArray:
|
||||
var result strings.Builder
|
||||
for _, item := range el {
|
||||
part, err := visibleRichText(item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result.WriteString(part)
|
||||
}
|
||||
return result.String(), nil
|
||||
case tgapi.RichTextCustomEmoji:
|
||||
return el.AlternativeText, nil
|
||||
case tgapi.RichTextMathematicalExpression:
|
||||
return el.Expression, nil
|
||||
case tgapi.RichTextAnchor:
|
||||
return "", nil
|
||||
case tgapi.RichTextWrap:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextURL:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextEmailAddress:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextPhoneNumber:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextBankCardNumber:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextMention:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextHashtag:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextCashtag:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextBotCommand:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextAnchorLink:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextReference:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextReferenceLink:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextDateTime:
|
||||
return visibleRichText(el.Text)
|
||||
case tgapi.RichTextTextMention:
|
||||
return visibleRichText(el.Text)
|
||||
default:
|
||||
return "", ErrRichUnknownTag
|
||||
}
|
||||
}
|
||||
|
||||
func (v *richValidator) caption(caption *tgapi.RichBlockCaption, depth int) error {
|
||||
if caption == nil {
|
||||
return nil
|
||||
}
|
||||
if err := v.text(caption.Text, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.text(caption.Credit, depth)
|
||||
}
|
||||
|
||||
func (v *richValidator) nested(blocks []tgapi.InputRichBlock, depth int) error {
|
||||
for _, block := range blocks {
|
||||
if err := v.block(block, depth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
|
||||
if depth >= maxRichDepth {
|
||||
return ErrRichNestingTooDeep
|
||||
}
|
||||
if err := v.addBlocks(1); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch el := block.(type) {
|
||||
case tgapi.InputRichBlockParagraph:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeParagraph); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.text(el.Text, depth+1)
|
||||
case tgapi.InputRichBlockSectionHeading:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeSectionHeading); err != nil {
|
||||
return err
|
||||
}
|
||||
if el.Size < 1 || el.Size > 6 {
|
||||
return ErrRichInvalidHeading
|
||||
}
|
||||
return v.text(el.Text, depth+1)
|
||||
case tgapi.InputRichBlockPreformatted:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypePre); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.text(el.Text, depth+1)
|
||||
case tgapi.InputRichBlockFooter:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeFooter); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.text(el.Text, depth+1)
|
||||
case tgapi.InputRichBlockDivider:
|
||||
return expectBlockType(el.Type, tgapi.InputRichTypeDivider)
|
||||
case tgapi.InputRichBlockAnchor:
|
||||
return expectBlockType(el.Type, tgapi.InputRichTypeAnchor)
|
||||
case tgapi.InputRichBlockMath:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeMathematicalExpression); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.addChars(el.Expression)
|
||||
case tgapi.InputRichBlockList:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeList); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addBlocks(len(el.Items)); err != nil {
|
||||
return err
|
||||
}
|
||||
ordered := false
|
||||
unordered := false
|
||||
for _, item := range el.Items {
|
||||
if item.IsChecked && !item.HasCheckbox {
|
||||
return ErrRichInvalidCheckbox
|
||||
}
|
||||
if item.Type == "" {
|
||||
unordered = true
|
||||
if item.Value != 0 {
|
||||
return ErrRichInvalidListItem
|
||||
}
|
||||
} else {
|
||||
ordered = true
|
||||
if !validListItemType(item.Type) {
|
||||
return ErrRichInvalidListItemType
|
||||
}
|
||||
}
|
||||
if err := v.nested(item.Blocks, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if ordered && unordered {
|
||||
return ErrRichListItemMix
|
||||
}
|
||||
return nil
|
||||
case tgapi.InputRichBlockBlockQuotation:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeBlockQuotation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.textValue(el.Credit, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.nested(el.Blocks, depth+1)
|
||||
case tgapi.InputRichBlockPullQuotation:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypePullQuotation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.text(el.Text, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.textValue(el.Credit, depth+1)
|
||||
case tgapi.InputRichBlockCollage:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeCollage); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.caption(el.Caption, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.nested(el.Blocks, depth+1)
|
||||
case tgapi.InputRichBlockSlideshow:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeSlideshow); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.caption(el.Caption, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.nested(el.Blocks, depth+1)
|
||||
case tgapi.InputRichBlockTable:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeTable); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addBlocks(len(el.Cells)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.textValue(el.Caption, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range el.Cells {
|
||||
columns := 0
|
||||
for _, cell := range row {
|
||||
if err := validateTableCell(cell); err != nil {
|
||||
return err
|
||||
}
|
||||
span := cell.ColSpan
|
||||
if span < 1 {
|
||||
span = 1
|
||||
}
|
||||
columns += span
|
||||
if err := v.text(cell.Text, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if columns > maxTableColumns {
|
||||
return ErrRichTableTooWide
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case tgapi.InputRichBlockDetails:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeDetails); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.text(el.Summary, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.nested(el.Blocks, depth+1)
|
||||
case tgapi.InputRichBlockMap:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeMap); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateMap(el); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockAnimation:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addMedia(el.Animation, tgapi.InputMediaTypeAnimation); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockAudio:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addMedia(el.Audio, tgapi.InputMediaTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockPhoto:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypePhoto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addMedia(el.Photo, tgapi.InputMediaTypePhoto); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockVideo:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addMedia(el.Video, tgapi.InputMediaTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockVoiceNote:
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeVoiceNote); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.addMedia(el.VoiceNote, tgapi.InputMediaTypeVoiceNote); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.caption(el.Caption, depth+1)
|
||||
case tgapi.InputRichBlockThinking:
|
||||
if !v.allowThinking {
|
||||
return ErrRichThinkingDraftOnly
|
||||
}
|
||||
if err := expectBlockType(el.Type, tgapi.InputRichTypeThinking); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.text(el.Text, depth+1)
|
||||
default:
|
||||
return ErrRichUnknownTag
|
||||
}
|
||||
}
|
||||
|
||||
func (v *richValidator) textValue(text *tgapi.RichText, depth int) error {
|
||||
if text == nil {
|
||||
return nil
|
||||
}
|
||||
return v.text(*text, depth)
|
||||
}
|
||||
+37
-3
@@ -130,6 +130,20 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) {
|
||||
from = u.EditedBusinessMessage.From
|
||||
}
|
||||
}
|
||||
case tgapi.UpdateTypeGuestMessage:
|
||||
if u.GuestMessage != nil {
|
||||
ctx.Msg = u.GuestMessage
|
||||
if u.GuestMessage.Chat != nil {
|
||||
chat = u.GuestMessage.Chat
|
||||
}
|
||||
if u.GuestMessage.From != nil {
|
||||
from = u.GuestMessage.From
|
||||
}
|
||||
}
|
||||
case tgapi.UpdateTypeDeletedBusinessMessages:
|
||||
if u.DeletedBusinessMessages != nil {
|
||||
chat = &u.DeletedBusinessMessages.Chat
|
||||
}
|
||||
case tgapi.UpdateTypeInlineQuery:
|
||||
if u.InlineQuery != nil {
|
||||
from = &u.InlineQuery.From
|
||||
@@ -187,23 +201,43 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) {
|
||||
}
|
||||
case tgapi.UpdateTypePollAnswer:
|
||||
if u.PollAnswer != nil {
|
||||
from = &u.PollAnswer.User
|
||||
if u.PollAnswer.User.ID != 0 {
|
||||
from = &u.PollAnswer.User
|
||||
} else if u.PollAnswer.VoterChat.ID != 0 {
|
||||
chat = &u.PollAnswer.VoterChat
|
||||
}
|
||||
}
|
||||
case tgapi.UpdateTypeMessageReaction:
|
||||
if u.MessageReaction != nil {
|
||||
from = u.MessageReaction.User
|
||||
chat = u.MessageReaction.Chat
|
||||
}
|
||||
case tgapi.UpdateTypeMessageReactionCount:
|
||||
if u.MessageReactionCount != nil {
|
||||
chat = u.MessageReactionCount.Chat
|
||||
}
|
||||
case tgapi.UpdateTypeChatBoost:
|
||||
if u.ChatBoost != nil {
|
||||
from = &u.ChatBoost.Boost.Source.User
|
||||
if u.ChatBoost.Boost.Source.User.ID != 0 {
|
||||
from = &u.ChatBoost.Boost.Source.User
|
||||
}
|
||||
chat = &u.ChatBoost.Chat
|
||||
}
|
||||
case tgapi.UpdateTypeRemovedChatBoost:
|
||||
if u.RemovedChatBoost != nil {
|
||||
from = &u.RemovedChatBoost.Source.User
|
||||
if u.RemovedChatBoost.Source.User.ID != 0 {
|
||||
from = &u.RemovedChatBoost.Source.User
|
||||
}
|
||||
chat = &u.RemovedChatBoost.Chat
|
||||
}
|
||||
case tgapi.UpdateTypeManagedBot:
|
||||
if u.ManagedBot != nil {
|
||||
from = &u.ManagedBot.User
|
||||
}
|
||||
case tgapi.UpdateTypeSubscription:
|
||||
if u.Subscription != nil {
|
||||
from = &u.Subscription.User
|
||||
}
|
||||
}
|
||||
if ctx.Msg != nil && from == nil {
|
||||
from = ctx.Msg.From
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
)
|
||||
|
||||
// Ptr returns a pointer to v.
|
||||
func Ptr[T any](v T) *T { return &v }
|
||||
//
|
||||
//go:fix inline
|
||||
func Ptr[T any](v T) *T { return new(v) }
|
||||
|
||||
// Val returns dereferenced pointer value or def when p is nil.
|
||||
func Val[T any](p *T, def T) T {
|
||||
@@ -36,5 +38,5 @@ func generateToken(b int) (string, error) {
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
+20
-9
@@ -157,9 +157,8 @@ func (rl *RateLimiter) GlobalAllow() bool {
|
||||
return limiter.Allow()
|
||||
}
|
||||
|
||||
// Allow checks if a request for the given chat can be made without blocking.
|
||||
// Returns false if: global cooldown, chat cooldown, global limiter, or chat limiter denies.
|
||||
// Note: Global limiter is checked before chat limiter — upstream limits take priority.
|
||||
// Allow checks whether a request for the given chat can be made without blocking.
|
||||
// A rejected chat reservation does not consume global capacity.
|
||||
func (rl *RateLimiter) Allow(chatID int64) bool {
|
||||
// Check global cooldown
|
||||
rl.globalMu.RLock()
|
||||
@@ -177,15 +176,27 @@ func (rl *RateLimiter) Allow(chatID int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check global token bucket
|
||||
limiter := rl.getGlobalLimiter()
|
||||
if limiter != nil && !limiter.Allow() {
|
||||
return false
|
||||
now := time.Now()
|
||||
globalLimiter := rl.getGlobalLimiter()
|
||||
var globalReservation *rate.Reservation
|
||||
if globalLimiter != nil {
|
||||
globalReservation = globalLimiter.ReserveN(now, 1)
|
||||
if !globalReservation.OK() || globalReservation.DelayFrom(now) > 0 {
|
||||
globalReservation.CancelAt(now)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check chat token bucket
|
||||
chatLimiter := rl.getChatLimiter(chatID)
|
||||
return chatLimiter.Allow()
|
||||
chatReservation := chatLimiter.ReserveN(now, 1)
|
||||
if !chatReservation.OK() || chatReservation.DelayFrom(now) > 0 {
|
||||
chatReservation.CancelAt(now)
|
||||
if globalReservation != nil {
|
||||
globalReservation.CancelAt(now)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check applies rate limiting based on configuration.
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) {
|
||||
@@ -28,6 +30,25 @@ func TestRateLimiterChatLocksAreScopedPerChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterRejectedChatDoesNotConsumeGlobalCapacity(t *testing.T) {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetGlobalRate(1)
|
||||
|
||||
if !rl.Allow(42) {
|
||||
t.Fatal("expected initial request for chat 42 to succeed")
|
||||
}
|
||||
rl.globalMu.Lock()
|
||||
rl.globalLimiter = rate.NewLimiter(1, 1)
|
||||
rl.globalMu.Unlock()
|
||||
|
||||
if rl.Allow(42) {
|
||||
t.Fatal("expected exhausted chat limiter to reject the request")
|
||||
}
|
||||
if !rl.Allow(7) {
|
||||
t.Fatal("expected rejected chat request not to consume global capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetGlobalLock(1)
|
||||
|
||||
+3
-3
@@ -2,13 +2,13 @@ package utils
|
||||
|
||||
const (
|
||||
// VersionString is the module version string.
|
||||
VersionString = "1.0.1"
|
||||
VersionString = "1.1.0"
|
||||
// VersionMajor is the module major version.
|
||||
VersionMajor = 1
|
||||
// VersionMinor is the module minor version.
|
||||
VersionMinor = 0
|
||||
VersionMinor = 1
|
||||
// VersionPatch is the module patch version.
|
||||
VersionPatch = 1
|
||||
VersionPatch = 0
|
||||
// VersionBeta is the prerelease counter for the current version.
|
||||
VersionBeta = 0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package laniakea
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateTokenUsesRawURLAlphabet(t *testing.T) {
|
||||
token, err := generateToken(32)
|
||||
if err != nil {
|
||||
t.Fatalf("generateToken returned error: %v", err)
|
||||
}
|
||||
if strings.Contains(token, "=") {
|
||||
t.Fatalf("token contains forbidden padding: %q", token)
|
||||
}
|
||||
for _, r := range token {
|
||||
if !strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-", r) {
|
||||
t.Fatalf("token contains a character forbidden by Telegram: %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user