(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+1
View File
@@ -6,3 +6,4 @@ test/
.codex
.agents/
.claude/
review.md
+1 -1
View File
@@ -2,7 +2,7 @@ version: "2"
run:
timeout: 5m
linters:
disable-all: true
default: none
enable:
- errcheck
- ineffassign
+7 -2
View File
@@ -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 repositorys 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;
+50 -3
View File
@@ -2,8 +2,11 @@
## v1.1.0
### Breaking Changes
- Fixed `Uploader.SendLivePhoto` and `Uploader.SendLivePhotoWithContext` to require both the live-photo video and its static image. The previous one-file signatures could not produce a valid `sendLivePhoto` request.
### Bot API 10.1
- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers with forward-compatible handling of unknown types.
- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers. Unknown text-bearing types retain their nested text through fallback wrappers while unmodeled fields are discarded.
- Added rich message sending support: `tgapi.InputRichMessage`, `tgapi.SendRichMessage` params, and `API.SendRichMessage`/`API.SendRichMessageWithContext`.
- Added rich message draft streaming: `API.SendRichMessageDraft`/`API.SendRichMessageDraftWithContext` for ephemeral ~30-second previews of partially generated messages.
- Added rich message editing: `EditMessageText.RichMessage` (`InputRichMessage`); `Text` is now omitted from the request when empty so rich-only edits are valid.
@@ -11,9 +14,53 @@
- Added join request query support: `User.SupportsJoinRequestQueries`, `ChatFullInfo.GuardBot`, `ChatJoinRequest.QueryID`, `API.AnswerChatJoinRequestQuery` with `ChatJoinRequestQueryResult` constants (`JoinRequestApprove`/`JoinRequestDecline`/`JoinRequestQueue`), and `API.SendChatJoinRequestWebApp` (plus `WithContext` variants).
- Added poll link media: the `tgapi.Link` type, `PollMedia.Link`, and the "link" type with `URL` on `InputPollOptionMedia`.
### Bot API 10.2
- Added block-based rich-message sending with `InputRichMessage.Blocks`, including animation, audio, photo, video, and voice-note input blocks. The `tgrich` package provides matching media constructors with optional block captions.
- Added `InputRichMessage.Media` for media embedded in rich-message HTML or Markdown, with multipart `attach://` upload support.
- Added multipart rich-message uploads through `Uploader.SendRichMessage`. Use `UploaderFile.SetAttachName` to match an `attach://` media reference; rich-message draft helpers reject direct uploads as required by Telegram.
- Added ephemeral-message support: outgoing receiver and callback parameters, reply targets, message fields, edit and delete methods, and ephemeral bot commands. Added community service-message types and subscription update handling.
### Added
- Added the `tgfmt` rich HTML DSL: typed `Rich` (inline) and `RichBlock` (block) fragments whose constructor signatures make invalid nesting uncompilable, inline helpers (`NewRich`, `Bold`, `Link`, `Mention`, `Emoji`, `Time`, `Math`, ...), block constructors (`H1``H6`, `P`, `Pre`/`PreCode`, `Footer`, `Hr`, `Ul`/`Ol`/`Li`/`LiCheckbox`, `Blockquote`/`Aside`, `Photo`/`Video`/`Audio` media with captions and spoilers, `Map`, `Collage`/`Slideshow`, `Table`/`Row`/`Cell`, `Details`, `MathBlock`, anchors), and the top-level `RichItem`/`RichHTML`/`RichMessage` assembly into `tgapi.InputRichMessage` (with `skip_entity_detection` enabled by default).
- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for sending rich messages built from `tgfmt` fragments.
- Added documented `tgrich` constructors and block types for building input rich messages.
- Added `tgrich.BuildHTML` and `tgrich.ToHTML` to validate input block trees, convert them to HTML rich messages, and collect URL, `file_id`, or multipart media references.
- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for validating and sending `tgrich` input blocks.
- Added `UpdateTypeSubscription` routing and normalized message, user, and chat context for guest messages, deleted business messages, anonymous poll answers, reaction counts, managed bots, chat boosts, and subscription updates.
- Added webhook secret-format validation and the exported `ErrBotWebhookOptsSecretTokenInvalid` sentinel.
### Changed
- `AutoGenerateCommandsForScope(nil)` now atomically replaces commands in Telegram's default scope without deleting the previous list first.
- API debug logging now records redacted request JSON and response metadata instead of complete response bodies.
- Scene updates sharing a user or chat session key are serialized, and duplicate scene names from later plugins are skipped with a warning.
- Inline keyboard button builders now keep URL and callback actions mutually exclusive, and `InlineKeyboard.Get` returns independent markup data.
- `NewBot` no longer aliases `BotOpts.Prefixes` or mutates `BotOpts.LoggerBasePath`.
- README requirements now match the module's Go 1.26 directive.
- Migrated the golangci-lint configuration to its v2 schema so the repository lint workflow runs again.
- Added missing Godoc for exported error methods, enum constants, and all public Bot API 10.1/10.2 fields introduced in this release.
### Fixed
- Fixed JSON BotOpts environment placeholders corrupting or injecting JSON when values contain quotes, backslashes, or control characters.
- Preserved checkbox state when converting list items to ordered lists and made generated HTML attribute ordering deterministic.
- Added validation for rich-block type discriminators, heading sizes, list fields, table cells, map parameters, and media values.
- Fixed generated webhook secrets using padded Base64 characters that Telegram rejects, stopped logging generated secrets, and added HTTP read and idle timeouts to the webhook server.
- Fixed negative group and channel IDs receiving global rather than per-chat `retry_after` cooldowns.
- Fixed rejected per-chat requests consuming global rate-limit capacity and draft construction consuming an extra rate-limit token before the API request.
- Fixed subscription updates being decoded as `UpdateTypeUnknown`.
- Fixed rich-text and rich-block decoding silently accepting malformed typed fields or a top-level `null` rich-text value.
- Fixed `tgrich.BuildHTML` disabling Telegram entity detection and accepting the draft-only thinking block; `BuildDraftHTML` now provides the explicit draft path.
- Fixed `tgrich.BuildHTML` silently losing explicit bank-card, mention, hashtag, cashtag, and bot-command values when their visible text differs.
- Fixed API debug logs exposing webhook, payment, callback, passport, and managed-bot secrets.
- Fixed panics and nil callbacks in asynchronous middleware terminating the process; failures now reach the logger and observer error stream.
- Fixed multipart helpers attempting direct file uploads for rich-message drafts, which Telegram does not support; they now return `ErrRichMessageDraftUploadUnsupported`.
- Fixed draft ID zero values and collisions overwriting tracked drafts, nil draft APIs panicking, and draft entity slices aliasing caller memory.
- Fixed scene session payloads and returned inline keyboard markup aliasing mutable internal slices.
- Recovered panics from runner callbacks so they are reported through normal runner and error observer events instead of terminating the process.
### Tests
- Added JSON regression coverage for rich-message embedded media and ephemeral send, edit, and delete parameters.
- Added regression coverage for escaped environment placeholders and both required `sendLivePhoto` multipart fields.
- Added regression coverage for API log redaction, same-session scene serialization, duplicate scene registration, async middleware failures, and rich entity preservation.
- Added rich HTML renderer coverage for all input block and media types, multipart references, field validation, and Telegram's text, block, nesting, media, and table-width limits.
- Added regression coverage for webhook token syntax, update context normalization, subscription routing, rate-limit capacity, draft ID collisions, runner panics, scene and keyboard aliasing, command replacement, and malformed rich JSON.
## v1.0.2
+1 -1
View File
@@ -2,7 +2,7 @@
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![Go Version](https://img.shields.io/badge/Go-1.26+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
+1 -1
View File
@@ -2,7 +2,7 @@
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![Go Version](https://img.shields.io/badge/Go-1.26+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
+6 -4
View File
@@ -122,6 +122,7 @@ type Bot[T AppData] struct {
sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope
sceneLocks sceneKeyLocker
updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID
@@ -191,7 +192,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
api := tgapi.NewAPI(apiOpts)
uploader := tgapi.NewUploader(api)
prefixes := opts.Prefixes
prefixes := append([]string(nil), opts.Prefixes...)
if len(prefixes) == 0 {
prefixes = []string{"/"}
}
@@ -230,10 +231,11 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
if len(opts.ErrorTemplate) > 0 {
bot.errorTemplate = opts.ErrorTemplate
}
if len(opts.LoggerBasePath) == 0 {
opts.LoggerBasePath = "./"
loggerOpts := *opts
if len(loggerOpts.LoggerBasePath) == 0 {
loggerOpts.LoggerBasePath = "./"
}
bot.initLoggers(opts)
bot.initLoggers(&loggerOpts)
if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion {
bot.logger.Warnln(
+15 -2
View File
@@ -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)
})
}
+19
View File
@@ -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")
+16
View File
@@ -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
View File
@@ -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:
+27
View File
@@ -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
View File
@@ -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
}
+47
View File
@@ -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
View File
@@ -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 {
+21 -6
View File
@@ -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,
+39
View File
@@ -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)
+2
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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 {
+5 -4
View File
@@ -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
}
+22 -21
View File
@@ -8,10 +8,9 @@ import (
"reflect"
"strconv"
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgfmt"
"git.scuroneko.dev/scuroneko/laniakea/tgrich"
"git.scuroneko.dev/scuroneko/sneklog/v2"
)
@@ -551,28 +550,19 @@ func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
return nil
}
if ctx.API.Limiter != nil {
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
defer cancel()
if err := ctx.API.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.Logger.Errorln(err)
return nil
}
}
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
return draft
}
// NewDraft creates a new message draft associated with the current chat.
// Uses the API limiter to avoid rate limiting.
// Draft sends are rate-limited by the API client.
func (ctx *MessageContext) NewDraft() *Draft {
return ctx.newDraft(tgapi.ParseNone)
}
// NewDraftMarkdown creates a new message draft associated with the current chat,
// with Markdown V2 parse mode enabled.
// Uses the API limiter to avoid rate limiting.
// Draft sends are rate-limited by the API client.
func (ctx *MessageContext) NewDraftMarkdown() *Draft {
return ctx.newDraft(tgapi.ParseMarkdownV2)
}
@@ -852,14 +842,25 @@ func (ctx *MessageContext) richAnswer(rich tgapi.InputRichMessage, keyboard *Inl
}
}
// RichAnswer sends a rich message (Bot API 10.1) built from tgfmt fragments.
// Both inline (tgfmt.Rich) and block (tgfmt.RichBlock) fragments are accepted
// at the top level: Telegram merges adjacent inline content into paragraphs.
func (ctx *MessageContext) RichAnswer(items ...tgfmt.RichItem) *AnswerMessage {
return ctx.richAnswer(tgfmt.RichMessage(items...), nil)
func (ctx *MessageContext) richBlocksAnswer(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage {
rich, err := tgrich.BuildHTML(blocks...)
if err != nil {
ctx.Logger.Errorln(err)
return nil
}
return ctx.richAnswer(rich, keyboard)
}
// RichAnswerKeyboard sends a rich message with an inline keyboard.
func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, items ...tgfmt.RichItem) *AnswerMessage {
return ctx.richAnswer(tgfmt.RichMessage(items...), keyboard)
// RichAnswer builds and sends input rich-message blocks.
//
// Since: Bot API 10.2
func (ctx *MessageContext) RichAnswer(blocks ...tgapi.InputRichBlock) *AnswerMessage {
return ctx.richBlocksAnswer(nil, blocks...)
}
// RichAnswerKeyboard builds and sends input rich-message blocks with an inline keyboard.
//
// Since: Bot API 10.2
func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage {
return ctx.richBlocksAnswer(keyboard, blocks...)
}
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+55
View File
@@ -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
View File
@@ -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),
+27
View File
@@ -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)
}
}
+5 -3
View File
@@ -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()
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+94
View File
@@ -64,6 +64,100 @@ func TestInputRichMessageContentMarshal(t *testing.T) {
}
}
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)
+35 -1
View File
@@ -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"`
+27 -12
View File
@@ -112,9 +112,13 @@ type PaidMediaInfo struct {
type PaidMediaType string
const (
PaidMediaPreviewType PaidMediaType = "preview"
PaidMediaPhotoType PaidMediaType = "photo"
PaidMediaVideoType PaidMediaType = "video"
// PaidMediaPreviewType identifies a paid-media preview.
PaidMediaPreviewType PaidMediaType = "preview"
// PaidMediaPhotoType identifies a paid photo.
PaidMediaPhotoType PaidMediaType = "photo"
// PaidMediaVideoType identifies a paid video.
PaidMediaVideoType PaidMediaType = "video"
// PaidMediaLivePhotoType identifies a paid live photo.
PaidMediaLivePhotoType PaidMediaType = "live_photo" // Since: Bot API 10.0
)
@@ -172,7 +176,8 @@ type PollOption struct {
type InputPollOptionMedia struct {
Type string `json:"type"`
Media string `json:"media,omitempty"`
URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link"
// URL contains the HTTP URL.
URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link"
}
// InputPollOption contains information about one answer option in a poll to be sent.
@@ -226,8 +231,8 @@ const (
// See https://core.telegram.org/bots/api#pollanswer
type PollAnswer struct {
PollID string `json:"poll_id"`
VoterChat Chat `json:"voter_chat"` // Since: Bot API 6.8
User User `json:"user"`
VoterChat Chat `json:"voter_chat,omitempty"` // Since: Bot API 6.8
User User `json:"user,omitempty"` // FIXME: Pointer in v2
OptionIDs []int `json:"option_ids"`
OptionPersistentIDs []string `json:"option_persistent_ids"` // Since: Bot API 9.6
}
@@ -264,15 +269,17 @@ type Poll struct {
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#link
type Link struct {
// URL contains the HTTP URL.
URL string `json:"url"`
}
// PollMedia represents media attached to a poll.
// Since: Bot API 10.0
type PollMedia struct {
Animation *Animation `json:"animation,omitempty"`
Audio *Audio `json:"audio,omitempty"`
Document *Document `json:"document,omitempty"`
Animation *Animation `json:"animation,omitempty"`
Audio *Audio `json:"audio,omitempty"`
Document *Document `json:"document,omitempty"`
// Link contains link media attached to the poll.
Link *Link `json:"link,omitempty"` // Since: Bot API 10.1
LivePhoto *LivePhoto `json:"live_photo,omitempty"`
Location *Location `json:"location,omitempty"`
@@ -352,10 +359,18 @@ const (
InputMediaTypeVideo InputMediaType = "video"
// InputMediaTypeAudio is an audio file.
InputMediaTypeAudio InputMediaType = "audio"
// InputMediaTypeVoiceNote is a voice message.
//
// Since: Bot API 10.2
InputMediaTypeVoiceNote InputMediaType = "voice_note"
InputMediaTypeSticker InputMediaType = "sticker"
InputMediaTypeLocation InputMediaType = "location"
InputMediaTypeVenue InputMediaType = "venue"
// InputMediaTypeSticker is a sticker.
InputMediaTypeSticker InputMediaType = "sticker"
// InputMediaTypeLocation is a location.
InputMediaTypeLocation InputMediaType = "location"
// InputMediaTypeVenue is a venue.
InputMediaTypeVenue InputMediaType = "venue"
// InputMediaTypeLivePhoto is a live photo.
InputMediaTypeLivePhoto InputMediaType = "live_photo" // Since: Bot API 10.0
)
+2
View File
@@ -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.
+7 -3
View File
@@ -498,8 +498,10 @@ const (
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
type AnswerChatJoinRequestQuery struct {
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
Result ChatJoinRequestQueryResult `json:"result"`
// ChatJoinRequestQueryID identifies the chat join request query.
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
// Result contains the decision for the join request query.
Result ChatJoinRequestQueryResult `json:"result"`
}
// AnswerChatJoinRequestQuery processes a received chat join request query.
@@ -524,8 +526,10 @@ func (api *API) AnswerChatJoinRequestQueryWithContext(ctx context.Context, param
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
type SendChatJoinRequestWebApp struct {
// ChatJoinRequestQueryID identifies the chat join request query.
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
WebAppURL string `json:"web_app_url"`
// WebAppURL is the HTTPS URL of the Mini App to open.
WebAppURL string `json:"web_app_url"`
}
// SendChatJoinRequestWebApp shows a Mini App to the user before deciding a
+27 -1
View File
@@ -52,7 +52,6 @@ type ChatFullInfo struct {
PersonalChat *Chat `json:"personal_chat,omitempty"`
ParentChat *Chat `json:"parent_chat,omitempty"` // Since: Bot API 9.2
GuardBot *User `json:"guard_bot,omitempty"` // Since: Bot API 10.1; visible to chat administrators only
AvailableReaction []ReactionType `json:"available_reaction,omitempty"`
@@ -92,6 +91,10 @@ type ChatFullInfo struct {
FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` // Since: Bot API 9.4
UniqueGiftColors *UniqueGiftColors `json:"unique_gift_colors,omitempty"` // Since: Bot API 9.3
PaidMessageStarCount *int `json:"paid_message_star_count,omitempty"` // Since: Bot API 9.3
// GuardBot contains the guard bot visible to chat administrators.
GuardBot *User `json:"guard_bot,omitempty"` // Since: Bot API 10.1; visible to chat administrators only
// Community contains information about the affected community.
Community *Community `json:"community,omitempty"` // Since: Bot API 10.2
}
// ChatPhoto represents a chat photo.
@@ -318,3 +321,26 @@ type ChatBoostRemoved struct {
RemoveDate int `json:"remove_date"`
Source ChatBoostSource `json:"source"`
}
// Community represents a group of chats.
//
// Since: Bot API 10.2
type Community struct {
// ID uniquely identifies the value within its containing object.
ID int64 `json:"id"`
// Name is the user-facing or reference name of the value.
Name string `json:"name"`
}
// CommunityChatAdded describes a service message about a chat joining a community.
//
// Since: Bot API 10.2
type CommunityChatAdded struct {
// Community contains information about the affected community.
Community Community `json:"community"`
}
// CommunityChatRemoved describes a service message about a chat leaving a community.
//
// Since: Bot API 10.2
type CommunityChatRemoved struct{}
+5
View File
@@ -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
+1
View File
@@ -20,6 +20,7 @@ type InlineQueryResultsButton struct {
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#inputrichmessagecontent
type InputRichMessageContent struct {
// RichMessage contains structured rich-message content.
RichMessage InputRichMessage `json:"rich_message"`
}
+57
View File
@@ -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)
}
+39
View File
@@ -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)
}
}
+216 -17
View File
@@ -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"`
@@ -551,8 +567,9 @@ type EditMessageText struct {
ParseMode ParseMode `json:"parse_mode,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
RichMessage *InputRichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1; required if Text is not specified
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.
@@ -1095,19 +1112,31 @@ func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params Del
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#sendrichmessage
type SendRichMessage struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int64 `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"`
// 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 InputRichMessage `json:"rich_message"`
DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
MessageEffectID string `json:"message_effect_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 *ReplyParameters `json:"reply_parameters,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,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.
@@ -1131,11 +1160,14 @@ func (api *API) SendRichMessageWithContext(ctx context.Context, params SendRichM
// Since: Bot API 10.1
// See https://core.telegram.org/bots/api#sendrichmessagedraft
type SendRichMessageDraft struct {
ChatID int64 `json:"chat_id"`
// 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"`
DraftID int64 `json:"draft_id"`
// RichMessage contains structured rich-message content.
RichMessage InputRichMessage `json:"rich_message"`
}
@@ -1158,3 +1190,170 @@ func (api *API) SendRichMessageDraftWithContext(ctx context.Context, params Send
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)
}
+53 -17
View File
@@ -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,6 +159,7 @@ 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
// 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"`
@@ -206,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
@@ -395,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"`
@@ -750,18 +765,39 @@ type SentGuestMessage struct {
InlineMessageID string `json:"inline_message_id"`
}
// RichMessage Rich formatted message.
// 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 bool `json:"is_rtl,omitempty"`
// IsRTL requests right-to-left rich-message layout.
IsRTL bool `json:"is_rtl,omitempty"`
}
// InputRichMessage Describes a rich message to be sent. Exactly one of the fields html or markdown must be used.
// 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 {
HTML string `json:"html,omitempty"`
Markdown string `json:"markdown,omitempty"`
IsRTL bool `json:"is_rtl,omitempty"`
SkipEntityDetection bool `json:"skip_entity_detection,omitempty"`
// 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
View File
@@ -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
View File
@@ -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.
+611
View File
@@ -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})
}
+427
View File
@@ -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() {}
+65
View File
@@ -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{}
}
+155 -209
View File
@@ -1,51 +1,44 @@
package tgapi
import (
"encoding/json"
"fmt"
)
// Rich messages (Bot API 10.1), receive side: the RichText*/RichBlock* types
// mirror what the server sends in Message.rich_message, plus their parsers.
// These types intentionally have no constructors: sending goes only through
// InputRichMessage (html/markdown), and HTML generation lives in tgfmt
// (rich.go). Names follow the API objects; the exception is
// RichBlockQuotation (officially RichBlockBlockQuotation, the double Block
// is dropped).
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()
}
// ---------------------------------------------------------------------------
// Base forms: string and array
// ---------------------------------------------------------------------------
// 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() {}
// ---------------------------------------------------------------------------
// Nodes with only a text field. There are 9; only the tag differs.
// bold italic underline strikethrough spoiler subscript superscript marked code
// ---------------------------------------------------------------------------
// RichTextWrap covers all "pure" wrapper nodes with a single type.
//
// Since: Bot API 10.1
type RichTextWrap struct {
Tag string // "bold", "italic", ...
// 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"`
@@ -59,17 +52,21 @@ var richTextWrapTags = map[string]bool{
"superscript": true, "marked": true, "code": true,
}
// ---------------------------------------------------------------------------
// Nodes with text + one extra string field.
// ---------------------------------------------------------------------------
// 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 string
// 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"`
@@ -79,12 +76,20 @@ func (v RichTextURL) MarshalJSON() ([]byte, error) {
}
// RichTextEmailAddress is rich text linking to an email address.
//
// Since: Bot API 10.1
type RichTextEmailAddress struct {
Text RichText
// 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"`
@@ -94,12 +99,20 @@ func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) {
}
// RichTextPhoneNumber is rich text linking to a phone number.
//
// Since: Bot API 10.1
type RichTextPhoneNumber struct {
Text RichText
// 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"`
@@ -109,12 +122,20 @@ func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) {
}
// RichTextBankCardNumber is rich text marked as a bank card number.
//
// Since: Bot API 10.1
type RichTextBankCardNumber struct {
Text RichText
// 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"`
@@ -124,12 +145,20 @@ func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) {
}
// RichTextMention is rich text mentioning a user by username.
//
// Since: Bot API 10.1
type RichTextMention struct {
Text RichText
// 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"`
@@ -139,12 +168,20 @@ func (v RichTextMention) MarshalJSON() ([]byte, error) {
}
// RichTextHashtag is rich text marked as a hashtag.
//
// Since: Bot API 10.1
type RichTextHashtag struct {
Text RichText
// 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"`
@@ -154,12 +191,20 @@ func (v RichTextHashtag) MarshalJSON() ([]byte, error) {
}
// RichTextCashtag is rich text marked as a cashtag.
//
// Since: Bot API 10.1
type RichTextCashtag struct {
Text RichText
// 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"`
@@ -169,12 +214,20 @@ func (v RichTextCashtag) MarshalJSON() ([]byte, error) {
}
// RichTextBotCommand is rich text marked as a bot command.
//
// Since: Bot API 10.1
type RichTextBotCommand struct {
Text RichText
// 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"`
@@ -184,12 +237,20 @@ func (v RichTextBotCommand) MarshalJSON() ([]byte, error) {
}
// RichTextAnchorLink is rich text linking to a named anchor in the same message.
//
// Since: Bot API 10.1
type RichTextAnchorLink struct {
Text RichText
// 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"`
@@ -199,12 +260,20 @@ func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) {
}
// 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"`
@@ -214,12 +283,20 @@ func (v RichTextReference) MarshalJSON() ([]byte, error) {
}
// RichTextReferenceLink is rich text linking to a named reference.
//
// Since: Bot API 10.1
type RichTextReferenceLink struct {
Text RichText
// 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"`
@@ -228,18 +305,23 @@ func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) {
}{"reference_link", v.Text, v.ReferenceName})
}
// ---------------------------------------------------------------------------
// Nodes with text + multiple/non-string fields.
// ---------------------------------------------------------------------------
// RichTextDateTime is rich text bound to a point in time with a display format.
//
// Since: Bot API 10.1
type RichTextDateTime struct {
Text RichText
UnixTime int64
// 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"`
@@ -250,12 +332,20 @@ func (v RichTextDateTime) MarshalJSON() ([]byte, error) {
}
// 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"`
@@ -264,17 +354,21 @@ func (v RichTextTextMention) MarshalJSON() ([]byte, error) {
}{"text_mention", v.Text, v.User})
}
// ---------------------------------------------------------------------------
// LEAVES: no text field.
// ---------------------------------------------------------------------------
// RichTextCustomEmoji is a custom emoji leaf with alternative text.
//
// Since: Bot API 10.1
type RichTextCustomEmoji struct {
CustomEmojiID string
// 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"`
@@ -284,11 +378,18 @@ func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) {
}
// 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"`
@@ -297,176 +398,21 @@ func (v RichTextMathematicalExpression) MarshalJSON() ([]byte, error) {
}
// 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})
}
// ---------------------------------------------------------------------------
// JSON -> RichText parsing
// ---------------------------------------------------------------------------
// 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 for forward compatibility.
func UnmarshalRichText(data []byte) (RichText, error) {
// 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"`
}
_ = json.Unmarshal(data, &v)
return RichTextEmailAddress{inner, v.V}, nil
case "phone_number":
var v struct {
V string `json:"phone_number"`
}
_ = json.Unmarshal(data, &v)
return RichTextPhoneNumber{inner, v.V}, nil
case "bank_card_number":
var v struct {
V string `json:"bank_card_number"`
}
_ = json.Unmarshal(data, &v)
return RichTextBankCardNumber{inner, v.V}, nil
case "mention":
var v struct {
V string `json:"username"`
}
_ = json.Unmarshal(data, &v)
return RichTextMention{inner, v.V}, nil
case "hashtag":
var v struct {
V string `json:"hashtag"`
}
_ = json.Unmarshal(data, &v)
return RichTextHashtag{inner, v.V}, nil
case "cashtag":
var v struct {
V string `json:"cashtag"`
}
_ = json.Unmarshal(data, &v)
return RichTextCashtag{inner, v.V}, nil
case "bot_command":
var v struct {
V string `json:"bot_command"`
}
_ = json.Unmarshal(data, &v)
return RichTextBotCommand{inner, v.V}, nil
case "anchor_link":
var v struct {
V string `json:"anchor_name"`
}
_ = json.Unmarshal(data, &v)
return RichTextAnchorLink{inner, v.V}, nil
case "reference":
var v struct {
V string `json:"name"`
}
_ = json.Unmarshal(data, &v)
return RichTextReference{inner, v.V}, nil
case "reference_link":
var v struct {
V string `json:"reference_name"`
}
_ = json.Unmarshal(data, &v)
return RichTextReferenceLink{inner, v.V}, nil
case "date_time":
var v struct {
UnixTime int64 `json:"unix_time"`
DateTimeFormat string `json:"date_time_format"`
}
_ = json.Unmarshal(data, &v)
return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
case "text_mention":
var v struct {
User User `json:"user"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
return RichTextCustomEmoji{v.ID, v.Alt}, nil
case "mathematical_expression":
var v struct {
Expression string `json:"expression"`
}
_ = json.Unmarshal(data, &v)
return RichTextMathematicalExpression{v.Expression}, nil
case "anchor":
var v struct {
Name string `json:"name"`
}
_ = json.Unmarshal(data, &v)
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)
}
}
@@ -68,3 +68,18 @@ func TestRichTextLeafHasNoText(t *testing.T) {
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")
}
})
}
}
+514
View File
@@ -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
}
@@ -92,11 +92,11 @@ func TestRichBlockRoundtrip(t *testing.T) {
{Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"},
},
{
{Text: RichTextPlain("Alice"), Colspan: 2},
{Text: RichTextPlain("Alice"), ColSpan: 2},
},
{
{}, // invisible cell
{Text: RichTextPlain("42"), Rowspan: 2},
{Text: RichTextPlain("42"), RowSpan: 2},
},
},
IsBordered: true,
@@ -251,3 +251,18 @@ func TestRichBlockUnknownTypeWithoutTextIsError(t *testing.T) {
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")
}
})
}
}
-738
View File
@@ -1,738 +0,0 @@
package tgapi
import (
"encoding/json"
"fmt"
)
// RichBlock is a block in a structured rich message.
type RichBlock interface {
isRichBlock()
}
// ---------------------------------------------------------------------------
// Helper types
// ---------------------------------------------------------------------------
// RichBlockCaption is the caption of a media block or container.
type RichBlockCaption struct {
Text RichText
Credit RichText
}
func (c RichBlockCaption) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Text RichText `json:"text"`
Credit RichText `json:"credit,omitempty"`
}{c.Text, c.Credit})
}
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.
type RichBlockListItem struct {
Label string
Blocks []RichBlock
HasCheckbox bool
IsChecked bool
Value int // for ordered lists: numeric value of the marker
Type string // for ordered lists: "a", "A", "i", "I" or "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 string `json:"type,omitempty"`
}{i.Label, i.Blocks, i.HasCheckbox, i.IsChecked, i.Value, i.Type})
}
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 string `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.
type RichBlockTableCell struct {
Text RichText
IsHeader bool
Colspan int
Rowspan int
Align string // "left", "center" or "right"
VAlign string // "top", "middle" or "bottom"
}
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})
}
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: pure text blocks — paragraph, footer, thinking.
// ---------------------------------------------------------------------------
// RichBlockWrap covers all blocks that have only a text field.
type RichBlockWrap struct {
Tag string
Text RichText
}
func (RichBlockWrap) isRichBlock() {}
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,
}
// ---------------------------------------------------------------------------
// Section heading: text + size
// ---------------------------------------------------------------------------
// RichBlockSectionHeading is a section heading block.
type RichBlockSectionHeading struct {
Text RichText
Size int // 1-6, 1 is the largest
}
func (RichBlockSectionHeading) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Block with text + language
// ---------------------------------------------------------------------------
// RichBlockPreformatted is a preformatted code block.
type RichBlockPreformatted struct {
Text RichText
Language string
}
func (RichBlockPreformatted) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Quotations
// ---------------------------------------------------------------------------
// RichBlockQuotation is a block quotation with block-level content
// (officially RichBlockBlockQuotation).
type RichBlockQuotation struct {
Blocks []RichBlock
Credit RichText
}
func (RichBlockQuotation) isRichBlock() {}
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.
type RichBlockPullQuotation struct {
Text RichText
Credit RichText
}
func (RichBlockPullQuotation) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
// RichBlockList is a list block.
type RichBlockList struct {
Items []RichBlockListItem
}
func (RichBlockList) isRichBlock() {}
func (b RichBlockList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Items []RichBlockListItem `json:"items"`
}{"list", b.Items})
}
// ---------------------------------------------------------------------------
// Containers with blocks []RichBlock + caption
// ---------------------------------------------------------------------------
// RichBlockCollage is a collage of media blocks.
type RichBlockCollage struct {
Blocks []RichBlock
Caption *RichBlockCaption
}
func (RichBlockCollage) isRichBlock() {}
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.
type RichBlockSlideshow struct {
Blocks []RichBlock
Caption *RichBlockCaption
}
func (RichBlockSlideshow) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Details — expandable block
// ---------------------------------------------------------------------------
// RichBlockDetails is an expandable block with an inline summary.
type RichBlockDetails struct {
Summary RichText
Blocks []RichBlock
IsOpen bool
}
func (RichBlockDetails) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Table
// ---------------------------------------------------------------------------
// RichBlockTable is a table block.
type RichBlockTable struct {
Cells [][]RichBlockTableCell
IsBordered bool
IsStriped bool
Caption RichText
}
func (RichBlockTable) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Map
// ---------------------------------------------------------------------------
// RichBlockMap is a location map block.
type RichBlockMap struct {
Location Location
Zoom int // 13-20
Width int
Height int
Caption *RichBlockCaption
}
func (RichBlockMap) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Media blocks
// ---------------------------------------------------------------------------
// RichBlockPhoto is a photo block.
type RichBlockPhoto struct {
Photo []PhotoSize
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockPhoto) isRichBlock() {}
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.
type RichBlockVideo struct {
Video Video
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockVideo) isRichBlock() {}
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.
type RichBlockAudio struct {
Audio Audio
Caption *RichBlockCaption
}
func (RichBlockAudio) isRichBlock() {}
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.
type RichBlockAnimation struct {
Animation Animation
HasSpoiler bool
Caption *RichBlockCaption
}
func (RichBlockAnimation) isRichBlock() {}
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.
type RichBlockVoiceNote struct {
VoiceNote Voice
Caption *RichBlockCaption
}
func (RichBlockVoiceNote) isRichBlock() {}
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})
}
// ---------------------------------------------------------------------------
// Leaves without nested content
// ---------------------------------------------------------------------------
// RichBlockDivider is a horizontal divider block.
type RichBlockDivider struct{}
func (RichBlockDivider) isRichBlock() {}
func (b RichBlockDivider) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
}{"divider"})
}
// RichBlockMathematicalExpression is a block-level mathematical expression.
type RichBlockMathematicalExpression struct {
Expression string
}
func (RichBlockMathematicalExpression) isRichBlock() {}
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.
type RichBlockAnchor struct {
Name string
}
func (RichBlockAnchor) isRichBlock() {}
func (b RichBlockAnchor) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Type string `json:"type"`
Name string `json:"name"`
}{"anchor", b.Name})
}
// ---------------------------------------------------------------------------
// JSON -> RichBlock parsing
// ---------------------------------------------------------------------------
// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the
// type tag. Unknown types that carry a text field are preserved as
// RichBlockWrap for forward compatibility.
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"`
}
_ = json.Unmarshal(data, &v)
text, _ := parseOptRichText(head.Text)
return RichBlockSectionHeading{text, v.Size}, nil
case "pre":
var v struct {
Language string `json:"language"`
}
_ = json.Unmarshal(data, &v)
text, _ := parseOptRichText(head.Text)
return RichBlockPreformatted{text, v.Language}, nil
case "blockquote":
var raw struct {
Blocks json.RawMessage `json:"blocks"`
Credit json.RawMessage `json:"credit"`
}
_ = json.Unmarshal(data, &raw)
blocks, err := unmarshalRichBlocks(raw.Blocks)
if err != nil {
return nil, err
}
credit, _ := parseOptRichText(raw.Credit)
return RichBlockQuotation{blocks, credit}, nil
case "pullquote":
var raw struct {
Credit json.RawMessage `json:"credit"`
}
_ = json.Unmarshal(data, &raw)
text, _ := parseOptRichText(head.Text)
credit, _ := parseOptRichText(raw.Credit)
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"`
}
_ = json.Unmarshal(data, &raw)
summary, _ := parseOptRichText(raw.Summary)
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, _ := parseOptRichText(raw.Caption)
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"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil
case "audio":
var v struct {
Audio Audio `json:"audio"`
Caption *RichBlockCaption `json:"caption"`
}
_ = json.Unmarshal(data, &v)
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"`
}
_ = json.Unmarshal(data, &v)
return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil
case "voice_note":
var v struct {
VoiceNote Voice `json:"voice_note"`
Caption *RichBlockCaption `json:"caption"`
}
_ = json.Unmarshal(data, &v)
return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil
case "divider":
return RichBlockDivider{}, nil
case "mathematical_expression":
var v struct {
Expression string `json:"expression"`
}
_ = json.Unmarshal(data, &v)
return RichBlockMathematicalExpression{v.Expression}, nil
case "anchor":
var v struct {
Name string `json:"name"`
}
_ = json.Unmarshal(data, &v)
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.
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 parses the blocks through UnmarshalRichBlock: the Blocks
// field is interface-typed, so the standard unmarshaler cannot handle it.
func (m *RichMessage) UnmarshalJSON(data []byte) error {
parsed, err := UnmarshalRichMessage(data)
if err != nil {
return err
}
*m = parsed
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// parseOptRichText parses an optional RichText field: absent and null yield 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
}
+4
View File
@@ -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"`
+46 -4
View File
@@ -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
}
@@ -557,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"
)
@@ -581,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"
)
@@ -604,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"`
}
+15
View File
@@ -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
View File
@@ -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)
+192
View File
@@ -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)
}
}
+89 -8
View File
@@ -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)
}
+12 -41
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
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
View File
@@ -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
View File
@@ -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, "\\", "\\\\")
-492
View File
@@ -1,492 +0,0 @@
package tgfmt
import (
"fmt"
"strconv"
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Rich is an inline fragment of rich-message HTML (Bot API 10.1).
// Raw text enters through NewRich, which escapes it; fragments compose as-is.
type Rich string
// RichBlock is a block-level fragment of rich-message HTML. Block
// constructors accept only Rich arguments, so invalid nesting (a block
// inside inline content) does not compile.
type RichBlock string
// RichItem is any rich-message fragment: Rich or RichBlock. Both are valid
// at the top level of a message — Telegram merges adjacent inline content
// into paragraphs.
type RichItem interface{ richItem() string }
func (r Rich) richItem() string { return string(r) }
func (r RichBlock) richItem() string { return string(r) }
// RichHTML concatenates fragments into the final rich-message HTML string.
func RichHTML(items ...RichItem) string {
var b strings.Builder
for _, item := range items {
b.WriteString(item.richItem())
}
return b.String()
}
// RichMessage builds a ready-to-send InputRichMessage from fragments.
// SkipEntityDetection is enabled so the server does not add auto-detected
// entities; set IsRTL on the result if needed.
func RichMessage(items ...RichItem) tgapi.InputRichMessage {
return tgapi.InputRichMessage{
HTML: RichHTML(items...),
SkipEntityDetection: true,
}
}
func richJoin(items ...Rich) Rich {
var out Rich
for _, item := range items {
out += item
}
return out
}
func richJoinSep(sep Rich, items ...Rich) Rich {
var out Rich
for i, item := range items {
if i > 0 {
out += sep
}
out += item
}
return out
}
func richBlocksJoin(items ...RichBlock) RichBlock {
var out RichBlock
for _, item := range items {
out += item
}
return out
}
// openTag omits the space when there are no attributes.
func openTag(name string, attrs []string) string {
if len(attrs) == 0 {
return "<" + name + ">"
}
return "<" + name + " " + strings.Join(attrs, " ") + ">"
}
func cite(credit Rich) Rich {
if credit == "" {
return ""
}
return "<cite>" + credit + "</cite>"
}
// NewRich escapes raw text and returns it as an inline fragment.
func NewRich(text string) Rich { return Rich(escapeHTML(text)) }
// Bold wraps the fragment in <b>.
func (r Rich) Bold() Rich { return "<b>" + r + "</b>" }
// Italic wraps the fragment in <i>.
func (r Rich) Italic() Rich { return "<i>" + r + "</i>" }
// Underline wraps the fragment in <u>.
func (r Rich) Underline() Rich { return "<u>" + r + "</u>" }
// Strike wraps the fragment in <s>.
func (r Rich) Strike() Rich { return "<s>" + r + "</s>" }
// Code wraps the fragment in <code>.
func (r Rich) Code() Rich { return "<code>" + r + "</code>" }
// Mark wraps the fragment in <mark>.
func (r Rich) Mark() Rich { return "<mark>" + r + "</mark>" }
// Sub wraps the fragment in <sub>.
func (r Rich) Sub() Rich { return "<sub>" + r + "</sub>" }
// Sup wraps the fragment in <sup>.
func (r Rich) Sup() Rich { return "<sup>" + r + "</sup>" }
// Spoiler wraps the fragment in <tg-spoiler>.
func (r Rich) Spoiler() Rich { return "<tg-spoiler>" + r + "</tg-spoiler>" }
// Link wraps the fragment in a hyperlink to url.
func (r Rich) Link(url string) Rich { return `<a href="` + escapeRich(url) + `">` + r + "</a>" }
// Email wraps the fragment in a mailto: link.
func (r Rich) Email(email string) Rich { return r.Link("mailto:" + email) }
// Phone wraps the fragment in a tel: link.
func (r Rich) Phone(phone string) Rich { return r.Link("tel:" + phone) }
// Mention wraps the fragment in an inline user mention link.
func (r Rich) Mention(userID int64) Rich {
return r.Link("tg://user?id=" + strconv.FormatInt(userID, 10))
}
// Anchor marks the fragment as a named anchor (<a name>).
func (r Rich) Anchor(name string) Rich { return `<a name="` + escapeRich(name) + `">` + r + "</a>" }
// AnchorLink wraps the fragment in an in-document link to a named anchor
// or reference; the server resolves which one by the target name.
func (r Rich) AnchorLink(anchor string) Rich { return r.Link("#" + anchor) }
// Ref wraps the fragment in a <tg-reference> to the named reference.
func (r Rich) Ref(ref string) Rich {
return `<tg-reference name="` + escapeRich(ref) + `">` + r + "</tg-reference>"
}
// Emoji builds a custom emoji fragment with alt text as fallback.
func Emoji(emojiID, alt string) Rich {
return `<tg-emoji emoji-id="` + escapeRich(emojiID) + `">` + escapeRich(alt) + `</tg-emoji>`
}
// Time marks the fragment as a <tg-time> bound to t.
func (r Rich) Time(t time.Time) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `">` + r + "</tg-time>"
}
// TimeFormat marks the fragment as a <tg-time> with an explicit display format.
func (r Rich) TimeFormat(t time.Time, format string) Rich {
return `<tg-time unix="` + Rich(strconv.FormatInt(t.Unix(), 10)) + `" format="` + escapeRich(format) + `">` + r + "</tg-time>"
}
// Math wraps the fragment in an inline <tg-math> expression.
func (r Rich) Math() Rich { return `<tg-math>` + r + `</tg-math>` }
// Br returns a line break fragment.
func Br() Rich { return "<br>" }
// H1 builds a level-1 heading block.
func H1(items ...Rich) RichBlock { return RichBlock("<h1>" + richJoin(items...) + "</h1>") }
// H2 builds a level-2 heading block.
func H2(items ...Rich) RichBlock { return RichBlock("<h2>" + richJoin(items...) + "</h2>") }
// H3 builds a level-3 heading block.
func H3(items ...Rich) RichBlock { return RichBlock("<h3>" + richJoin(items...) + "</h3>") }
// H4 builds a level-4 heading block.
func H4(items ...Rich) RichBlock { return RichBlock("<h4>" + richJoin(items...) + "</h4>") }
// H5 builds a level-5 heading block.
func H5(items ...Rich) RichBlock { return RichBlock("<h5>" + richJoin(items...) + "</h5>") }
// H6 builds a level-6 heading block.
func H6(items ...Rich) RichBlock { return RichBlock("<h6>" + richJoin(items...) + "</h6>") }
// P builds a paragraph block.
func P(items ...Rich) RichBlock { return RichBlock("<p>" + richJoin(items...) + "</p>") }
// Pre builds a preformatted code block.
func Pre(items ...Rich) RichBlock { return RichBlock("<pre>" + richJoin(items...) + "</pre>") }
// PreCode builds a preformatted code block tagged with a language.
func PreCode(lang string, items ...Rich) RichBlock {
return RichBlock(`<pre><code class="language-` + escapeRich(lang) + `">` + richJoin(items...) + `</code></pre>`)
}
// Footer builds a footer block.
func Footer(items ...Rich) RichBlock { return RichBlock("<footer>" + richJoin(items...) + "</footer>") }
// Hr builds a divider block.
func Hr() RichBlock { return "<hr/>" }
// AnchorBlock builds a standalone named anchor between blocks: <a name></a>.
func AnchorBlock(name string) RichBlock {
return RichBlock(`<a name="` + escapeHTML(name) + `"></a>`)
}
// LiItem is a list item under construction for Ul or Ol.
type LiItem struct {
text Rich
value int
typ string
checkbox bool
checked bool
}
// Li builds a list item from inline fragments.
func Li(items ...Rich) LiItem { return LiItem{text: richJoin(items...)} }
// LiCheckbox builds a checkbox list item.
func LiCheckbox(checked bool, items ...Rich) LiItem {
return LiItem{text: richJoin(items...), checkbox: true, checked: checked}
}
// SetValue sets the explicit ordinal of the item (like <li value>).
func (l LiItem) SetValue(val int) LiItem {
l.value = val
return l
}
// SetType sets the numbering type of the item: "1", "a", "A", "i", "I".
func (l LiItem) SetType(t string) LiItem {
l.typ = t
return l
}
func (l LiItem) build() Rich {
if l.checkbox {
input := Rich(`<input type="checkbox">`)
if l.checked {
input = `<input type="checkbox" checked>`
}
return "<li>" + input + l.text + "</li>"
}
attrs := make([]string, 0, 2)
if l.value != 0 {
attrs = append(attrs, `value="`+strconv.Itoa(l.value)+`"`)
}
if l.typ != "" {
attrs = append(attrs, `type="`+escapeHTML(l.typ)+`"`)
}
return Rich(openTag("li", attrs)) + l.text + "</li>"
}
func joinLiItems(items []LiItem) Rich {
var out Rich
for _, item := range items {
out += item.build()
}
return out
}
// Ul builds an unordered list block.
func Ul(items ...LiItem) RichBlock {
return RichBlock(`<ul>` + joinLiItems(items) + `</ul>`)
}
// OlOpts holds the <ol> numbering attributes.
type OlOpts struct {
Start int
Type string
Reversed bool
}
// Ol builds an ordered list block. Item labels are rendered by the server.
func Ol(opts OlOpts, items ...LiItem) RichBlock {
attrs := make([]string, 0, 3)
if opts.Start > 0 {
attrs = append(attrs, `start="`+strconv.Itoa(opts.Start)+`"`)
}
if opts.Type != "" {
attrs = append(attrs, `type="`+escapeHTML(opts.Type)+`"`)
}
if opts.Reversed {
attrs = append(attrs, "reversed")
}
return RichBlock(openTag("ol", attrs) + string(joinLiItems(items)) + "</ol>")
}
// Blockquote builds a block quotation: lines are joined with <br> (as in the
// official HTML example) and credit renders as a trailing <cite>.
func Blockquote(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<blockquote>` + richJoinSep(Br(), lines...) + cite(credit) + `</blockquote>`)
}
// Aside builds a pull quote (<aside>) with an optional <cite> credit.
func Aside(credit Rich, lines ...Rich) RichBlock {
return RichBlock(`<aside>` + richJoinSep(Br(), lines...) + cite(credit) + `</aside>`)
}
// RichMedia is a media element for standalone blocks, collages, and
// slideshows. Media is sent by HTTP(S) URL only; file_id does not work in
// html mode.
type RichMedia struct {
tag string
src string
spoiler bool
}
// Photo builds a photo element from an HTTP(S) URL.
func Photo(url string) RichMedia { return RichMedia{tag: "img", src: url} }
// Video builds a video or animation element from an HTTP(S) URL; the server
// distinguishes them by the URL extension.
func Video(url string) RichMedia { return RichMedia{tag: "video", src: url} }
// Audio builds an audio or voice-note element from an HTTP(S) URL; the
// server treats .ogg as a voice note.
func Audio(url string) RichMedia { return RichMedia{tag: "audio", src: url} }
// SetSpoiler hides the media behind a spoiler overlay.
func (m RichMedia) SetSpoiler() RichMedia {
m.spoiler = true
return m
}
func (m RichMedia) build() string {
attrs := []string{`src="` + escapeHTML(m.src) + `"`}
if m.spoiler {
attrs = append(attrs, "tg-spoiler")
}
if m.tag == "img" {
return "<img " + strings.Join(attrs, " ") + "/>"
}
return openTag(m.tag, attrs) + "</" + m.tag + ">"
}
// Block turns the media into a standalone block without a caption.
func (m RichMedia) Block() RichBlock { return RichBlock(m.build()) }
// Caption wraps the media in <figure> with a caption and optional credit.
func (m RichMedia) Caption(credit Rich, caption ...Rich) RichBlock {
return RichBlock(`<figure>` + m.build() + string(figcaption(credit, caption...)) + `</figure>`)
}
func figcaption(credit Rich, caption ...Rich) Rich {
text := richJoin(caption...) + cite(credit)
if text == "" {
return ""
}
return "<figcaption>" + text + "</figcaption>"
}
func richMediaJoin(items []RichMedia) string {
var out string
for _, item := range items {
out += item.build()
}
return out
}
// Map builds a location map block.
func Map(lat, long float64, zoom int) RichBlock {
latString := fmt.Sprintf("%.6f", lat)
longString := fmt.Sprintf("%.6f", long)
zoomString := strconv.Itoa(zoom)
return RichBlock(`<tg-map lat="` + latString + `" long="` + longString + `" zoom="` + zoomString + `"/>`)
}
// MapCaption builds a map block wrapped in <figure> with a caption.
func MapCaption(lat, long float64, zoom int, credit Rich, caption ...Rich) RichBlock {
return `<figure>` + Map(lat, long, zoom) + RichBlock(figcaption(credit, caption...)) + `</figure>`
}
// Collage builds a media collage block.
func Collage(items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + `</tg-collage>`)
}
// CollageCaption builds a media collage block with a caption.
func CollageCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-collage>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-collage>`)
}
// Slideshow builds a media slideshow block.
func Slideshow(items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + `</tg-slideshow>`)
}
// SlideshowCaption builds a media slideshow block with a caption.
func SlideshowCaption(credit Rich, caption Rich, items ...RichMedia) RichBlock {
return RichBlock(`<tg-slideshow>` + richMediaJoin(items) + string(figcaption(credit, caption)) + `</tg-slideshow>`)
}
// RichCell is a table cell under construction for Row.
type RichCell struct {
text Rich
colspan int
rowspan int
align string
valign string
}
// Cell builds a table cell from inline fragments.
func Cell(items ...Rich) RichCell {
return RichCell{text: richJoin(items...)}
}
// SetSpan sets colspan and rowspan; zero leaves the attribute out.
func (r RichCell) SetSpan(col, row int) RichCell {
r.colspan = col
r.rowspan = row
return r
}
// SetAlign sets horizontal alignment: "left", "center", or "right".
func (r RichCell) SetAlign(align string) RichCell {
r.align = align
return r
}
// SetVAlign sets vertical alignment: "top", "middle", or "bottom".
func (r RichCell) SetVAlign(align string) RichCell {
r.valign = align
return r
}
func (r RichCell) build(isHeader bool) string {
attrs := make([]string, 0, 4)
if r.colspan > 0 {
attrs = append(attrs, `colspan="`+strconv.Itoa(r.colspan)+`"`)
}
if r.rowspan > 0 {
attrs = append(attrs, `rowspan="`+strconv.Itoa(r.rowspan)+`"`)
}
if r.align != "" {
attrs = append(attrs, `align="`+escapeHTML(r.align)+`"`)
}
if r.valign != "" {
attrs = append(attrs, `valign="`+escapeHTML(r.valign)+`"`)
}
tag := "td"
if isHeader {
tag = "th"
}
return openTag(tag, attrs) + string(r.text) + "</" + tag + ">"
}
// RichRow is a table row under construction for Table.
type RichRow struct {
cells []RichCell
isHeader bool
}
// Row builds a table row; isHeader renders every cell as <th>.
func Row(isHeader bool, cells ...RichCell) RichRow {
return RichRow{cells: cells, isHeader: isHeader}
}
func (r RichRow) build() string {
var buildCells string
for _, cell := range r.cells {
buildCells += cell.build(r.isHeader)
}
return `<tr>` + buildCells + `</tr>`
}
// Table builds a table block; an empty caption is omitted.
func Table(bordered, striped bool, caption Rich, rows ...RichRow) RichBlock {
attrs := make([]string, 0, 2)
if bordered {
attrs = append(attrs, "bordered")
}
if striped {
attrs = append(attrs, "striped")
}
out := openTag("table", attrs)
if caption != "" {
out += `<caption>` + string(caption) + `</caption>`
}
for _, row := range rows {
out += row.build()
}
out += `</table>`
return RichBlock(out)
}
// Details builds an expandable block with an inline summary.
func Details(isOpen bool, summary Rich, blocks ...RichBlock) RichBlock {
tag := `<details>`
if isOpen {
tag = `<details open>`
}
return RichBlock(tag) + `<summary>` + RichBlock(summary) + `</summary>` + richBlocksJoin(blocks...) + `</details>`
}
// MathBlock builds a block-level mathematical expression.
func MathBlock(expr string) RichBlock {
return RichBlock(`<tg-math-block>` + escapeHTML(expr) + `</tg-math-block>`)
}
-179
View File
@@ -1,179 +0,0 @@
package tgfmt
import (
"testing"
"time"
)
func TestRichInline(t *testing.T) {
cases := []struct {
name string
got Rich
want string
}{
{"bold", NewRich("bold text").Bold(), "<b>bold text</b>"},
{"spoiler", NewRich("spoiler").Spoiler(), "<tg-spoiler>spoiler</tg-spoiler>"},
{"escape", NewRich(`a<b> & "c"`), "a&lt;b&gt; &amp; &quot;c&quot;"},
{"link", NewRich("inline URL").Link("https://t.me/"), `<a href="https://t.me/">inline URL</a>`},
{"link attr escape", NewRich("x").Link(`https://e/?q="><b>`), `<a href="https://e/?q=&quot;&gt;&lt;b&gt;">x</a>`},
{"mention", NewRich("user").Mention(123456789), `<a href="tg://user?id=123456789">user</a>`},
{"anchor", Rich("").Anchor("chapter-1"), `<a name="chapter-1"></a>`},
{"anchor link", NewRich("in-document link").AnchorLink("chapter-1"), `<a href="#chapter-1">in-document link</a>`},
{"reference", NewRich("Referenced text").Ref("note-1"), `<tg-reference name="note-1">Referenced text</tg-reference>`},
{"emoji", Emoji("5368324170671202286", "👍"), `<tg-emoji emoji-id="5368324170671202286">👍</tg-emoji>`},
{"emoji alt escape", Emoji("1", `<x>`), `<tg-emoji emoji-id="1">&lt;x&gt;</tg-emoji>`},
{"time format", NewRich("22:45 tomorrow").TimeFormat(time.Unix(1647531900, 0), "wDT"),
`<tg-time unix="1647531900" format="wDT">22:45 tomorrow</tg-time>`},
{"math", NewRich("x^2 + y^2").Math(), "<tg-math>x^2 + y^2</tg-math>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichBlocks(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"h1", H1(NewRich("Heading 1")), "<h1>Heading 1</h1>"},
{"p", P(NewRich("Paragraph text")), "<p>Paragraph text</p>"},
{"pre code", PreCode("python", NewRich("print('x')")),
`<pre><code class="language-python">print('x')</code></pre>`},
{"footer", Footer(NewRich("Footer text")), "<footer>Footer text</footer>"},
{"hr", Hr(), "<hr/>"},
{"anchor block", AnchorBlock("chapter-2"), `<a name="chapter-2"></a>`},
{"math block", MathBlock("E = mc^2"), "<tg-math-block>E = mc^2</tg-math-block>"},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichLists(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"ul", Ul(Li(NewRich("unordered list item"))),
"<ul><li>unordered list item</li></ul>"},
{"ol plain", Ol(OlOpts{}, Li(NewRich("ordered list item"))),
"<ol><li>ordered list item</li></ol>"},
{"ol attrs", Ol(OlOpts{Start: 3, Type: "a", Reversed: true}, Li(NewRich("ordered list item"))),
`<ol start="3" type="a" reversed><li>ordered list item</li></ol>`},
{"li value type", Ol(OlOpts{}, Li(NewRich("item")).SetValue(7).SetType("i")),
`<ol><li value="7" type="i">item</li></ol>`},
{"checkboxes", Ul(
LiCheckbox(true, NewRich("Checked checkbox")),
LiCheckbox(false, NewRich("Unchecked checkbox")),
), `<ul><li><input type="checkbox" checked>Checked checkbox</li><li><input type="checkbox">Unchecked checkbox</li></ul>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichQuotes(t *testing.T) {
got := Blockquote(NewRich("The Author"),
NewRich("Block quotation started"),
NewRich("Block quotation continued"),
NewRich("The last line of the block quotation"),
)
want := "<blockquote>Block quotation started<br>Block quotation continued<br>" +
"The last line of the block quotation<cite>The Author</cite></blockquote>"
if string(got) != want {
t.Errorf("blockquote:\n got %s\n want %s", got, want)
}
got = Aside(NewRich("The Author"), NewRich("Pull quote"))
want = "<aside>Pull quote<cite>The Author</cite></aside>"
if string(got) != want {
t.Errorf("aside:\n got %s\n want %s", got, want)
}
got = Blockquote("", NewRich("no credit"))
want = "<blockquote>no credit</blockquote>"
if string(got) != want {
t.Errorf("blockquote without credit:\n got %s\n want %s", got, want)
}
}
func TestRichMedia(t *testing.T) {
cases := []struct {
name string
got RichBlock
want string
}{
{"photo", Photo("https://telegram.org/example/photo.jpg").Block(),
`<img src="https://telegram.org/example/photo.jpg"/>`},
{"video", Video("https://telegram.org/example/video.mp4").Block(),
`<video src="https://telegram.org/example/video.mp4"></video>`},
{"audio", Audio("https://telegram.org/example/audio.mp3").Block(),
`<audio src="https://telegram.org/example/audio.mp3"></audio>`},
{"photo spoiler caption", Photo("https://telegram.org/example/photo.jpg").SetSpoiler().
Caption(NewRich("Photo credit"), NewRich("Photo caption")),
`<figure><img src="https://telegram.org/example/photo.jpg" tg-spoiler/>` +
`<figcaption>Photo caption<cite>Photo credit</cite></figcaption></figure>`},
{"video caption no credit", Video("https://telegram.org/example/video.mp4").
Caption("", NewRich("Video caption")),
`<figure><video src="https://telegram.org/example/video.mp4"></video>` +
`<figcaption>Video caption</figcaption></figure>`},
{"map", Map(41.9, 12.5, 14), `<tg-map lat="41.900000" long="12.500000" zoom="14"/>`},
{"map caption", MapCaption(41.9, 12.5, 14, "", NewRich("Map caption")),
`<figure><tg-map lat="41.900000" long="12.500000" zoom="14"/><figcaption>Map caption</figcaption></figure>`},
{"collage", Collage(
Photo("https://telegram.org/example/photo.jpg"),
Video("https://telegram.org/example/video.mp4"),
), `<tg-collage><img src="https://telegram.org/example/photo.jpg"/>` +
`<video src="https://telegram.org/example/video.mp4"></video></tg-collage>`},
{"slideshow caption", SlideshowCaption("", NewRich("Slideshow caption"),
Photo("https://telegram.org/example/photo.jpg"),
), `<tg-slideshow><img src="https://telegram.org/example/photo.jpg"/>` +
`<figcaption>Slideshow caption</figcaption></tg-slideshow>`},
}
for _, c := range cases {
if string(c.got) != c.want {
t.Errorf("%s:\n got %s\n want %s", c.name, c.got, c.want)
}
}
}
func TestRichTable(t *testing.T) {
got := Table(false, false, "",
Row(true, Cell(NewRich("Header 1")), Cell(NewRich("Header 2"))),
Row(false, Cell(NewRich("Value 1")), Cell(NewRich("Value 2"))),
)
want := "<table><tr><th>Header 1</th><th>Header 2</th></tr>" +
"<tr><td>Value 1</td><td>Value 2</td></tr></table>"
if string(got) != want {
t.Errorf("plain table:\n got %s\n want %s", got, want)
}
got = Table(true, true, NewRich("Table caption"),
Row(false,
Cell(NewRich("Value")).SetSpan(2, 2).SetAlign("left"),
Cell(NewRich("Value2")).SetAlign("center"),
),
)
want = `<table bordered striped><caption>Table caption</caption>` +
`<tr><td colspan="2" rowspan="2" align="left">Value</td><td align="center">Value2</td></tr></table>`
if string(got) != want {
t.Errorf("table attrs:\n got %s\n want %s", got, want)
}
}
func TestRichDetails(t *testing.T) {
got := Details(true, NewRich("Title"), P(NewRich("Content")))
want := "<details open><summary>Title</summary><p>Content</p></details>"
if string(got) != want {
t.Errorf("details:\n got %s\n want %s", got, want)
}
}
+18 -2
View File
@@ -1,6 +1,8 @@
package tgfmt
import "strings"
import (
"strings"
)
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
@@ -9,7 +11,21 @@ func escapeHTML(s string) string {
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func escapeRich(s string) Rich { return Rich(escapeHTML(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
View File
@@ -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) }
+112
View File
@@ -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&>"), "&lt;text&amp;&gt;"},
{"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&quot;DT">tomorrow</tg-time>`},
{"custom emoji", Emoji("🙂", `id"`), `<tg-emoji emoji-id="id&quot;">🙂</tg-emoji>`},
{"formula", MathExpression("x < y"), "<tg-math>x &lt; 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)
}
})
}
}
+297
View File
@@ -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 &lt; y</tg-math-block>"},
{"anchor", Anchor(`a"b`), `<a name="a&quot;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
}
+40
View File
@@ -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")
)
+548
View File
@@ -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
}
+570
View File
@@ -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}
}
+44
View File
@@ -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)
}
}
+41
View File
@@ -0,0 +1,41 @@
package tgrich
import (
"fmt"
"sort"
"strings"
)
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
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, " ") + "/>"
}
+472
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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.
+21
View File
@@ -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)
+21
View File
@@ -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)
}
}
}