REPOSITORY / ScuroNeko/Laniakea
Compare commits
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f03a081ed6
|
||
|
|
48ddf66540
|
||
|
|
b563e695df
|
@@ -6,3 +6,4 @@ test/
|
|||||||
.codex
|
.codex
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
review.md
|
||||||
+1
-1
@@ -2,7 +2,7 @@ version: "2"
|
|||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
linters:
|
linters:
|
||||||
disable-all: true
|
default: none
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- ineffassign
|
- ineffassign
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ Each exported godoc comment must:
|
|||||||
- avoid repeating the signature mechanically;
|
- avoid repeating the signature mechanically;
|
||||||
- stay high-signal and informative.
|
- 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 declarations
|
||||||
Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason.
|
Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason.
|
||||||
|
|
||||||
@@ -115,8 +120,8 @@ Prefer the repository’s documented commands. If multiple choices exist, use th
|
|||||||
|
|
||||||
## Breaking changes policy
|
## Breaking changes policy
|
||||||
- The agent must detect potential breaking changes before editing public APIs.
|
- 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.
|
- 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 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.
|
- 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:
|
- In that case, the agent must offer only these options:
|
||||||
1. do not make the breaking change;
|
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;
|
2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear;
|
||||||
|
|||||||
@@ -1,5 +1,67 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.1.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- Fixed `Uploader.SendLivePhoto` and `Uploader.SendLivePhotoWithContext` to require both the live-photo video and its static image. The previous one-file signatures could not produce a valid `sendLivePhoto` request.
|
||||||
|
|
||||||
|
### Bot API 10.1
|
||||||
|
- Added rich message receiving support: `tgapi.RichMessage` on `Message.RichMessage` (`rich_message`), the full set of `RichText*`/`RichBlock*` wire types with official API names, and `UnmarshalRichText`/`UnmarshalRichBlock`/`UnmarshalRichMessage` parsers. Unknown text-bearing types retain their nested text through fallback wrappers while unmodeled fields are discarded.
|
||||||
|
- Added rich message sending support: `tgapi.InputRichMessage`, `tgapi.SendRichMessage` params, and `API.SendRichMessage`/`API.SendRichMessageWithContext`.
|
||||||
|
- Added rich message draft streaming: `API.SendRichMessageDraft`/`API.SendRichMessageDraftWithContext` for ephemeral ~30-second previews of partially generated messages.
|
||||||
|
- Added rich message editing: `EditMessageText.RichMessage` (`InputRichMessage`); `Text` is now omitted from the request when empty so rich-only edits are valid.
|
||||||
|
- Added `tgapi.InputRichMessageContent` for rich content in inline query results.
|
||||||
|
- Added join request query support: `User.SupportsJoinRequestQueries`, `ChatFullInfo.GuardBot`, `ChatJoinRequest.QueryID`, `API.AnswerChatJoinRequestQuery` with `ChatJoinRequestQueryResult` constants (`JoinRequestApprove`/`JoinRequestDecline`/`JoinRequestQueue`), and `API.SendChatJoinRequestWebApp` (plus `WithContext` variants).
|
||||||
|
- Added poll link media: the `tgapi.Link` type, `PollMedia.Link`, and the "link" type with `URL` on `InputPollOptionMedia`.
|
||||||
|
|
||||||
|
### Bot API 10.2
|
||||||
|
- Added block-based rich-message sending with `InputRichMessage.Blocks`, including animation, audio, photo, video, and voice-note input blocks. The `tgrich` package provides matching media constructors with optional block captions.
|
||||||
|
- Added `InputRichMessage.Media` for media embedded in rich-message HTML or Markdown, with multipart `attach://` upload support.
|
||||||
|
- Added multipart rich-message uploads through `Uploader.SendRichMessage`. Use `UploaderFile.SetAttachName` to match an `attach://` media reference; rich-message draft helpers reject direct uploads as required by Telegram.
|
||||||
|
- Added ephemeral-message support: outgoing receiver and callback parameters, reply targets, message fields, edit and delete methods, and ephemeral bot commands. Added community service-message types and subscription update handling.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added documented `tgrich` constructors and block types for building input rich messages.
|
||||||
|
- Added `tgrich.BuildHTML` and `tgrich.ToHTML` to validate input block trees, convert them to HTML rich messages, and collect URL, `file_id`, or multipart media references.
|
||||||
|
- Added `MessageContext.RichAnswer(...)` and `MessageContext.RichAnswerKeyboard(...)` for validating and sending `tgrich` input blocks.
|
||||||
|
- Added `UpdateTypeSubscription` routing and normalized message, user, and chat context for guest messages, deleted business messages, anonymous poll answers, reaction counts, managed bots, chat boosts, and subscription updates.
|
||||||
|
- Added webhook secret-format validation and the exported `ErrBotWebhookOptsSecretTokenInvalid` sentinel.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `AutoGenerateCommandsForScope(nil)` now atomically replaces commands in Telegram's default scope without deleting the previous list first.
|
||||||
|
- API debug logging now records redacted request JSON and response metadata instead of complete response bodies.
|
||||||
|
- Scene updates sharing a user or chat session key are serialized, and duplicate scene names from later plugins are skipped with a warning.
|
||||||
|
- Inline keyboard button builders now keep URL and callback actions mutually exclusive, and `InlineKeyboard.Get` returns independent markup data.
|
||||||
|
- `NewBot` no longer aliases `BotOpts.Prefixes` or mutates `BotOpts.LoggerBasePath`.
|
||||||
|
- README requirements now match the module's Go 1.26 directive.
|
||||||
|
- Migrated the golangci-lint configuration to its v2 schema so the repository lint workflow runs again.
|
||||||
|
- Added missing Godoc for exported error methods, enum constants, and all public Bot API 10.1/10.2 fields introduced in this release.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed JSON BotOpts environment placeholders corrupting or injecting JSON when values contain quotes, backslashes, or control characters.
|
||||||
|
- Preserved checkbox state when converting list items to ordered lists and made generated HTML attribute ordering deterministic.
|
||||||
|
- Added validation for rich-block type discriminators, heading sizes, list fields, table cells, map parameters, and media values.
|
||||||
|
- Fixed generated webhook secrets using padded Base64 characters that Telegram rejects, stopped logging generated secrets, and added HTTP read and idle timeouts to the webhook server.
|
||||||
|
- Fixed negative group and channel IDs receiving global rather than per-chat `retry_after` cooldowns.
|
||||||
|
- Fixed rejected per-chat requests consuming global rate-limit capacity and draft construction consuming an extra rate-limit token before the API request.
|
||||||
|
- Fixed subscription updates being decoded as `UpdateTypeUnknown`.
|
||||||
|
- Fixed rich-text and rich-block decoding silently accepting malformed typed fields or a top-level `null` rich-text value.
|
||||||
|
- Fixed `tgrich.BuildHTML` disabling Telegram entity detection and accepting the draft-only thinking block; `BuildDraftHTML` now provides the explicit draft path.
|
||||||
|
- Fixed `tgrich.BuildHTML` silently losing explicit bank-card, mention, hashtag, cashtag, and bot-command values when their visible text differs.
|
||||||
|
- Fixed API debug logs exposing webhook, payment, callback, passport, and managed-bot secrets.
|
||||||
|
- Fixed panics and nil callbacks in asynchronous middleware terminating the process; failures now reach the logger and observer error stream.
|
||||||
|
- Fixed multipart helpers attempting direct file uploads for rich-message drafts, which Telegram does not support; they now return `ErrRichMessageDraftUploadUnsupported`.
|
||||||
|
- Fixed draft ID zero values and collisions overwriting tracked drafts, nil draft APIs panicking, and draft entity slices aliasing caller memory.
|
||||||
|
- Fixed scene session payloads and returned inline keyboard markup aliasing mutable internal slices.
|
||||||
|
- Recovered panics from runner callbacks so they are reported through normal runner and error observer events instead of terminating the process.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Added JSON regression coverage for rich-message embedded media and ephemeral send, edit, and delete parameters.
|
||||||
|
- Added regression coverage for escaped environment placeholders and both required `sendLivePhoto` multipart fields.
|
||||||
|
- Added regression coverage for API log redaction, same-session scene serialization, duplicate scene registration, async middleware failures, and rich entity preservation.
|
||||||
|
- Added rich HTML renderer coverage for all input block and media types, multipart references, field validation, and Telegram's text, block, nesting, media, and table-width limits.
|
||||||
|
- Added regression coverage for webhook token syntax, update context normalization, subscription routing, rate-limit capacity, draft ID collisions, runner panics, scene and keyboard aliasing, command replacement, and malformed rich JSON.
|
||||||
|
|
||||||
## v1.0.2
|
## v1.0.2
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||

|

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

|

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

|

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

|

|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type Bot[T AppData] struct {
|
|||||||
|
|
||||||
sessionStore SessionStore // Session store for scene management
|
sessionStore SessionStore // Session store for scene management
|
||||||
sceneScopePriority []SceneScope
|
sceneScopePriority []SceneScope
|
||||||
|
sceneLocks sceneKeyLocker
|
||||||
|
|
||||||
updateOffsetMu sync.Mutex
|
updateOffsetMu sync.Mutex
|
||||||
updateOffset int // Last processed update ID
|
updateOffset int // Last processed update ID
|
||||||
@@ -191,7 +192,7 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
api := tgapi.NewAPI(apiOpts)
|
api := tgapi.NewAPI(apiOpts)
|
||||||
uploader := tgapi.NewUploader(api)
|
uploader := tgapi.NewUploader(api)
|
||||||
|
|
||||||
prefixes := opts.Prefixes
|
prefixes := append([]string(nil), opts.Prefixes...)
|
||||||
if len(prefixes) == 0 {
|
if len(prefixes) == 0 {
|
||||||
prefixes = []string{"/"}
|
prefixes = []string{"/"}
|
||||||
}
|
}
|
||||||
@@ -230,10 +231,11 @@ func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
|
|||||||
if len(opts.ErrorTemplate) > 0 {
|
if len(opts.ErrorTemplate) > 0 {
|
||||||
bot.errorTemplate = opts.ErrorTemplate
|
bot.errorTemplate = opts.ErrorTemplate
|
||||||
}
|
}
|
||||||
if len(opts.LoggerBasePath) == 0 {
|
loggerOpts := *opts
|
||||||
opts.LoggerBasePath = "./"
|
if len(loggerOpts.LoggerBasePath) == 0 {
|
||||||
|
loggerOpts.LoggerBasePath = "./"
|
||||||
}
|
}
|
||||||
bot.initLoggers(opts)
|
bot.initLoggers(&loggerOpts)
|
||||||
|
|
||||||
if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion {
|
if opts.FileConfigVersion > 0 && opts.FileConfigVersion < ConfigVersion {
|
||||||
bot.logger.Warnln(
|
bot.logger.Warnln(
|
||||||
|
|||||||
+15
-2
@@ -127,8 +127,18 @@ func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error {
|
|||||||
return SaveBotOptsFile(codec, filename, opts)
|
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*\}\}`)
|
var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
|
||||||
|
|
||||||
|
type botOptsFileEnvEscaper interface {
|
||||||
|
EscapeEnv(string) string
|
||||||
|
}
|
||||||
|
|
||||||
// BotOptsFileCodec decodes and encodes BotOpts file formats.
|
// BotOptsFileCodec decodes and encodes BotOpts file formats.
|
||||||
type BotOptsFileCodec interface {
|
type BotOptsFileCodec interface {
|
||||||
FromBytes([]byte) (*BotOpts, error)
|
FromBytes([]byte) (*BotOpts, error)
|
||||||
@@ -148,7 +158,7 @@ func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data = expandEnvPlaceholdersInFile(data)
|
data = expandEnvPlaceholdersInFile(codec, data)
|
||||||
return codec.FromBytes(data)
|
return codec.FromBytes(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +175,7 @@ func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func expandEnvPlaceholdersInFile(data []byte) []byte {
|
func expandEnvPlaceholdersInFile(codec BotOptsFileCodec, data []byte) []byte {
|
||||||
return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte {
|
return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte {
|
||||||
group := envParameterRegex.FindSubmatch(match)
|
group := envParameterRegex.FindSubmatch(match)
|
||||||
if len(group) != 2 {
|
if len(group) != 2 {
|
||||||
@@ -173,6 +183,9 @@ func expandEnvPlaceholdersInFile(data []byte) []byte {
|
|||||||
}
|
}
|
||||||
key := group[1]
|
key := group[1]
|
||||||
value := os.Getenv(string(key))
|
value := os.Getenv(string(key))
|
||||||
|
if escaper, ok := codec.(botOptsFileEnvEscaper); ok {
|
||||||
|
value = escaper.EscapeEnv(value)
|
||||||
|
}
|
||||||
return []byte(value)
|
return []byte(value)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,25 @@ func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadBotOptsFileEscapesEnvironmentValuesForJSON(t *testing.T) {
|
||||||
|
want := "quote: \"; slash: \\; newline:\n; tab:\t; control:\x01"
|
||||||
|
t.Setenv("TG_TOKEN", want)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
filename := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(filename, []byte(`{"token":"{{TG_TOKEN}}"}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := LoadBotOptsFile(BotOptsFileJSONCodec{}, filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadBotOptsFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Token != want {
|
||||||
|
t.Fatalf("unexpected token: got %q want %q", got.Token, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
|
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
filename := filepath.Join(dir, "config.json")
|
filename := filepath.Join(dir, "config.json")
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
|
|||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
level := bot.GetLoggerLevel()
|
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 {
|
for _, p := range plugin {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
if bot.logger != nil {
|
if bot.logger != nil {
|
||||||
@@ -27,6 +33,16 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cloned := clonePlugin(p)
|
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 {
|
if cloned.logger == nil {
|
||||||
cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter)
|
cloned.logger = utils.CreateLogger(cloned.name, level, bot.logFormat, bot.logFormatter)
|
||||||
cloned.loggerOwned = true
|
cloned.loggerOwned = true
|
||||||
|
|||||||
+24
-3
@@ -152,6 +152,9 @@ func (bot *Bot[T]) RunWebhookWithContext(ctx context.Context, opts *BotWebhookOp
|
|||||||
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
|
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
|
||||||
return ErrBotWebhookOptsMaxConnectionsRange
|
return ErrBotWebhookOptsMaxConnectionsRange
|
||||||
}
|
}
|
||||||
|
if err := validateWebhookSecretToken(opts.SecretToken); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
|
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
|
||||||
return err
|
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 {
|
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
|
||||||
if autoSecret != "" {
|
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)
|
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
|
||||||
if err != nil {
|
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 {
|
func (bot *Bot[T]) baseRunWebhook(ctx context.Context, opts *BotWebhookOpts, runFunc func(*http.Server, chan error)) error {
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", opts.LocalPort),
|
Addr: fmt.Sprintf(":%d", opts.LocalPort),
|
||||||
Handler: bot.newWebhookMux(ctx, opts),
|
Handler: bot.newWebhookMux(ctx, opts),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
@@ -442,6 +448,21 @@ func validateWebhookPath(path string, useStatusPath bool) error {
|
|||||||
return nil
|
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 {
|
func validateWebhookTLSFiles(tlsFiles []string) error {
|
||||||
switch len(tlsFiles) {
|
switch len(tlsFiles) {
|
||||||
case 0, 2:
|
case 0, 2:
|
||||||
|
|||||||
@@ -255,6 +255,33 @@ func TestValidateWebhookTLSFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateWebhookSecretToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "minimum", token: "a"},
|
||||||
|
{name: "allowed alphabet", token: "AZaz09_-"},
|
||||||
|
{name: "maximum", token: strings.Repeat("a", 256)},
|
||||||
|
{name: "empty", token: "", wantErr: true},
|
||||||
|
{name: "too long", token: strings.Repeat("a", 257), wantErr: true},
|
||||||
|
{name: "padding", token: "abc=", wantErr: true},
|
||||||
|
{name: "non ASCII", token: "секрет", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateWebhookSecretToken(tt.token)
|
||||||
|
if tt.wantErr && !errors.Is(err, ErrBotWebhookOptsSecretTokenInvalid) {
|
||||||
|
t.Fatalf("expected ErrBotWebhookOptsSecretTokenInvalid, got %v", err)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
|
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
updateQueue: make(chan *tgapi.Update, 1),
|
updateQueue: make(chan *tgapi.Update, 1),
|
||||||
|
|||||||
+32
-38
@@ -1,6 +1,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -10,7 +11,6 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdRegexp matches command names allowed for Telegram command registration.
|
|
||||||
var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
|
var cmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
|
||||||
|
|
||||||
// ErrTooManyCommands is returned when the total number of registered commands
|
// 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, " "))
|
usage := fmt.Sprintf("Usage: /%s %s", cmd.command, strings.Join(descArgs, " "))
|
||||||
if desc != "" {
|
if desc != "" {
|
||||||
desc = fmt.Sprintf("%s. %s", desc, usage)
|
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) }
|
func checkCmdRegex(cmd string) bool { return cmdRegexp.MatchString(cmd) }
|
||||||
@@ -79,15 +79,8 @@ func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoGenerateCommands registers all plugin-defined commands with Telegram's Bot API
|
// AutoGenerateCommands replaces plugin-defined commands in the private-chat,
|
||||||
// across three scopes:
|
// group-chat, and all-chat-administrators 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.
|
|
||||||
//
|
//
|
||||||
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
|
// Returns ErrTooManyCommands if the total number of commands exceeds 100.
|
||||||
// Returns any API error from Telegram (e.g., network issues, invalid scope).
|
// 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)
|
// log.Fatal(err)
|
||||||
// }
|
// }
|
||||||
func (bot *Bot[T]) AutoGenerateCommands() error {
|
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)
|
commands := gatherCommands(bot)
|
||||||
if len(commands) > 100 {
|
if len(commands) > 100 {
|
||||||
return ErrTooManyCommands
|
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
|
// Register commands for each scope
|
||||||
scopes := []*tgapi.BotCommandScope{
|
scopes := []tgapi.BotCommandScope{
|
||||||
{Type: tgapi.BotCommandScopePrivateType},
|
{Type: tgapi.BotCommandScopePrivateType},
|
||||||
{Type: tgapi.BotCommandScopeGroupType},
|
{Type: tgapi.BotCommandScopeGroupType},
|
||||||
{Type: tgapi.BotCommandScopeAllChatAdministratorsType},
|
{Type: tgapi.BotCommandScopeAllChatAdministratorsType},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scope := range scopes {
|
for i := range scopes {
|
||||||
_, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
|
if err := bot.setCommandsForScope(ctx, &scopes[i], commands); err != nil {
|
||||||
Commands: commands,
|
return err
|
||||||
Scope: scope,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set commands for scope %q: %w", scope.Type, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AutoGenerateCommandsForScope registers all plugin-defined commands with Telegram's Bot API
|
// 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
|
// for the specified command scope. A nil scope selects Telegram's default scope.
|
||||||
// to ensure a clean state, then sets the new set of commands.
|
|
||||||
//
|
//
|
||||||
// The scope parameter defines where the commands should be available (e.g., private chats,
|
// 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.
|
// group chats, chat administrators). See tgapi.BotCommandScope and its predefined types.
|
||||||
@@ -150,22 +136,30 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
|
|||||||
// log.Fatal(err)
|
// log.Fatal(err)
|
||||||
// }
|
// }
|
||||||
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
|
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)
|
commands := gatherCommands(bot)
|
||||||
if len(commands) > 100 {
|
if len(commands) > 100 {
|
||||||
return ErrTooManyCommands
|
return ErrTooManyCommands
|
||||||
}
|
}
|
||||||
|
return bot.setCommandsForScope(ctx, scope, commands)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{Scope: scope})
|
func (bot *Bot[T]) setCommandsForScope(ctx context.Context, scope *tgapi.BotCommandScope, commands []tgapi.BotCommand) error {
|
||||||
if err != nil {
|
if len(commands) > 100 {
|
||||||
return fmt.Errorf("failed to delete existing commands: %w", err)
|
return ErrTooManyCommands
|
||||||
}
|
}
|
||||||
|
_, err := bot.api.SetMyCommandsWithContext(ctx, tgapi.SetMyCommands{Scope: scope, Commands: commands})
|
||||||
_, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
|
|
||||||
Commands: commands,
|
|
||||||
Scope: scope,
|
|
||||||
})
|
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,3 +83,50 @@ func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
|
|||||||
t.Fatalf("unexpected command order: got %v want %v", got, want)
|
t.Fatalf("unexpected command order: got %v want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAutoGenerateCommandsForNilScopeUsesSingleAtomicReplacement(t *testing.T) {
|
||||||
|
var methods []string
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
methods = append(methods, req.URL.Path)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
|
||||||
|
}, nil
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
api := tgapi.NewAPI(
|
||||||
|
tgapi.NewAPIOpts("token").
|
||||||
|
SetAPIURL("https://example.test").
|
||||||
|
SetHTTPClient(client),
|
||||||
|
)
|
||||||
|
defer func() {
|
||||||
|
if err := api.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
plugin := NewPlugin[NoData]("commands")
|
||||||
|
plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil })
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
api: api,
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
plugins: []Plugin[NoData]{*plugin},
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := bot.logger.Close(); err != nil {
|
||||||
|
t.Fatalf("Close logger returned error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := bot.AutoGenerateCommandsForScope(nil); err != nil {
|
||||||
|
t.Fatalf("AutoGenerateCommandsForScope returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(methods) != 1 {
|
||||||
|
t.Fatalf("expected one request, got %d", len(methods))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(methods[0], "/setMyCommands") {
|
||||||
|
t.Fatalf("expected setMyCommands request, got %v", methods[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+12
-1
@@ -93,6 +93,7 @@ type Command[T AppData] struct {
|
|||||||
args extypes.Slice[CommandArg] // List of expected arguments
|
args extypes.Slice[CommandArg] // List of expected arguments
|
||||||
middlewares extypes.Slice[Middleware[T]] // Optional middleware chain
|
middlewares extypes.Slice[Middleware[T]] // Optional middleware chain
|
||||||
skipAutoCmd bool // If true, this command won't be auto-added to help menus
|
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.
|
// 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
|
// that fit Telegram's callback_data limit, though the configured payload
|
||||||
// encoding may impose its own restrictions.
|
// encoding may impose its own restrictions.
|
||||||
func NewCommand[T any](command string, exec CommandExecutor[T], args ...CommandArg) *Command[T] {
|
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.
|
// Use adds a middleware to the command's execution chain.
|
||||||
@@ -130,6 +133,14 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
|
|||||||
return c
|
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 {
|
func (c *Command[T]) validateArgs(args []string) error {
|
||||||
for i := range c.args.Len() {
|
for i := range c.args.Len() {
|
||||||
if i >= len(args) && c.args.Get(i).required {
|
if i >= len(args) && c.args.Get(i).required {
|
||||||
|
|||||||
@@ -129,7 +129,19 @@ type Draft struct {
|
|||||||
//
|
//
|
||||||
// The caller must set a chat with SetChat before Push or Flush.
|
// The caller must set a chat with SetChat before Push or Flush.
|
||||||
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
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{
|
draft := &Draft{
|
||||||
api: p.api,
|
api: p.api,
|
||||||
provider: p,
|
provider: p,
|
||||||
@@ -137,9 +149,7 @@ func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
|
|||||||
ID: id,
|
ID: id,
|
||||||
Message: "",
|
Message: "",
|
||||||
}
|
}
|
||||||
p.mu.Lock()
|
|
||||||
p.drafts[id] = draft
|
p.drafts[id] = draft
|
||||||
p.mu.Unlock()
|
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,10 +164,9 @@ func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft {
|
|||||||
|
|
||||||
// SetEntities replaces the draft's message entities.
|
// SetEntities replaces the draft's message entities.
|
||||||
//
|
//
|
||||||
// Entities are stored by reference. If you plan to mutate the slice later,
|
// The entities slice is copied.
|
||||||
// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))`.
|
|
||||||
func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft {
|
func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft {
|
||||||
d.entities = entities
|
d.entities = append([]tgapi.MessageEntity(nil), entities...)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +229,9 @@ func (d *Draft) Flush() error {
|
|||||||
if err := validateMessageText(d.Message); err != nil {
|
if err := validateMessageText(d.Message); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if d.api == nil {
|
||||||
|
return ErrAPIIsNil
|
||||||
|
}
|
||||||
|
|
||||||
params := tgapi.SendMessage{
|
params := tgapi.SendMessage{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
@@ -252,6 +264,9 @@ func (d *Draft) push(text string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
d.Message = candidate
|
d.Message = candidate
|
||||||
|
if d.api == nil {
|
||||||
|
return ErrAPIIsNil
|
||||||
|
}
|
||||||
params := tgapi.SendMessageDraft{
|
params := tgapi.SendMessageDraft{
|
||||||
ChatID: d.chatID,
|
ChatID: d.chatID,
|
||||||
DraftID: d.ID,
|
DraftID: d.ID,
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import (
|
|||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"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) {
|
func TestDraftFlushRequiresChatID(t *testing.T) {
|
||||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
|
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
|
||||||
draft.Message = "hello"
|
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) {
|
func TestDraftFlushRejectsLongMessage(t *testing.T) {
|
||||||
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
|
||||||
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
|
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type classifiedError struct {
|
|||||||
internalOnly bool
|
internalOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error returns the underlying error message.
|
||||||
func (e *classifiedError) Error() string {
|
func (e *classifiedError) Error() string {
|
||||||
if e == nil || e.err == nil {
|
if e == nil || e.err == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -15,6 +16,7 @@ func (e *classifiedError) Error() string {
|
|||||||
return e.err.Error()
|
return e.err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying error.
|
||||||
func (e *classifiedError) Unwrap() error {
|
func (e *classifiedError) Unwrap() error {
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ var (
|
|||||||
ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL")
|
ErrNoBotWebhookOptsURL = errors.New("empty BotWebhookOpts.URL")
|
||||||
// ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range.
|
// ErrBotWebhookOptsMaxConnectionsRange reports that BotWebhookOpts.MaxConnections is out of range.
|
||||||
ErrBotWebhookOptsMaxConnectionsRange = errors.New("BotWebhookOpts.MaxConnections must be between 1 and 100")
|
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 reports that a certificate was set without an uploader.
|
||||||
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
|
ErrBotUploaderWhenCertificate = errors.New("bot uploader nil, but certificate set")
|
||||||
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
|
// ErrStatusPathSecretRequired reports that UseStatusPath requires SecretToken to be set.
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
|
|||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
}
|
}
|
||||||
bot.prepareUpdateCtx(u, msgCtx)
|
bot.prepareUpdateCtx(u, msgCtx)
|
||||||
|
unlockScenes := bot.sceneLocks.lock(sceneKeysForContext(msgCtx))
|
||||||
|
defer unlockScenes()
|
||||||
bot.safeEmitEvent(ctx, UpdateReceivedEvent{
|
bot.safeEmitEvent(ctx, UpdateReceivedEvent{
|
||||||
UpdateID: u.UpdateID,
|
UpdateID: u.UpdateID,
|
||||||
UpdateType: u.Type,
|
UpdateType: u.Type,
|
||||||
|
|||||||
+66
-1
@@ -318,6 +318,15 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
wantFrom: true,
|
wantFrom: true,
|
||||||
wantFromID: 115,
|
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",
|
name: "message reaction",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
@@ -368,8 +377,64 @@ func TestPrepareUpdateCtxContract(t *testing.T) {
|
|||||||
name: "message reaction count",
|
name: "message reaction count",
|
||||||
update: &tgapi.Update{
|
update: &tgapi.Update{
|
||||||
Type: tgapi.UpdateTypeMessageReactionCount,
|
Type: tgapi.UpdateTypeMessageReactionCount,
|
||||||
MessageReactionCount: &tgapi.MessageReactionCountUpdated{},
|
MessageReactionCount: &tgapi.MessageReactionCountUpdated{Chat: &tgapi.Chat{ID: -2008}},
|
||||||
},
|
},
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2008,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "guest message",
|
||||||
|
update: &tgapi.Update{
|
||||||
|
Type: tgapi.UpdateTypeGuestMessage,
|
||||||
|
GuestMessage: &tgapi.Message{
|
||||||
|
From: &tgapi.User{ID: 119},
|
||||||
|
Chat: &tgapi.Chat{ID: -2009},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantMsg: true,
|
||||||
|
wantFrom: true,
|
||||||
|
wantFromID: 119,
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2009,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deleted business messages",
|
||||||
|
update: &tgapi.Update{
|
||||||
|
Type: tgapi.UpdateTypeDeletedBusinessMessages,
|
||||||
|
DeletedBusinessMessages: &tgapi.BusinessMessagesDeleted{Chat: tgapi.Chat{ID: -2010}},
|
||||||
|
},
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2010,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed bot",
|
||||||
|
update: &tgapi.Update{
|
||||||
|
Type: tgapi.UpdateTypeManagedBot,
|
||||||
|
ManagedBot: &tgapi.ManagedBotUpdated{User: tgapi.User{ID: 120}},
|
||||||
|
},
|
||||||
|
wantFrom: true,
|
||||||
|
wantFromID: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subscription",
|
||||||
|
update: &tgapi.Update{
|
||||||
|
Type: tgapi.UpdateTypeSubscription,
|
||||||
|
Subscription: &tgapi.BotSubscriptionUpdated{User: tgapi.User{ID: 121}},
|
||||||
|
},
|
||||||
|
wantFrom: true,
|
||||||
|
wantFromID: 121,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "giveaway chat boost has no user",
|
||||||
|
update: &tgapi.Update{
|
||||||
|
Type: tgapi.UpdateTypeChatBoost,
|
||||||
|
ChatBoost: &tgapi.ChatBoostUpdated{
|
||||||
|
Chat: tgapi.Chat{ID: -2011},
|
||||||
|
Boost: tgapi.ChatBoost{Source: tgapi.ChatBoostSource{Source: "giveaway"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantChat: true,
|
||||||
|
wantChatID: -2011,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-9
@@ -59,9 +59,12 @@ func (b InlineKeyboardButtonBuilder) SetStyle(style tgapi.KeyboardButtonStyle) I
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetURL sets a URL that will be opened when the button is pressed.
|
// 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 {
|
func (b InlineKeyboardButtonBuilder) SetURL(url string) InlineKeyboardButtonBuilder {
|
||||||
b.url = url
|
b.url = url
|
||||||
|
if url != "" {
|
||||||
|
b.data = ""
|
||||||
|
}
|
||||||
return b
|
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"]}.
|
// Example: SetCallbackDataJSON("delete_user", 123, "confirm") → {"cmd":"delete_user","args":["123","confirm"]}.
|
||||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
func (b InlineKeyboardButtonBuilder) SetCallbackDataJSON(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||||
|
b.url = ""
|
||||||
b.data = NewCallbackData(cmd, args...).ToJSON()
|
b.data = NewCallbackData(cmd, args...).ToJSON()
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallbackDataBase64 sets a structured callback payload encoded as Base64.
|
// SetCallbackDataBase64 sets a Base64-encoded structured callback payload.
|
||||||
// This can be useful when the JSON payload exceeds Telegram's callback data length limit.
|
// Base64 does not bypass Telegram's 64-byte callback-data limit.
|
||||||
// Args are converted to strings using fmt.Sprint.
|
|
||||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
func (b InlineKeyboardButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||||
|
b.url = ""
|
||||||
b.data = NewCallbackData(cmd, args...).ToBase64()
|
b.data = NewCallbackData(cmd, args...).ToBase64()
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallbackDataCompact sets a structured callback payload encoded as compact text.
|
// SetCallbackDataCompact sets a structured callback payload encoded as compact text.
|
||||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompact(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||||
|
b.url = ""
|
||||||
b.data = NewCallbackData(cmd, args...).ToCompact()
|
b.data = NewCallbackData(cmd, args...).ToCompact()
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCallbackDataCompactBase64 sets a compact callback payload encoded as Base64.
|
// SetCallbackDataCompactBase64 sets a compact callback payload encoded as Base64.
|
||||||
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||||
|
b.url = ""
|
||||||
b.data = NewCallbackData(cmd, args...).ToCompactBase64()
|
b.data = NewCallbackData(cmd, args...).ToCompactBase64()
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -106,6 +112,7 @@ func (b InlineKeyboardButtonBuilder) SetCallbackDataCompactBase64(cmd string, ar
|
|||||||
// SetCallbackData sets a structured callback payload using the configured payload type.
|
// SetCallbackData sets a structured callback payload using the configured payload type.
|
||||||
// The default payload type is JSON.
|
// The default payload type is JSON.
|
||||||
func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
func (b InlineKeyboardButtonBuilder) SetCallbackData(cmd string, args ...any) InlineKeyboardButtonBuilder {
|
||||||
|
b.url = ""
|
||||||
switch b.payloadType {
|
switch b.payloadType {
|
||||||
case BotPayloadJSON:
|
case BotPayloadJSON:
|
||||||
b.data = NewCallbackData(cmd, args...).ToJSON()
|
b.data = NewCallbackData(cmd, args...).ToJSON()
|
||||||
@@ -269,7 +276,11 @@ func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup {
|
|||||||
if in.CurrentLine.Len() > 0 {
|
if in.CurrentLine.Len() > 0 {
|
||||||
in.AddLine()
|
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
|
// 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 {
|
for i, arg := range args {
|
||||||
stringArgs[i] = fmt.Sprint(arg)
|
stringArgs[i] = fmt.Sprint(arg)
|
||||||
}
|
}
|
||||||
return CallbackData{
|
return CallbackData{Command: command, Args: stringArgs}
|
||||||
Command: command,
|
|
||||||
Args: stringArgs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All To* encoders return an empty string when serialization fails. Telegram
|
// All To* encoders return an empty string when serialization fails. Telegram
|
||||||
|
|||||||
@@ -73,6 +73,35 @@ func TestInlineKeyboardButtonBuilderSetCallbackDataUsesConfiguredPayloadType(t *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInlineKeyboardButtonBuilderKeepsExactlyOneAction(t *testing.T) {
|
||||||
|
callback := NewInlineKeyboardButton("Action").
|
||||||
|
SetURL("https://example.test").
|
||||||
|
SetCallbackDataJSON("confirm").
|
||||||
|
build()
|
||||||
|
if callback.URL != "" || callback.CallbackData == "" {
|
||||||
|
t.Fatalf("callback action was not exclusive: %#v", callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := NewInlineKeyboardButton("Action").
|
||||||
|
SetCallbackDataJSON("confirm").
|
||||||
|
SetURL("https://example.test").
|
||||||
|
build()
|
||||||
|
if link.URL == "" || link.CallbackData != "" {
|
||||||
|
t.Fatalf("URL action was not exclusive: %#v", link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInlineKeyboardGetReturnsIndependentMarkup(t *testing.T) {
|
||||||
|
keyboard := NewInlineKeyboardJSON(1).AddURLButton("Docs", "https://example.test")
|
||||||
|
first := keyboard.Get()
|
||||||
|
first.InlineKeyboard[0][0].Text = "mutated"
|
||||||
|
|
||||||
|
second := keyboard.Get()
|
||||||
|
if got := second.InlineKeyboard[0][0].Text; got != "Docs" {
|
||||||
|
t.Fatalf("Get exposed builder state for mutation: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
|
||||||
kb := NewInlineKeyboardJSON(2)
|
kb := NewInlineKeyboardJSON(2)
|
||||||
if got := kb.GetPayloadType(); got != BotPayloadJSON {
|
if got := kb.GetPayloadType(); got != BotPayloadJSON {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"maps"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// DictEntry maps language codes to translated strings.
|
// DictEntry maps language codes to translated strings.
|
||||||
type DictEntry map[string]string
|
type DictEntry map[string]string
|
||||||
@@ -64,8 +67,6 @@ func cloneDictEntry(src DictEntry) DictEntry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := make(DictEntry, len(src))
|
cloned := make(DictEntry, len(src))
|
||||||
for lang, text := range src {
|
maps.Copy(cloned, src)
|
||||||
cloned[lang] = text
|
|
||||||
}
|
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-12
@@ -8,9 +8,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgrich"
|
||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -550,28 +550,19 @@ func (ctx *MessageContext) newDraft(parseMode tgapi.ParseMode) *Draft {
|
|||||||
return nil
|
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)
|
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
|
||||||
return draft
|
return draft
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDraft creates a new message draft associated with the current chat.
|
// 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 {
|
func (ctx *MessageContext) NewDraft() *Draft {
|
||||||
return ctx.newDraft(tgapi.ParseNone)
|
return ctx.newDraft(tgapi.ParseNone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDraftMarkdown creates a new message draft associated with the current chat,
|
// NewDraftMarkdown creates a new message draft associated with the current chat,
|
||||||
// with Markdown V2 parse mode enabled.
|
// 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 {
|
func (ctx *MessageContext) NewDraftMarkdown() *Draft {
|
||||||
return ctx.newDraft(tgapi.ParseMarkdownV2)
|
return ctx.newDraft(tgapi.ParseMarkdownV2)
|
||||||
}
|
}
|
||||||
@@ -821,3 +812,55 @@ func (ctx *MessageContext) UpsertKeyboard(text string, keyboard *InlineKeyboard)
|
|||||||
func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
func (ctx *MessageContext) UpsertKeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage {
|
||||||
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
|
return ctx.upsertKeyboard(text, keyboard, tgapi.ParseMarkdownV2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctx *MessageContext) richAnswer(rich tgapi.InputRichMessage, keyboard *InlineKeyboard) *AnswerMessage {
|
||||||
|
if ctx.Msg == nil {
|
||||||
|
ctx.Logger.Errorln(ErrMessageContextNil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
params := tgapi.SendRichMessage{
|
||||||
|
ChatID: ctx.Msg.Chat.ID,
|
||||||
|
RichMessage: rich,
|
||||||
|
}
|
||||||
|
if keyboard != nil {
|
||||||
|
params.ReplyMarkup = keyboard.Get()
|
||||||
|
}
|
||||||
|
if ctx.Msg.MessageThreadID > 0 {
|
||||||
|
params.MessageThreadID = int64(ctx.Msg.MessageThreadID)
|
||||||
|
}
|
||||||
|
if ctx.Msg.DirectMessageTopic != nil {
|
||||||
|
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := ctx.API.SendRichMessageWithContext(ctx.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Errorln(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &AnswerMessage{
|
||||||
|
MessageID: msg.MessageID, ctx: ctx, Text: rich.HTML, IsMedia: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *MessageContext) richBlocksAnswer(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage {
|
||||||
|
rich, err := tgrich.BuildHTML(blocks...)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Logger.Errorln(err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.richAnswer(rich, keyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichAnswer builds and sends input rich-message blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (ctx *MessageContext) RichAnswer(blocks ...tgapi.InputRichBlock) *AnswerMessage {
|
||||||
|
return ctx.richBlocksAnswer(nil, blocks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichAnswerKeyboard builds and sends input rich-message blocks with an inline keyboard.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (ctx *MessageContext) RichAnswerKeyboard(keyboard *InlineKeyboard, blocks ...tgapi.InputRichBlock) *AnswerMessage {
|
||||||
|
return ctx.richBlocksAnswer(keyboard, blocks...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,65 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgrich"
|
||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"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) {
|
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
|
||||||
var gotBody map[string]any
|
var gotBody map[string]any
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -16,6 +16,8 @@ const (
|
|||||||
HandlerCommandKind HandlerEventKind = "command"
|
HandlerCommandKind HandlerEventKind = "command"
|
||||||
// HandlerMessageKind identifies a message fallback handler.
|
// HandlerMessageKind identifies a message fallback handler.
|
||||||
HandlerMessageKind HandlerEventKind = "message"
|
HandlerMessageKind HandlerEventKind = "message"
|
||||||
|
// HandlerMiddlewareKind identifies middleware execution.
|
||||||
|
HandlerMiddlewareKind HandlerEventKind = "middleware"
|
||||||
// HandlerPayloadKind identifies a callback payload handler.
|
// HandlerPayloadKind identifies a callback payload handler.
|
||||||
HandlerPayloadKind HandlerEventKind = "payload"
|
HandlerPayloadKind HandlerEventKind = "payload"
|
||||||
// HandlerUpdateKind identifies a generic update handler.
|
// HandlerUpdateKind identifies a generic update handler.
|
||||||
@@ -41,6 +43,24 @@ type Event interface {
|
|||||||
isEvent()
|
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.
|
// UpdateReceivedEvent describes an update entering the bot runtime.
|
||||||
type UpdateReceivedEvent struct {
|
type UpdateReceivedEvent struct {
|
||||||
UpdateID int
|
UpdateID int
|
||||||
@@ -162,9 +182,15 @@ func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) {
|
|||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
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) {
|
switch e := event.(type) {
|
||||||
case UpdateReceivedEvent:
|
case UpdateReceivedEvent:
|
||||||
bot.observer.OnUpdateReceived(ctx, e)
|
bot.observer.OnUpdateReceived(ctx, e)
|
||||||
|
|||||||
+31
-1
@@ -2,6 +2,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/extypes"
|
"git.scuroneko.dev/scuroneko/extypes"
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"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.
|
// If async, return value is ignored.
|
||||||
type MiddlewareExecutor[T AppData] func(ctx *MessageContext, db T) bool
|
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.
|
// Middleware represents a reusable execution interceptor.
|
||||||
// Can be synchronous (blocking) or asynchronous (non-blocking).
|
// Can be synchronous (blocking) or asynchronous (non-blocking).
|
||||||
type Middleware[T AppData] struct {
|
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}
|
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] {
|
func (m Middleware[T]) SetOrder(order int) Middleware[T] {
|
||||||
m.order = order
|
m.order = order
|
||||||
return m
|
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
|
// must treat those fields as read-only — mutating them races the sync chain
|
||||||
// that mutates the same context concurrently.
|
// that mutates the same context concurrently.
|
||||||
func (m Middleware[T]) Execute(ctx *MessageContext, db T) bool {
|
func (m Middleware[T]) Execute(ctx *MessageContext, db T) bool {
|
||||||
|
if m.executor == nil {
|
||||||
|
reportMiddlewareError(ctx, m.name, ErrMiddlewareExecutorNil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if m.async {
|
if m.async {
|
||||||
ctxCopy := *ctx
|
ctxCopy := *ctx
|
||||||
go func(ctx MessageContext) {
|
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)
|
m.executor(&ctx, db)
|
||||||
}(ctxCopy)
|
}(ctxCopy)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return m.executor(ctx, db)
|
return m.executor(ctx, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportMiddlewareError(ctx *MessageContext, name string, err error) {
|
||||||
|
event := ErrorEvent{
|
||||||
|
Plugin: "bot",
|
||||||
|
HandlerKind: HandlerMiddlewareKind,
|
||||||
|
HandlerName: name,
|
||||||
|
Err: err,
|
||||||
|
UserFacing: false,
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
event.UpdateID = ctx.Update.UpdateID
|
||||||
|
event.UpdateType = ctx.Update.Type
|
||||||
|
event.FromID = ctx.FromID
|
||||||
|
event.ChatID = ctx.ChatID
|
||||||
|
}
|
||||||
|
emitContextError(ctx, event)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,65 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"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) {
|
func TestValidateArgsRequiresFullMatch(t *testing.T) {
|
||||||
intCmd := NewCommand("int", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired())
|
intCmd := NewCommand("int", func(ctx *MessageContext, db NoData) error { return nil }, NewCommandArg("n").SetValueType(CommandValueInt).SetRequired())
|
||||||
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
if err := intCmd.validateArgs([]string{"123"}); err != nil {
|
||||||
|
|||||||
+17
-9
@@ -2,6 +2,7 @@ package laniakea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,18 @@ type Runner[T AppData] struct {
|
|||||||
fn RunnerFn[T] // The function to execute
|
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.
|
// 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
|
// 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
|
// to customize this. Do not call builder methods concurrently or after the
|
||||||
// bot runtime has begun executing runners.
|
// bot runtime has begun executing runners.
|
||||||
func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
|
func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
|
||||||
return Runner[T]{
|
return Runner[T]{name: name, fn: fn, async: true, every: 0}
|
||||||
name: name,
|
|
||||||
fn: fn,
|
|
||||||
async: true,
|
|
||||||
every: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Async sets whether the runner executes synchronously or asynchronously.
|
// 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]) {
|
go func(r Runner[T]) {
|
||||||
defer bot.runnerOnceWG.Done()
|
defer bot.runnerOnceWG.Done()
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
err := r.fn(bot)
|
err := executeRunner(r, bot)
|
||||||
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
||||||
Name: r.name,
|
Name: r.name,
|
||||||
Duration: time.Since(startedAt),
|
Duration: time.Since(startedAt),
|
||||||
@@ -109,7 +117,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
|||||||
} else if runner.every == 0 && !runner.async {
|
} else if runner.every == 0 && !runner.async {
|
||||||
// One-time sync: block until done
|
// One-time sync: block until done
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
err := runner.fn(bot)
|
err := executeRunner(runner, bot)
|
||||||
elapsed := time.Since(t)
|
elapsed := time.Since(t)
|
||||||
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
||||||
Name: runner.name,
|
Name: runner.name,
|
||||||
@@ -150,7 +158,7 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
err := r.fn(bot)
|
err := executeRunner(r, bot)
|
||||||
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
|
||||||
Name: r.name,
|
Name: r.name,
|
||||||
Duration: time.Since(startedAt),
|
Duration: time.Since(startedAt),
|
||||||
|
|||||||
@@ -95,3 +95,30 @@ func TestExecRunnersEmitObserverEvents(t *testing.T) {
|
|||||||
t.Fatalf("unexpected runner error event: %#v", got)
|
t.Fatalf("unexpected runner error event: %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecRunnersRecoversRunnerPanic(t *testing.T) {
|
||||||
|
observer := &runnerObserver{}
|
||||||
|
bot := &Bot[NoData]{
|
||||||
|
logger: sneklog.NewLogger(),
|
||||||
|
observer: observer,
|
||||||
|
runners: []Runner[NoData]{
|
||||||
|
NewRunner("panic", func(*Bot[NoData]) error {
|
||||||
|
panic("boom")
|
||||||
|
}).Async(false),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := bot.logger.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bot.ExecRunners(context.Background())
|
||||||
|
|
||||||
|
if len(observer.runners) != 1 || observer.runners[0].Err == nil {
|
||||||
|
t.Fatalf("expected recovered panic in runner event, got %#v", observer.runners)
|
||||||
|
}
|
||||||
|
if len(observer.errors) != 1 || observer.errors[0].Err == nil {
|
||||||
|
t.Fatalf("expected recovered panic in error event, got %#v", observer.errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package laniakea
|
package laniakea
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"maps"
|
"maps"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -131,18 +132,17 @@ type SceneSession struct {
|
|||||||
Scene string
|
Scene string
|
||||||
// Step is the current step name inside the active scene.
|
// Step is the current step name inside the active scene.
|
||||||
Step string
|
Step string
|
||||||
// data stores opaque session payload bytes, typically JSON.
|
|
||||||
data []byte
|
data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetData stores arbitrary opaque session data.
|
// SetData stores arbitrary opaque session data.
|
||||||
func (s *SceneSession) SetData(data []byte) {
|
func (s *SceneSession) SetData(data []byte) {
|
||||||
s.data = data
|
s.data = bytes.Clone(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetData returns the raw session data payload.
|
// GetData returns the raw session data payload.
|
||||||
func (s *SceneSession) GetData() []byte {
|
func (s *SceneSession) GetData() []byte {
|
||||||
return s.data
|
return bytes.Clone(s.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasData reports whether the session has a non-empty data payload.
|
// 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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
if session, ok := s.store[key]; ok {
|
if session, ok := s.store[key]; ok {
|
||||||
|
session.data = bytes.Clone(session.data)
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
return SceneSession{}, nil
|
return SceneSession{}, nil
|
||||||
@@ -205,6 +206,7 @@ func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
|
|||||||
|
|
||||||
// Set stores session under key.
|
// Set stores session under key.
|
||||||
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
|
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
|
||||||
|
session.data = bytes.Clone(session.data)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.store[key] = session
|
s.store[key] = session
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sceneLockEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
type sceneKeyLocker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*sceneLockEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *sceneKeyLocker) lock(keys []string) func() {
|
||||||
|
keys = uniqueSortedStrings(keys)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
if l.entries == nil {
|
||||||
|
l.entries = make(map[string]*sceneLockEntry)
|
||||||
|
}
|
||||||
|
entries := make([]*sceneLockEntry, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
entry := l.entries[key]
|
||||||
|
if entry == nil {
|
||||||
|
entry = new(sceneLockEntry)
|
||||||
|
l.entries[key] = entry
|
||||||
|
}
|
||||||
|
entry.refs++
|
||||||
|
entries[i] = entry
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry.mu.Lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
for i := len(entries) - 1; i >= 0; i-- {
|
||||||
|
entries[i].mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
for i, key := range keys {
|
||||||
|
entries[i].refs--
|
||||||
|
if entries[i].refs == 0 {
|
||||||
|
delete(l.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSortedStrings(values []string) []string {
|
||||||
|
sort.Strings(values)
|
||||||
|
result := values[:0]
|
||||||
|
for _, value := range values {
|
||||||
|
if value == "" || len(result) > 0 && result[len(result)-1] == value {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sceneKeysForContext(ctx *MessageContext) []string {
|
||||||
|
keys := make([]string, 0, 3)
|
||||||
|
for _, scope := range []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser} {
|
||||||
|
if key, ok := buildSceneKey(scope, ctx); ok {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
+141
@@ -3,7 +3,10 @@ package laniakea
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
"git.scuroneko.dev/scuroneko/sneklog/v2"
|
"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) {
|
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
|
||||||
called := false
|
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) {
|
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
|
||||||
bot := &Bot[NoData]{
|
bot := &Bot[NoData]{
|
||||||
logger: sneklog.NewLogger(),
|
logger: sneklog.NewLogger(),
|
||||||
|
|||||||
+3
-3
@@ -236,7 +236,7 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
|
|||||||
req.Body = io.NopCloser(buf)
|
req.Body = io.NopCloser(buf)
|
||||||
req.ContentLength = int64(len(reqData))
|
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)
|
resp, err := api.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return zero, fmt.Errorf("HTTP request failed: %w", err)
|
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)
|
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)
|
response, err := parseBody[R](respData)
|
||||||
if err != nil {
|
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
|
// Apply cooldown to global or chat-specific limiter
|
||||||
if api.Limiter != nil {
|
if api.Limiter != nil {
|
||||||
if r.chatID > 0 {
|
if r.chatID != 0 {
|
||||||
api.Limiter.SetChatLock(r.chatID, after)
|
api.Limiter.SetChatLock(r.chatID, after)
|
||||||
} else {
|
} else {
|
||||||
api.Limiter.SetGlobalLock(after)
|
api.Limiter.SetGlobalLock(after)
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditMessageTextMarshalsInputRichMessage(t *testing.T) {
|
||||||
|
params := EditMessageText{
|
||||||
|
ChatID: 1,
|
||||||
|
MessageID: 2,
|
||||||
|
RichMessage: &InputRichMessage{
|
||||||
|
HTML: "<p>hi</p>",
|
||||||
|
SkipEntityDetection: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
for _, want := range []string{`"rich_message":{"html":`, `"skip_entity_detection":true`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %s in editMessageText JSON: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `"blocks"`) {
|
||||||
|
t.Fatalf("rich_message must be an InputRichMessage, not a block tree: %s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, `"text"`) {
|
||||||
|
t.Fatalf("empty text must be omitted when editing rich content: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendRichMessageDraftMarshal(t *testing.T) {
|
||||||
|
params := SendRichMessageDraft{
|
||||||
|
ChatID: 1,
|
||||||
|
DraftID: 7,
|
||||||
|
RichMessage: InputRichMessage{Markdown: "*hi*"},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
for _, want := range []string{`"chat_id":1`, `"draft_id":7`, `"rich_message":{"markdown":"*hi*"}`} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("missing %s in sendRichMessageDraft JSON: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputRichMessageContentMarshal(t *testing.T) {
|
||||||
|
content := InputRichMessageContent{
|
||||||
|
RichMessage: InputRichMessage{HTML: "<p>hi</p>"},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(data); !strings.Contains(got, `"rich_message":{"html":`) {
|
||||||
|
t.Fatalf("unexpected InputRichMessageContent JSON: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputRichMessageMediaMarshal(t *testing.T) {
|
||||||
|
message := InputRichMessage{
|
||||||
|
HTML: `<video src="tg://video?id=intro"></video>`,
|
||||||
|
Media: []InputRichMessageMedia{{
|
||||||
|
ID: "intro",
|
||||||
|
Media: InputMedia{Type: InputMediaTypeVideo, Media: "attach://intro"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
Media []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Media InputMedia `json:"media"`
|
||||||
|
} `json:"media"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Media) != 1 || got.Media[0].ID != "intro" {
|
||||||
|
t.Fatalf("unexpected media: %+v", got.Media)
|
||||||
|
}
|
||||||
|
if got.Media[0].Media.Type != InputMediaTypeVideo || got.Media[0].Media.Media != "attach://intro" {
|
||||||
|
t.Fatalf("unexpected embedded media: %+v", got.Media[0].Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEphemeralMethodsMarshalReceiverUserID(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
params any
|
||||||
|
}{
|
||||||
|
{"edit text", EditEphemeralMessageText{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Text: "updated"}},
|
||||||
|
{"edit media", EditEphemeralMessageMedia{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Media: InputMedia{Type: InputMediaTypePhoto, Media: "photo-id"}}},
|
||||||
|
{"edit caption", EditEphemeralMessageCaption{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3, Caption: "updated"}},
|
||||||
|
{"edit markup", EditEphemeralMessageReplyMarkup{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3}},
|
||||||
|
{"delete", DeleteEphemeralMessage{ChatID: 1, ReceiverUserID: 2, EphemeralMessageID: 3}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := json.Marshal(tt.params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &fields); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := fields["receiver_user_id"]; !ok {
|
||||||
|
t.Fatalf("receiver_user_id is missing from %s", data)
|
||||||
|
}
|
||||||
|
if _, ok := fields["reciever_user_id"]; ok {
|
||||||
|
t.Fatalf("misspelled receiver_user_id is present in %s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEphemeralSendParametersMarshal(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
params any
|
||||||
|
}{
|
||||||
|
{"message", SendMessage{ChatID: 1, Text: "text", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"animation", SendAnimation{ChatID: 1, Animation: "animation", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"audio", SendAudio{ChatID: 1, Audio: "audio", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"document", SendDocument{ChatID: 1, Document: "document", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"photo", SendPhoto{ChatID: 1, Photo: "photo", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"sticker", SendSticker{ChatID: 1, Sticker: "sticker", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"video", SendVideo{ChatID: 1, Video: "video", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"video note", SendVideoNote{ChatID: 1, VideoNote: "video-note", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"voice", SendVoice{ChatID: 1, Voice: "voice", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"contact", SendContact{ChatID: 1, PhoneNumber: "+10000000000", FirstName: "A", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"location", SendLocation{ChatID: 1, Latitude: 1, Longitude: 2, ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
{"venue", SendVenue{ChatID: 1, Latitude: 1, Longitude: 2, Title: "Venue", Address: "Address", ReceiverUserID: 2, CallbackQueryID: "callback"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := json.Marshal(tt.params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"receiver_user_id":2`) || !strings.Contains(string(data), `"callback_query_id":"callback"`) {
|
||||||
|
t.Fatalf("missing ephemeral parameters in %s", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputPollOptionMediaLinkMarshal(t *testing.T) {
|
||||||
|
media := InputPollOptionMedia{Type: "link", URL: "https://example.com"}
|
||||||
|
data, err := json.Marshal(media)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
if got != `{"type":"link","url":"https://example.com"}` {
|
||||||
|
t.Fatalf("unexpected link media JSON: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPollMediaUnmarshalLink(t *testing.T) {
|
||||||
|
var media PollMedia
|
||||||
|
if err := json.Unmarshal([]byte(`{"link":{"url":"https://example.com"}}`), &media); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if media.Link == nil || media.Link.URL != "https://example.com" {
|
||||||
|
t.Fatalf("unexpected poll media link: %+v", media.Link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatJoinRequestUnmarshalQueryID(t *testing.T) {
|
||||||
|
payload := `{"chat":{"id":1},"from":{"id":2,"first_name":"A"},"user_chat_id":2,"date":3,"query_id":"q42"}`
|
||||||
|
var req ChatJoinRequest
|
||||||
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if req.QueryID == nil || *req.QueryID != "q42" {
|
||||||
|
t.Fatalf("unexpected query_id: %+v", req.QueryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswerChatJoinRequestQueryResultValues(t *testing.T) {
|
||||||
|
if JoinRequestApprove != "approve" || JoinRequestDecline != "decline" || JoinRequestQueue != "queue" {
|
||||||
|
t.Fatalf("unexpected join request query result values: %q %q %q",
|
||||||
|
JoinRequestApprove, JoinRequestDecline, JoinRequestQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserUnmarshalSupportsJoinRequestQueries(t *testing.T) {
|
||||||
|
var user User
|
||||||
|
if err := json.Unmarshal([]byte(`{"id":1,"first_name":"A","supports_join_request_queries":true}`), &user); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if user.SupportsJoinRequestQueries == nil || !*user.SupportsJoinRequestQueries {
|
||||||
|
t.Fatalf("unexpected supports_join_request_queries: %+v", user.SupportsJoinRequestQueries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ type SendPhoto struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Photo string `json:"photo"`
|
||||||
Caption string `json:"caption,omitempty"`
|
Caption string `json:"caption,omitempty"`
|
||||||
@@ -53,6 +57,10 @@ type SendAudio struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Audio string `json:"audio"`
|
||||||
Caption string `json:"caption,omitempty"`
|
Caption string `json:"caption,omitempty"`
|
||||||
@@ -98,6 +106,10 @@ type SendDocument struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Document string `json:"document"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
@@ -141,6 +153,10 @@ type SendVideo struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Video string `json:"video"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
@@ -192,6 +208,10 @@ type SendAnimation struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Animation string `json:"animation"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
@@ -239,6 +259,10 @@ type SendVoice struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Voice string `json:"voice"`
|
||||||
Caption string `json:"caption,omitempty"`
|
Caption string `json:"caption,omitempty"`
|
||||||
@@ -280,6 +304,10 @@ type SendVideoNote struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
VideoNote string `json:"video_note"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
@@ -397,7 +425,13 @@ type SendLivePhoto struct {
|
|||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||||
|
|||||||
+37
-12
@@ -112,9 +112,13 @@ type PaidMediaInfo struct {
|
|||||||
type PaidMediaType string
|
type PaidMediaType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PaidMediaPreviewType PaidMediaType = "preview"
|
// PaidMediaPreviewType identifies a paid-media preview.
|
||||||
PaidMediaPhotoType PaidMediaType = "photo"
|
PaidMediaPreviewType PaidMediaType = "preview"
|
||||||
PaidMediaVideoType PaidMediaType = "video"
|
// 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
|
PaidMediaLivePhotoType PaidMediaType = "live_photo" // Since: Bot API 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,11 +170,14 @@ type PollOption struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InputPollOptionMedia describes the media to attach to a poll option.
|
// InputPollOptionMedia describes the media to attach to a poll option.
|
||||||
|
// For type "link" set URL instead of Media.
|
||||||
// Since: Bot API 10.0
|
// Since: Bot API 10.0
|
||||||
// See https://core.telegram.org/bots/api#inputpolloptionmedia
|
// See https://core.telegram.org/bots/api#inputpolloptionmedia
|
||||||
type InputPollOptionMedia struct {
|
type InputPollOptionMedia struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Media string `json:"media"`
|
Media string `json:"media,omitempty"`
|
||||||
|
// URL contains the HTTP URL.
|
||||||
|
URL string `json:"url,omitempty"` // Since: Bot API 10.1; for type "link"
|
||||||
}
|
}
|
||||||
|
|
||||||
// InputPollOption contains information about one answer option in a poll to be sent.
|
// InputPollOption contains information about one answer option in a poll to be sent.
|
||||||
@@ -224,8 +231,8 @@ const (
|
|||||||
// See https://core.telegram.org/bots/api#pollanswer
|
// See https://core.telegram.org/bots/api#pollanswer
|
||||||
type PollAnswer struct {
|
type PollAnswer struct {
|
||||||
PollID string `json:"poll_id"`
|
PollID string `json:"poll_id"`
|
||||||
VoterChat Chat `json:"voter_chat"` // Since: Bot API 6.8
|
VoterChat Chat `json:"voter_chat,omitempty"` // Since: Bot API 6.8
|
||||||
User User `json:"user"`
|
User User `json:"user,omitempty"` // FIXME: Pointer in v2
|
||||||
OptionIDs []int `json:"option_ids"`
|
OptionIDs []int `json:"option_ids"`
|
||||||
OptionPersistentIDs []string `json:"option_persistent_ids"` // Since: Bot API 9.6
|
OptionPersistentIDs []string `json:"option_persistent_ids"` // Since: Bot API 9.6
|
||||||
}
|
}
|
||||||
@@ -258,12 +265,22 @@ type Poll struct {
|
|||||||
Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0
|
Media *PollMedia `json:"media,omitempty"` // Since: Bot API 10.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link represents an HTTP link.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#link
|
||||||
|
type Link struct {
|
||||||
|
// URL contains the HTTP URL.
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
// PollMedia represents media attached to a poll.
|
// PollMedia represents media attached to a poll.
|
||||||
// Since: Bot API 10.0
|
// Since: Bot API 10.0
|
||||||
type PollMedia struct {
|
type PollMedia struct {
|
||||||
Animation *Animation `json:"animation,omitempty"`
|
Animation *Animation `json:"animation,omitempty"`
|
||||||
Audio *Audio `json:"audio,omitempty"`
|
Audio *Audio `json:"audio,omitempty"`
|
||||||
Document *Document `json:"document,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"`
|
LivePhoto *LivePhoto `json:"live_photo,omitempty"`
|
||||||
Location *Location `json:"location,omitempty"`
|
Location *Location `json:"location,omitempty"`
|
||||||
Photo []PhotoSize `json:"photo,omitempty"`
|
Photo []PhotoSize `json:"photo,omitempty"`
|
||||||
@@ -342,10 +359,18 @@ const (
|
|||||||
InputMediaTypeVideo InputMediaType = "video"
|
InputMediaTypeVideo InputMediaType = "video"
|
||||||
// InputMediaTypeAudio is an audio file.
|
// InputMediaTypeAudio is an audio file.
|
||||||
InputMediaTypeAudio InputMediaType = "audio"
|
InputMediaTypeAudio InputMediaType = "audio"
|
||||||
|
// InputMediaTypeVoiceNote is a voice message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
InputMediaTypeVoiceNote InputMediaType = "voice_note"
|
||||||
|
|
||||||
InputMediaTypeSticker InputMediaType = "sticker"
|
// InputMediaTypeSticker is a sticker.
|
||||||
InputMediaTypeLocation InputMediaType = "location"
|
InputMediaTypeSticker InputMediaType = "sticker"
|
||||||
InputMediaTypeVenue InputMediaType = "venue"
|
// 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
|
InputMediaTypeLivePhoto InputMediaType = "live_photo" // Since: Bot API 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package tgapi
|
|||||||
type BotCommand struct {
|
type BotCommand struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Description string `json:"description"`
|
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.
|
// BotCommandScopeType indicates the type of a command scope.
|
||||||
|
|||||||
@@ -481,6 +481,77 @@ func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params De
|
|||||||
return req.DoWithContext(ctx, api)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatJoinRequestQueryResult is the verdict passed to answerChatJoinRequestQuery.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type ChatJoinRequestQueryResult string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// JoinRequestApprove allows the user to join the chat.
|
||||||
|
JoinRequestApprove ChatJoinRequestQueryResult = "approve"
|
||||||
|
// JoinRequestDecline disallows the user to join the chat.
|
||||||
|
JoinRequestDecline ChatJoinRequestQueryResult = "decline"
|
||||||
|
// JoinRequestQueue leaves the decision to other administrators.
|
||||||
|
JoinRequestQueue ChatJoinRequestQueryResult = "queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnswerChatJoinRequestQuery holds parameters for the answerChatJoinRequestQuery method.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||||
|
type AnswerChatJoinRequestQuery struct {
|
||||||
|
// ChatJoinRequestQueryID identifies the chat join request query.
|
||||||
|
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
|
||||||
|
// Result contains the decision for the join request query.
|
||||||
|
Result ChatJoinRequestQueryResult `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerChatJoinRequestQuery processes a received chat join request query.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// Returns True on success.
|
||||||
|
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||||
|
func (api *API) AnswerChatJoinRequestQuery(params AnswerChatJoinRequestQuery) (bool, error) {
|
||||||
|
req := NewRequest[bool]("answerChatJoinRequestQuery", params)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnswerChatJoinRequestQueryWithContext is the context-aware variant of AnswerChatJoinRequestQuery.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
|
// See https://core.telegram.org/bots/api#answerchatjoinrequestquery
|
||||||
|
func (api *API) AnswerChatJoinRequestQueryWithContext(ctx context.Context, params AnswerChatJoinRequestQuery) (bool, error) {
|
||||||
|
req := NewRequest[bool]("answerChatJoinRequestQuery", params)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatJoinRequestWebApp holds parameters for the sendChatJoinRequestWebApp method.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
|
||||||
|
type SendChatJoinRequestWebApp struct {
|
||||||
|
// ChatJoinRequestQueryID identifies the chat join request query.
|
||||||
|
ChatJoinRequestQueryID string `json:"chat_join_request_query_id"`
|
||||||
|
// WebAppURL is the HTTPS URL of the Mini App to open.
|
||||||
|
WebAppURL string `json:"web_app_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatJoinRequestWebApp shows a Mini App to the user before deciding a
|
||||||
|
// join request query; resolve the query with AnswerChatJoinRequestQuery based
|
||||||
|
// on the Mini App interaction.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// Returns True on success.
|
||||||
|
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
|
||||||
|
func (api *API) SendChatJoinRequestWebApp(params SendChatJoinRequestWebApp) (bool, error) {
|
||||||
|
req := NewRequest[bool]("sendChatJoinRequestWebApp", params)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatJoinRequestWebAppWithContext is the context-aware variant of SendChatJoinRequestWebApp.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
|
// See https://core.telegram.org/bots/api#sendchatjoinrequestwebapp
|
||||||
|
func (api *API) SendChatJoinRequestWebAppWithContext(ctx context.Context, params SendChatJoinRequestWebApp) (bool, error) {
|
||||||
|
req := NewRequest[bool]("sendChatJoinRequestWebApp", params)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
// SetChatPhoto holds parameters for the setChatPhoto method.
|
// SetChatPhoto holds parameters for the setChatPhoto method.
|
||||||
// Since: Bot API 3.1
|
// Since: Bot API 3.1
|
||||||
// See https://core.telegram.org/bots/api#setchatphoto
|
// See https://core.telegram.org/bots/api#setchatphoto
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ type ChatFullInfo struct {
|
|||||||
FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` // Since: Bot API 9.4
|
FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` // Since: Bot API 9.4
|
||||||
UniqueGiftColors *UniqueGiftColors `json:"unique_gift_colors,omitempty"` // Since: Bot API 9.3
|
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
|
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.
|
// ChatPhoto represents a chat photo.
|
||||||
@@ -317,3 +321,26 @@ type ChatBoostRemoved struct {
|
|||||||
RemoveDate int `json:"remove_date"`
|
RemoveDate int `json:"remove_date"`
|
||||||
Source ChatBoostSource `json:"source"`
|
Source ChatBoostSource `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Community represents a group of chats.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type Community struct {
|
||||||
|
// ID uniquely identifies the value within its containing object.
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
// Name is the user-facing or reference name of the value.
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityChatAdded describes a service message about a chat joining a community.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type CommunityChatAdded struct {
|
||||||
|
// Community contains information about the affected community.
|
||||||
|
Community Community `json:"community"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityChatRemoved describes a service message about a chat leaving a community.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type CommunityChatRemoved struct{}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ var ErrPoolQueueFull = errors.New("worker pool queue full")
|
|||||||
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
|
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
|
||||||
var ErrPoolStopped = errors.New("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.
|
// ResponseError reports an unsuccessful Telegram API response.
|
||||||
type ResponseError struct {
|
type ResponseError struct {
|
||||||
Code int
|
Code int
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ type InlineQueryResultsButton struct {
|
|||||||
StartParameter string `json:"start_parameter,omitempty"`
|
StartParameter string `json:"start_parameter,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InputRichMessageContent represents the content of a rich message to be
|
||||||
|
// sent as the result of an inline query. Use it as the input_message_content
|
||||||
|
// value of an InlineQueryResult.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#inputrichmessagecontent
|
||||||
|
type InputRichMessageContent struct {
|
||||||
|
// RichMessage contains structured rich-message content.
|
||||||
|
RichMessage InputRichMessage `json:"rich_message"`
|
||||||
|
}
|
||||||
|
|
||||||
// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user.
|
// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user.
|
||||||
// Since: Bot API 8.0
|
// Since: Bot API 8.0
|
||||||
// See https://core.telegram.org/bots/api#sentwebappmessage
|
// See https://core.telegram.org/bots/api#sentwebappmessage
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const redactedLogValue = "<REDACTED>"
|
||||||
|
|
||||||
|
var sensitiveLogFields = map[string]struct{}{
|
||||||
|
"callback_data": {},
|
||||||
|
"credentials": {},
|
||||||
|
"data": {},
|
||||||
|
"invoice_payload": {},
|
||||||
|
"payload": {},
|
||||||
|
"provider_data": {},
|
||||||
|
"provider_token": {},
|
||||||
|
"secret": {},
|
||||||
|
"secret_token": {},
|
||||||
|
"token": {},
|
||||||
|
"web_app_query_id": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactRequestLog(data []byte) string {
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return fmt.Sprintf("<invalid JSON omitted: %d bytes>", len(data))
|
||||||
|
}
|
||||||
|
redactLogValue(value)
|
||||||
|
redacted, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("<unavailable JSON omitted: %d bytes>", len(data))
|
||||||
|
}
|
||||||
|
return string(redacted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactLogValue(value any) {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, item := range value {
|
||||||
|
if _, sensitive := sensitiveLogFields[strings.ToLower(key)]; sensitive {
|
||||||
|
value[key] = redactedLogValue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
redactLogValue(item)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, item := range value {
|
||||||
|
redactLogValue(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseLogSummary(method string, size int) string {
|
||||||
|
return fmt.Sprintf("method=%s bytes=%d body=omitted", method, size)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedactRequestLogRemovesSensitiveValues(t *testing.T) {
|
||||||
|
const input = `{"secret_token":"webhook-secret","provider_token":"payment-token","nested":{"data":"passport-data","callback_data":"callback-secret"},"chat_id":42}`
|
||||||
|
got := redactRequestLog([]byte(input))
|
||||||
|
|
||||||
|
for _, secret := range []string{"webhook-secret", "payment-token", "passport-data", "callback-secret"} {
|
||||||
|
if strings.Contains(got, secret) {
|
||||||
|
t.Errorf("redacted request contains %q: %s", secret, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"chat_id":42`) {
|
||||||
|
t.Errorf("redacted request lost non-sensitive field: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactRequestLogOmitsInvalidJSON(t *testing.T) {
|
||||||
|
const secret = "not-json-secret"
|
||||||
|
got := redactRequestLog([]byte(secret))
|
||||||
|
if strings.Contains(got, secret) {
|
||||||
|
t.Fatalf("invalid JSON was logged verbatim: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseLogSummaryNeverContainsBody(t *testing.T) {
|
||||||
|
const token = "managed-bot-token"
|
||||||
|
got := responseLogSummary("getManagedBotToken", len(token))
|
||||||
|
if strings.Contains(got, token) {
|
||||||
|
t.Fatalf("response summary contains response body: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "body=omitted") {
|
||||||
|
t.Fatalf("response summary does not explain omission: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
-4
@@ -10,6 +10,10 @@ type SendMessage struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int64 `json:"direct_messages_topic_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"`
|
Text string `json:"text"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
@@ -29,7 +33,7 @@ type SendMessage struct {
|
|||||||
// Since: Bot API 1.0
|
// Since: Bot API 1.0
|
||||||
// See https://core.telegram.org/bots/api#sendmessage
|
// See https://core.telegram.org/bots/api#sendmessage
|
||||||
func (api *API) SendMessage(params SendMessage) (Message, error) {
|
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)
|
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.
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
// See https://core.telegram.org/bots/api#sendmessage
|
// See https://core.telegram.org/bots/api#sendmessage
|
||||||
func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) (Message, error) {
|
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)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +204,10 @@ type SendLocation struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Latitude float64 `json:"latitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
@@ -243,6 +251,10 @@ type SendVenue struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Latitude float64 `json:"latitude"`
|
||||||
Longitude float64 `json:"longitude"`
|
Longitude float64 `json:"longitude"`
|
||||||
@@ -288,6 +300,10 @@ type SendContact struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
PhoneNumber string `json:"phone_number"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
@@ -547,11 +563,13 @@ type EditMessageText struct {
|
|||||||
ChatID int64 `json:"chat_id,omitempty"`
|
ChatID int64 `json:"chat_id,omitempty"`
|
||||||
MessageID int `json:"message_id,omitempty"`
|
MessageID int `json:"message_id,omitempty"`
|
||||||
InlineMessageID string `json:"inline_message_id,omitempty"`
|
InlineMessageID string `json:"inline_message_id,omitempty"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text,omitempty"` // required unless RichMessage is set
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
Entities []MessageEntity `json:"entities,omitempty"`
|
Entities []MessageEntity `json:"entities,omitempty"`
|
||||||
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
|
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
|
||||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
// RichMessage contains structured rich-message content.
|
||||||
|
RichMessage *InputRichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1; required if Text is not specified
|
||||||
|
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditMessageText edits text messages.
|
// EditMessageText edits text messages.
|
||||||
@@ -1089,3 +1107,253 @@ func (api *API) DeleteMessageReactionWithContext(ctx context.Context, params Del
|
|||||||
req := NewRequest[bool]("deleteMessageReaction", params)
|
req := NewRequest[bool]("deleteMessageReaction", params)
|
||||||
return req.DoWithContext(ctx, api)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendRichMessage holds parameters for the sendRichMessage method.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||||
|
type SendRichMessage struct {
|
||||||
|
// BusinessConnectionID identifies the business connection used to send the message.
|
||||||
|
BusinessConnectionID string `json:"business_connection_id,omitempty"`
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// MessageThreadID identifies the target message thread.
|
||||||
|
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
||||||
|
// DirectMessagesTopicID identifies the target direct-messages topic.
|
||||||
|
DirectMessagesTopicID int64 `json:"direct_messages_topic_id,omitempty"`
|
||||||
|
|
||||||
|
// RichMessage contains structured rich-message content.
|
||||||
|
RichMessage InputRichMessage `json:"rich_message"`
|
||||||
|
// DisableNotification requests delivery without a notification sound.
|
||||||
|
DisableNotification bool `json:"disable_notification,omitempty"`
|
||||||
|
// ProtectContent prevents forwarding and saving the sent content.
|
||||||
|
ProtectContent bool `json:"protect_content,omitempty"`
|
||||||
|
// AllowPaidBroadcast permits high-throughput delivery using paid broadcast capacity.
|
||||||
|
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
|
||||||
|
// MessageEffectID identifies the message effect to apply.
|
||||||
|
MessageEffectID string `json:"message_effect_id,omitempty"`
|
||||||
|
// SuggestedPostParameters contains parameters for a suggested channel post.
|
||||||
|
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
|
||||||
|
// ReplyParameters describes the message being replied to.
|
||||||
|
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
|
||||||
|
// ReplyMarkup defines the message's inline keyboard.
|
||||||
|
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRichMessage sends a rich formatted message.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||||
|
func (api *API) SendRichMessage(params SendRichMessage) (Message, error) {
|
||||||
|
req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRichMessageWithContext is the context-aware variant of SendRichMessage.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessage
|
||||||
|
func (api *API) SendRichMessageWithContext(ctx context.Context, params SendRichMessage) (Message, error) {
|
||||||
|
req := NewRequestWithChatID[Message]("sendRichMessage", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRichMessageDraft holds parameters for the sendRichMessageDraft method.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||||
|
type SendRichMessageDraft struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// MessageThreadID identifies the target message thread.
|
||||||
|
MessageThreadID int64 `json:"message_thread_id,omitempty"`
|
||||||
|
|
||||||
|
// DraftID must be non-zero; changes to drafts with the same identifier are animated.
|
||||||
|
DraftID int64 `json:"draft_id"`
|
||||||
|
// RichMessage contains structured rich-message content.
|
||||||
|
RichMessage InputRichMessage `json:"rich_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRichMessageDraft streams a partial rich message to a private chat while
|
||||||
|
// the message is being generated. The draft is an ephemeral ~30-second
|
||||||
|
// preview; call SendRichMessage with the complete message to persist it.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// Returns True on success.
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||||
|
func (api *API) SendRichMessageDraft(params SendRichMessageDraft) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRichMessageDraftWithContext is the context-aware variant of SendRichMessageDraft.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
|
// See https://core.telegram.org/bots/api#sendrichmessagedraft
|
||||||
|
func (api *API) SendRichMessageDraftWithContext(ctx context.Context, params SendRichMessageDraft) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("sendRichMessageDraft", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageText holds parameters for editing an ephemeral text message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type EditEphemeralMessageText struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||||
|
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||||
|
// EphemeralMessageID identifies the ephemeral message.
|
||||||
|
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text string `json:"text"`
|
||||||
|
|
||||||
|
// ParseMode selects the formatting syntax used by the text or caption.
|
||||||
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
|
// Entities describes explicit formatting entities in Text.
|
||||||
|
Entities []MessageEntity `json:"entities,omitempty"`
|
||||||
|
// LinkPreviewOptions controls link preview generation for Text.
|
||||||
|
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
|
||||||
|
// ReplyMarkup defines the message's inline keyboard.
|
||||||
|
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageText edits an ephemeral text message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageText(params EditEphemeralMessageText) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageText", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageTextWithContext is the context-aware variant of EditEphemeralMessageText.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageTextWithContext(ctx context.Context, params EditEphemeralMessageText) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageText", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageMedia holds parameters for editing ephemeral message media.
|
||||||
|
// New files cannot be uploaded; use a file ID or URL.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type EditEphemeralMessageMedia struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||||
|
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||||
|
// EphemeralMessageID identifies the ephemeral message.
|
||||||
|
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||||
|
// Media contains or identifies media associated with the value.
|
||||||
|
Media InputMedia `json:"media"`
|
||||||
|
// ReplyMarkup defines the message's inline keyboard.
|
||||||
|
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageMedia edits the media of an ephemeral message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageMedia(params EditEphemeralMessageMedia) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageMedia", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageMediaWithContext is the context-aware variant of EditEphemeralMessageMedia.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageMediaWithContext(ctx context.Context, params EditEphemeralMessageMedia) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageMedia", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageCaption holds parameters for editing an ephemeral message caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type EditEphemeralMessageCaption struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||||
|
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||||
|
// EphemeralMessageID identifies the ephemeral message.
|
||||||
|
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||||
|
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption string `json:"caption,omitempty"`
|
||||||
|
// ParseMode selects the formatting syntax used by the text or caption.
|
||||||
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
|
// CaptionEntities describes formatting entities in Caption.
|
||||||
|
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||||
|
// ReplyMarkup defines the message's inline keyboard.
|
||||||
|
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageCaption edits an ephemeral message caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageCaption(params EditEphemeralMessageCaption) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageCaption", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageCaptionWithContext is the context-aware variant of EditEphemeralMessageCaption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageCaptionWithContext(ctx context.Context, params EditEphemeralMessageCaption) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageCaption", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageReplyMarkup holds parameters for editing an ephemeral message's inline keyboard.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type EditEphemeralMessageReplyMarkup struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||||
|
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||||
|
// EphemeralMessageID identifies the ephemeral message.
|
||||||
|
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||||
|
// ReplyMarkup defines the message's inline keyboard.
|
||||||
|
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageReplyMarkup edits an ephemeral message's inline keyboard.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageReplyMarkup(params EditEphemeralMessageReplyMarkup) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageReplyMarkup", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditEphemeralMessageReplyMarkupWithContext is the context-aware variant of EditEphemeralMessageReplyMarkup.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) EditEphemeralMessageReplyMarkupWithContext(ctx context.Context, params EditEphemeralMessageReplyMarkup) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("editEphemeralMessageReplyMarkup", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEphemeralMessage holds parameters for deleting an ephemeral message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type DeleteEphemeralMessage struct {
|
||||||
|
// ChatID identifies the target chat.
|
||||||
|
ChatID int64 `json:"chat_id"`
|
||||||
|
// ReceiverUserID identifies the user who can see the ephemeral message.
|
||||||
|
ReceiverUserID int64 `json:"receiver_user_id"`
|
||||||
|
// EphemeralMessageID identifies the ephemeral message.
|
||||||
|
EphemeralMessageID int64 `json:"ephemeral_message_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEphemeralMessage deletes an ephemeral message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) DeleteEphemeralMessage(params DeleteEphemeralMessage) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("deleteEphemeralMessage", params, params.ChatID)
|
||||||
|
return req.Do(api)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEphemeralMessageWithContext is the context-aware variant of DeleteEphemeralMessage.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (api *API) DeleteEphemeralMessageWithContext(ctx context.Context, params DeleteEphemeralMessage) (bool, error) {
|
||||||
|
req := NewRequestWithChatID[bool]("deleteEphemeralMessage", params, params.ChatID)
|
||||||
|
return req.DoWithContext(ctx, api)
|
||||||
|
}
|
||||||
|
|||||||
+64
-11
@@ -23,10 +23,14 @@ type DirectMessageTopic struct {
|
|||||||
type MessageOriginType string
|
type MessageOriginType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MessageOriginUserType = "user"
|
// MessageOriginUserType identifies a known user origin.
|
||||||
|
MessageOriginUserType = "user"
|
||||||
|
// MessageOriginHiddenUserType identifies a hidden user origin.
|
||||||
MessageOriginHiddenUserType = "hidden_user"
|
MessageOriginHiddenUserType = "hidden_user"
|
||||||
MessageOriginChatType = "chat"
|
// MessageOriginChatType identifies a chat origin.
|
||||||
MessageOriginChannel = "channel"
|
MessageOriginChatType = "chat"
|
||||||
|
// MessageOriginChannel identifies a channel origin.
|
||||||
|
MessageOriginChannel = "channel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MessageOrigin describes the origin of a message.
|
// 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
|
DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` // Since: Bot API 9.2
|
||||||
From *User `json:"from,omitempty"`
|
From *User `json:"from,omitempty"`
|
||||||
|
|
||||||
SenderChat *Chat `json:"sender_chat,omitempty"` // Since: Bot API 5.0
|
SenderChat *Chat `json:"sender_chat,omitempty"` // Since: Bot API 5.0
|
||||||
SenderBoostCount int `json:"sender_boost_count,omitempty"` // Since: Bot API 7.1
|
SenderBoostCount int `json:"sender_boost_count,omitempty"` // Since: Bot API 7.1
|
||||||
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` // Since: Bot API 7.2
|
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` // Since: Bot API 7.2
|
||||||
SenderTag string `json:"sender_tag,omitempty"` // Since: Bot API 9.5
|
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"`
|
Date int `json:"date"`
|
||||||
GuestQueryID string `json:"guest_query_id,omitempty"` // Since: Bot API 10.0
|
GuestQueryID string `json:"guest_query_id,omitempty"` // Since: Bot API 10.0
|
||||||
BusinessConnectionID string `json:"business_connection_id,omitempty"` // Since: Bot API 7.2
|
BusinessConnectionID string `json:"business_connection_id,omitempty"` // Since: Bot API 7.2
|
||||||
@@ -151,7 +159,9 @@ type Message struct {
|
|||||||
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1
|
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"` // Since: Bot API 9.1
|
||||||
EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4
|
EffectID string `json:"effect_id,omitempty"` // Since: Bot API 7.4
|
||||||
|
|
||||||
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
// RichMessage contains structured rich-message content.
|
||||||
|
RichMessage *RichMessage `json:"rich_message,omitempty"` // Since: Bot API 10.1
|
||||||
|
Animation *Animation `json:"animation,omitempty"` // Since: Bot API 4.0
|
||||||
Audio *Audio `json:"audio,omitempty"`
|
Audio *Audio `json:"audio,omitempty"`
|
||||||
Document *Document `json:"document,omitempty"`
|
Document *Document `json:"document,omitempty"`
|
||||||
PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6
|
PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` // Since: Bot API 7.6
|
||||||
@@ -205,8 +215,12 @@ type Message struct {
|
|||||||
BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` // Since: Bot API 7.1
|
BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` // Since: Bot API 7.1
|
||||||
ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` // Since: Bot API 7.5
|
ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` // Since: Bot API 7.5
|
||||||
|
|
||||||
ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,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
|
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
|
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
|
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
|
ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` // Since: Bot API 6.3
|
||||||
@@ -394,8 +408,10 @@ type MessageEntity struct {
|
|||||||
// Since: Bot API 7.0
|
// Since: Bot API 7.0
|
||||||
// See https://core.telegram.org/bots/api#replyparameters
|
// See https://core.telegram.org/bots/api#replyparameters
|
||||||
type ReplyParameters struct {
|
type ReplyParameters struct {
|
||||||
MessageID int `json:"message_id"`
|
MessageID int `json:"message_id,omitempty"`
|
||||||
ChatID int64 `json:"chat_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"`
|
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
|
||||||
Quote string `json:"quote,omitempty"`
|
Quote string `json:"quote,omitempty"`
|
||||||
@@ -748,3 +764,40 @@ type VideoChatParticipantsInvited struct {
|
|||||||
type SentGuestMessage struct {
|
type SentGuestMessage struct {
|
||||||
InlineMessageID string `json:"inline_message_id"`
|
InlineMessageID string `json:"inline_message_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RichMessage represents a received rich-formatted message.
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichMessage struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
// IsRTL requests right-to-left rich-message layout.
|
||||||
|
IsRTL bool `json:"is_rtl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRichMessageMedia describes media embedded in outgoing rich-message HTML or Markdown.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichMessageMedia struct {
|
||||||
|
// ID uniquely identifies the value within its containing object.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Media contains or identifies media associated with the value.
|
||||||
|
Media InputMedia `json:"media"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRichMessage describes a rich message to be sent. Exactly one of HTML, Markdown, or Blocks must be used.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type InputRichMessage struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks,omitempty"` // Since: Bot API 10.2
|
||||||
|
// HTML contains rich-message content in Telegram HTML syntax.
|
||||||
|
HTML string `json:"html,omitempty"`
|
||||||
|
// Markdown contains rich-message content in Telegram Markdown syntax.
|
||||||
|
Markdown string `json:"markdown,omitempty"`
|
||||||
|
// Media contains or identifies media associated with the value.
|
||||||
|
Media []InputRichMessageMedia `json:"media,omitempty"` // Since: Bot API 10.2
|
||||||
|
// IsRTL requests right-to-left rich-message layout.
|
||||||
|
IsRTL bool `json:"is_rtl,omitempty"`
|
||||||
|
// SkipEntityDetection disables automatic detection of links, mentions, hashtags, commands, phone numbers, and bank cards.
|
||||||
|
SkipEntityDetection bool `json:"skip_entity_detection,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
+4
-4
@@ -79,7 +79,7 @@ func (api *API) ReplaceManagedBotTokenWithContext(ctx context.Context, params Re
|
|||||||
// Returns true on success.
|
// Returns true on success.
|
||||||
// See https://core.telegram.org/bots/api#logout
|
// See https://core.telegram.org/bots/api#logout
|
||||||
func (api *API) LogOut() (bool, error) {
|
func (api *API) LogOut() (bool, error) {
|
||||||
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
|
req := NewRequest[bool]("logOut", NoParams)
|
||||||
return req.Do(api)
|
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.
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
// See https://core.telegram.org/bots/api#logout
|
// See https://core.telegram.org/bots/api#logout
|
||||||
func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
|
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)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
|
|||||||
// Returns true on success.
|
// Returns true on success.
|
||||||
// See https://core.telegram.org/bots/api#close
|
// See https://core.telegram.org/bots/api#close
|
||||||
func (api *API) CloseRemote() (bool, error) {
|
func (api *API) CloseRemote() (bool, error) {
|
||||||
req := NewRequest[bool, EmptyParams]("close", NoParams)
|
req := NewRequest[bool]("close", NoParams)
|
||||||
return req.Do(api)
|
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.
|
// It executes the same request but uses ctx for cancellation and deadlines.
|
||||||
// See https://core.telegram.org/bots/api#close
|
// See https://core.telegram.org/bots/api#close
|
||||||
func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) {
|
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)
|
return req.DoWithContext(ctx, api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -20,19 +20,32 @@ type PassportFile struct {
|
|||||||
type PassportElementType string
|
type PassportElementType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PassportPersonalDetailsType PassportElementType = "personal_details"
|
// PassportPersonalDetailsType identifies personal details.
|
||||||
PassportPassportType PassportElementType = "passport"
|
PassportPersonalDetailsType PassportElementType = "personal_details"
|
||||||
PassportDriverLicenseType PassportElementType = "driver_license"
|
// PassportPassportType identifies an international passport.
|
||||||
PassportIdentityCardType PassportElementType = "identity_card"
|
PassportPassportType PassportElementType = "passport"
|
||||||
PassportInternalPassportType PassportElementType = "internal_passport"
|
// PassportDriverLicenseType identifies a driver license.
|
||||||
PassportAddressType PassportElementType = "address"
|
PassportDriverLicenseType PassportElementType = "driver_license"
|
||||||
PassportUtilityBillType PassportElementType = "utility_bill"
|
// PassportIdentityCardType identifies an identity card.
|
||||||
PassportBankStatementType PassportElementType = "bank_statement"
|
PassportIdentityCardType PassportElementType = "identity_card"
|
||||||
PassportRentalAgreementType PassportElementType = "rental_agreement"
|
// PassportInternalPassportType identifies an internal passport.
|
||||||
PassportPassportRegistrationType PassportElementType = "passport_registration"
|
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"
|
PassportTemporaryRegistrationType PassportElementType = "temporary_registration"
|
||||||
PassportPhoneNumberType PassportElementType = "phone_number"
|
// PassportPhoneNumberType identifies a phone number.
|
||||||
PassportEmailType PassportElementType = "email"
|
PassportPhoneNumberType PassportElementType = "phone_number"
|
||||||
|
// PassportEmailType identifies an email address.
|
||||||
|
PassportEmailType PassportElementType = "email"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EncryptedPassportElement contains information about documents or other Telegram Passport elements.
|
// EncryptedPassportElement contains information about documents or other Telegram Passport elements.
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// RichBlock is a block in a structured rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlock interface {
|
||||||
|
isRichBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockCaption is the caption of a media block or container.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockCaption struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Credit contains attribution displayed with the block.
|
||||||
|
Credit RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (c RichBlockCaption) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Credit RichText `json:"credit,omitempty"`
|
||||||
|
}{c.Text, c.Credit})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (c *RichBlockCaption) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
Text json.RawMessage `json:"text"`
|
||||||
|
Credit json.RawMessage `json:"credit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text, err := parseOptRichText(raw.Text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
credit, err := parseOptRichText(raw.Credit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*c = RichBlockCaption{text, credit}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockListItem is a single list item. Label is the ready-to-display
|
||||||
|
// visible marker ("1.", "c.", "vii.", "•"): the server renders it itself
|
||||||
|
// when parsing html/markdown.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockListItem struct {
|
||||||
|
// Label contains the list-item label.
|
||||||
|
Label string
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock
|
||||||
|
// HasCheckbox reports whether the list item includes a checkbox.
|
||||||
|
HasCheckbox bool
|
||||||
|
// IsChecked reports whether the list-item checkbox is checked.
|
||||||
|
IsChecked bool
|
||||||
|
Value int // for ordered lists: numeric value of the marker
|
||||||
|
Type RichBlockListItemType // for ordered lists: "a", "A", "i", "I" or "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (i RichBlockListItem) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
HasCheckbox bool `json:"has_checkbox,omitempty"`
|
||||||
|
IsChecked bool `json:"is_checked,omitempty"`
|
||||||
|
Value int `json:"value,omitempty"`
|
||||||
|
Type RichBlockListItemType `json:"type,omitempty"`
|
||||||
|
}{i.Label, i.Blocks, i.HasCheckbox, i.IsChecked, i.Value, i.Type})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (i *RichBlockListItem) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
HasCheckbox bool `json:"has_checkbox"`
|
||||||
|
IsChecked bool `json:"is_checked"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
Type RichBlockListItemType `json:"type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*i = RichBlockListItem{raw.Label, blocks, raw.HasCheckbox, raw.IsChecked, raw.Value, raw.Type}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockTableCell is a table cell. An empty Text means an invisible cell.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockTableCell struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// IsHeader marks the table cell as a header cell.
|
||||||
|
IsHeader bool
|
||||||
|
// ColSpan is the number of table columns spanned by the cell.
|
||||||
|
ColSpan int
|
||||||
|
// RowSpan is the number of table rows spanned by the cell.
|
||||||
|
RowSpan int
|
||||||
|
Align string // "left", "center" or "right"
|
||||||
|
VAlign string // "top", "middle" or "bottom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (c RichBlockTableCell) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Text RichText `json:"text,omitempty"`
|
||||||
|
IsHeader bool `json:"is_header,omitempty"`
|
||||||
|
Colspan int `json:"colspan,omitempty"`
|
||||||
|
Rowspan int `json:"rowspan,omitempty"`
|
||||||
|
Align string `json:"align,omitempty"`
|
||||||
|
VAlign string `json:"valign,omitempty"`
|
||||||
|
}{c.Text, c.IsHeader, c.ColSpan, c.RowSpan, c.Align, c.VAlign})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (c *RichBlockTableCell) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw struct {
|
||||||
|
Text json.RawMessage `json:"text"`
|
||||||
|
IsHeader bool `json:"is_header"`
|
||||||
|
Colspan int `json:"colspan"`
|
||||||
|
Rowspan int `json:"rowspan"`
|
||||||
|
Align string `json:"align"`
|
||||||
|
VAlign string `json:"valign"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text, err := parseOptRichText(raw.Text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*c = RichBlockTableCell{text, raw.IsHeader, raw.Colspan, raw.Rowspan, raw.Align, raw.VAlign}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockWrap covers all blocks that have only a text field.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockWrap struct {
|
||||||
|
// Tag identifies the rich-text formatting wrapper.
|
||||||
|
Tag string
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockWrap) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockWrap) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
}{b.Tag, b.Text})
|
||||||
|
}
|
||||||
|
|
||||||
|
var richBlockWrapTags = map[string]bool{
|
||||||
|
"paragraph": true, "footer": true, "thinking": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockSectionHeading is a section heading block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockSectionHeading struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
Size int // 1-6, 1 is the largest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockSectionHeading) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockSectionHeading) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}{"heading", b.Text, b.Size})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockPreformatted is a preformatted code block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockPreformatted struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Language identifies the programming language used for syntax highlighting.
|
||||||
|
Language string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockPreformatted) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockPreformatted) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}{"pre", b.Text, b.Language})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockQuotation is a block quotation with block-level content
|
||||||
|
// (officially RichBlockBlockQuotation).
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockQuotation struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock
|
||||||
|
// Credit contains attribution displayed with the block.
|
||||||
|
Credit RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockQuotation) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockQuotation) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
Credit RichText `json:"credit,omitempty"`
|
||||||
|
}{"blockquote", b.Blocks, b.Credit})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockPullQuotation is a pull quotation with inline content.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockPullQuotation struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Credit contains attribution displayed with the block.
|
||||||
|
Credit RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockPullQuotation) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockPullQuotation) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Credit RichText `json:"credit,omitempty"`
|
||||||
|
}{"pullquote", b.Text, b.Credit})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockList is a list block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockList struct {
|
||||||
|
// Items contains the list items.
|
||||||
|
Items []RichBlockListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockList) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockList) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Items []RichBlockListItem `json:"items"`
|
||||||
|
}{"list", b.Items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockCollage is a collage of media blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockCollage struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockCollage) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockCollage) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"collage", b.Blocks, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockSlideshow is a slideshow of media blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockSlideshow struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockSlideshow) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockSlideshow) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"slideshow", b.Blocks, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockDetails is an expandable block with an inline summary.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockDetails struct {
|
||||||
|
// Summary contains the visible summary of a details block.
|
||||||
|
Summary RichText
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []RichBlock
|
||||||
|
// IsOpen requests the details block to be expanded initially.
|
||||||
|
IsOpen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockDetails) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockDetails) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Summary RichText `json:"summary"`
|
||||||
|
Blocks []RichBlock `json:"blocks"`
|
||||||
|
IsOpen bool `json:"is_open,omitempty"`
|
||||||
|
}{"details", b.Summary, b.Blocks, b.IsOpen})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockTable is a table block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockTable struct {
|
||||||
|
// Cells contains the table rows and cells.
|
||||||
|
Cells [][]RichBlockTableCell
|
||||||
|
// IsBordered requests visible table borders.
|
||||||
|
IsBordered bool
|
||||||
|
// IsStriped requests alternating table row styling.
|
||||||
|
IsStriped bool
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockTable) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockTable) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cells [][]RichBlockTableCell `json:"cells"`
|
||||||
|
IsBordered bool `json:"is_bordered,omitempty"`
|
||||||
|
IsStriped bool `json:"is_striped,omitempty"`
|
||||||
|
Caption RichText `json:"caption,omitempty"`
|
||||||
|
}{"table", b.Cells, b.IsBordered, b.IsStriped, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockMap is a location map block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockMap struct {
|
||||||
|
// Location contains the map location.
|
||||||
|
Location Location
|
||||||
|
Zoom int // 13-20
|
||||||
|
// Width is the requested media or map width in pixels.
|
||||||
|
Width int
|
||||||
|
// Height is the requested media or map height in pixels.
|
||||||
|
Height int
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockMap) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockMap) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Location Location `json:"location"`
|
||||||
|
Zoom int `json:"zoom"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"map", b.Location, b.Zoom, b.Width, b.Height, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockPhoto is a photo block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockPhoto struct {
|
||||||
|
// Photo contains or identifies the associated photo.
|
||||||
|
Photo []PhotoSize
|
||||||
|
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||||
|
HasSpoiler bool
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockPhoto) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockPhoto) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Photo []PhotoSize `json:"photo"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"photo", b.Photo, b.HasSpoiler, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockVideo is a video block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockVideo struct {
|
||||||
|
// Video contains the video rendered by the block.
|
||||||
|
Video Video
|
||||||
|
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||||
|
HasSpoiler bool
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockVideo) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockVideo) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Video Video `json:"video"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"video", b.Video, b.HasSpoiler, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockAudio is an audio block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockAudio struct {
|
||||||
|
// Audio contains the audio rendered by the block.
|
||||||
|
Audio Audio
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockAudio) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockAudio) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Audio Audio `json:"audio"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"audio", b.Audio, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockAnimation is an animation block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockAnimation struct {
|
||||||
|
// Animation contains the animation rendered by the block.
|
||||||
|
Animation Animation
|
||||||
|
// HasSpoiler reports whether the media is covered by a spoiler.
|
||||||
|
HasSpoiler bool
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockAnimation) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockAnimation) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Animation Animation `json:"animation"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler,omitempty"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"animation", b.Animation, b.HasSpoiler, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockVoiceNote is a voice note block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockVoiceNote struct {
|
||||||
|
// VoiceNote contains the voice note rendered by the block.
|
||||||
|
VoiceNote Voice
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockVoiceNote) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockVoiceNote) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
VoiceNote Voice `json:"voice_note"`
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}{"voice_note", b.VoiceNote, b.Caption})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockDivider is a horizontal divider block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockDivider struct{}
|
||||||
|
|
||||||
|
func (RichBlockDivider) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockDivider) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}{"divider"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockMathematicalExpression is a block-level mathematical expression.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockMathematicalExpression struct {
|
||||||
|
// Expression contains the mathematical expression source.
|
||||||
|
Expression string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockMathematicalExpression) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}{"mathematical_expression", b.Expression})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichBlockAnchor is a named anchor block that anchor links can point to.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichBlockAnchor struct {
|
||||||
|
// Name is the user-facing or reference name of the value.
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichBlockAnchor) isRichBlock() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (b RichBlockAnchor) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}{"anchor", b.Name})
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
// InputRichType identifies the JSON type of an input rich block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// InputRichTypeParagraph identifies a paragraph block.
|
||||||
|
InputRichTypeParagraph InputRichType = "paragraph"
|
||||||
|
// InputRichTypeSectionHeading identifies a section-heading block.
|
||||||
|
InputRichTypeSectionHeading InputRichType = "heading"
|
||||||
|
// InputRichTypePre identifies a preformatted block.
|
||||||
|
InputRichTypePre InputRichType = "pre"
|
||||||
|
// InputRichTypeFooter identifies a footer block.
|
||||||
|
InputRichTypeFooter InputRichType = "footer"
|
||||||
|
// InputRichTypeDivider identifies a divider block.
|
||||||
|
InputRichTypeDivider InputRichType = "divider"
|
||||||
|
// InputRichTypeMathematicalExpression identifies a mathematical-expression block.
|
||||||
|
InputRichTypeMathematicalExpression InputRichType = "mathematical_expression"
|
||||||
|
// InputRichTypeAnchor identifies an anchor block.
|
||||||
|
InputRichTypeAnchor InputRichType = "anchor"
|
||||||
|
// InputRichTypeList identifies a list block.
|
||||||
|
InputRichTypeList InputRichType = "list"
|
||||||
|
// InputRichTypeBlockQuotation identifies a block-quotation block.
|
||||||
|
InputRichTypeBlockQuotation InputRichType = "blockquote"
|
||||||
|
// InputRichTypePullQuotation identifies a pull-quotation block.
|
||||||
|
InputRichTypePullQuotation InputRichType = "pullquote"
|
||||||
|
// InputRichTypeCollage identifies a collage block.
|
||||||
|
InputRichTypeCollage InputRichType = "collage"
|
||||||
|
// InputRichTypeSlideshow identifies a slideshow block.
|
||||||
|
InputRichTypeSlideshow InputRichType = "slideshow"
|
||||||
|
// InputRichTypeTable identifies a table block.
|
||||||
|
InputRichTypeTable InputRichType = "table"
|
||||||
|
// InputRichTypeDetails identifies an expandable details block.
|
||||||
|
InputRichTypeDetails InputRichType = "details"
|
||||||
|
// InputRichTypeMap identifies a map block.
|
||||||
|
InputRichTypeMap InputRichType = "map"
|
||||||
|
// InputRichTypeAnimation identifies an animation block.
|
||||||
|
InputRichTypeAnimation InputRichType = "animation"
|
||||||
|
// InputRichTypeAudio identifies an audio block.
|
||||||
|
InputRichTypeAudio InputRichType = "audio"
|
||||||
|
// InputRichTypePhoto identifies a photo block.
|
||||||
|
InputRichTypePhoto InputRichType = "photo"
|
||||||
|
// InputRichTypeVideo identifies a video block.
|
||||||
|
InputRichTypeVideo InputRichType = "video"
|
||||||
|
// InputRichTypeVoiceNote identifies a voice-note block.
|
||||||
|
InputRichTypeVoiceNote InputRichType = "voice_note"
|
||||||
|
// InputRichTypeThinking identifies a thinking block.
|
||||||
|
InputRichTypeThinking InputRichType = "thinking"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputRichBlock represents a block available to format an outgoing rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlock interface {
|
||||||
|
isInputRichBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRichBlockParagraph is a text paragraph corresponding to the HTML <p> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockParagraph struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockParagraph) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockSectionHeading is a section heading corresponding to an HTML <h1> through <h6> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockSectionHeading struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
// Size selects the section heading level from 1 through 6.
|
||||||
|
Size uint8 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockSectionHeading) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockPreformatted is a preformatted text block corresponding to nested HTML <pre> and <code> tags.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockPreformatted struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
// Language identifies the programming language used for syntax highlighting.
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockPreformatted) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockFooter is a footer corresponding to the HTML <footer> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockFooter struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockFooter) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockDivider is a divider corresponding to the HTML <hr/> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockDivider struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockDivider) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockMath is a block containing a mathematical expression in LaTeX format,
|
||||||
|
// corresponding to the custom HTML <tg-math-block> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockMath struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Expression contains the mathematical expression source.
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockMath) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockAnchor is a block containing an anchor corresponding to an HTML <a> tag with a name attribute.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockAnchor struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Name is the user-facing or reference name of the value.
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockAnchor) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// RichBlockListItemType identifies an ordered-list label style.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type RichBlockListItemType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// InputRichBlockListItemTypeLower uses lowercase letters.
|
||||||
|
InputRichBlockListItemTypeLower RichBlockListItemType = "a"
|
||||||
|
// InputRichBlockListItemTypeUpper uses uppercase letters.
|
||||||
|
InputRichBlockListItemTypeUpper RichBlockListItemType = "A"
|
||||||
|
// InputRichBlockListItemTypeRomanLow uses lowercase Roman numerals.
|
||||||
|
InputRichBlockListItemTypeRomanLow RichBlockListItemType = "i"
|
||||||
|
// InputRichBlockListItemTypeRomanUpper uses uppercase Roman numerals.
|
||||||
|
InputRichBlockListItemTypeRomanUpper RichBlockListItemType = "I"
|
||||||
|
// InputRichBlockListItemTypeDecimal uses decimal numbers.
|
||||||
|
InputRichBlockListItemTypeDecimal RichBlockListItemType = "1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputRichBlockListItem represents an item in an input rich-message list.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockListItem struct {
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks"`
|
||||||
|
// HasCheckbox reports whether the list item includes a checkbox.
|
||||||
|
HasCheckbox bool `json:"has_checkbox,omitempty"`
|
||||||
|
// IsChecked reports whether the list-item checkbox is checked.
|
||||||
|
IsChecked bool `json:"is_checked,omitempty"`
|
||||||
|
// Value sets the numeric marker value for an ordered list item.
|
||||||
|
Value int `json:"value,omitempty"`
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type RichBlockListItemType `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInputRichBlockListItem creates a list item containing blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func NewInputRichBlockListItem(blocks ...InputRichBlock) *InputRichBlockListItem {
|
||||||
|
return &InputRichBlockListItem{Blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCheckbox configures whether the list item has a checkbox.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *InputRichBlockListItem) SetCheckbox(hasCheckbox bool) *InputRichBlockListItem {
|
||||||
|
i.HasCheckbox = hasCheckbox
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check marks the list item's checkbox as checked.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *InputRichBlockListItem) Check() *InputRichBlockListItem {
|
||||||
|
i.IsChecked = true
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue sets the numeric value of an ordered-list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *InputRichBlockListItem) SetValue(val int) *InputRichBlockListItem {
|
||||||
|
i.Value = val
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetType sets the label style of an ordered-list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *InputRichBlockListItem) SetType(t RichBlockListItemType) *InputRichBlockListItem {
|
||||||
|
i.Type = t
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputRichBlockList is a list of input rich-message blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockList struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Items contains the list items.
|
||||||
|
Items []InputRichBlockListItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockList) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockBlockQuotation is a block quotation in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockBlockQuotation struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks"`
|
||||||
|
// Credit contains attribution displayed with the block.
|
||||||
|
Credit *RichText `json:"credit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockBlockQuotation) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockPullQuotation is a centered quotation in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockPullQuotation struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
// Credit contains attribution displayed with the block.
|
||||||
|
Credit *RichText `json:"credit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockPullQuotation) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockCollage is a collage in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockCollage struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockCollage) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockSlideshow is a slideshow in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockSlideshow struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockSlideshow) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockTable is a table in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockTable struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Cells contains the table rows and cells.
|
||||||
|
Cells [][]RichBlockTableCell `json:"cells"`
|
||||||
|
// IsBordered requests visible table borders.
|
||||||
|
IsBordered bool `json:"is_bordered,omitempty"`
|
||||||
|
// IsStriped requests alternating table row styling.
|
||||||
|
IsStriped bool `json:"is_striped,omitempty"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichText `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockTable) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockDetails is an expandable block in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockDetails struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Summary contains the visible summary of a details block.
|
||||||
|
Summary RichText `json:"summary"`
|
||||||
|
// Blocks contains the nested rich-message blocks.
|
||||||
|
Blocks []InputRichBlock `json:"blocks"`
|
||||||
|
// IsOpen requests the details block to be expanded initially.
|
||||||
|
IsOpen bool `json:"is_open,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockDetails) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockMap is a location map in an input rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockMap struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Location contains the map location.
|
||||||
|
Location Location `json:"location"`
|
||||||
|
// Zoom sets the map zoom level.
|
||||||
|
Zoom uint8 `json:"zoom,omitempty"`
|
||||||
|
// Width is the requested media or map width in pixels.
|
||||||
|
Width uint16 `json:"width,omitempty"`
|
||||||
|
// Height is the requested media or map height in pixels.
|
||||||
|
Height uint16 `json:"height,omitempty"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockMap) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockAnimation is an animation block corresponding to the HTML <video> tag.
|
||||||
|
// The animation caption is ignored; use Caption instead.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockAnimation struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Animation contains the animation rendered by the block.
|
||||||
|
Animation InputMedia `json:"animation"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockAnimation) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockAudio is a music-file block corresponding to the HTML <audio> tag.
|
||||||
|
// The audio caption is ignored; use Caption instead.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockAudio struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Audio contains the audio rendered by the block.
|
||||||
|
Audio InputMedia `json:"audio"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockAudio) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockPhoto is a photo block corresponding to the HTML <img> tag.
|
||||||
|
// The photo caption is ignored; use Caption instead.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockPhoto struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Photo contains or identifies the associated photo.
|
||||||
|
Photo InputMedia `json:"photo"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockPhoto) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockVideo is a video block corresponding to the HTML <video> tag.
|
||||||
|
// The video caption is ignored; use Caption instead.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockVideo struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Video contains the video rendered by the block.
|
||||||
|
Video InputMedia `json:"video"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockVideo) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockVoiceNote is a voice-note block corresponding to the HTML <audio> tag.
|
||||||
|
// The voice-note caption is ignored; use Caption instead.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockVoiceNote struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// VoiceNote contains the voice note rendered by the block.
|
||||||
|
VoiceNote InputMedia `json:"voice_note"`
|
||||||
|
// Caption contains the media or block caption.
|
||||||
|
Caption *RichBlockCaption `json:"caption,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockVoiceNote) isInputRichBlock() {}
|
||||||
|
|
||||||
|
// InputRichBlockThinking is a block for displaying a thinking state.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type InputRichBlockThinking struct {
|
||||||
|
// Type is the Bot API type discriminator.
|
||||||
|
Type InputRichType `json:"type"`
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (InputRichBlockThinking) isInputRichBlock() {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInputRichMediaBlocksMarshal(t *testing.T) {
|
||||||
|
caption := RichBlockCaption{Text: RichTextPlain("caption")}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
block InputRichBlock
|
||||||
|
blockType InputRichType
|
||||||
|
mediaKey string
|
||||||
|
mediaType InputMediaType
|
||||||
|
}{
|
||||||
|
{"animation", InputRichBlockAnimation{Type: InputRichTypeAnimation, Animation: InputMedia{Type: InputMediaTypeAnimation, Media: "animation-id"}, Caption: &caption}, InputRichTypeAnimation, "animation", InputMediaTypeAnimation},
|
||||||
|
{"audio", InputRichBlockAudio{Type: InputRichTypeAudio, Audio: InputMedia{Type: InputMediaTypeAudio, Media: "audio-id"}, Caption: &caption}, InputRichTypeAudio, "audio", InputMediaTypeAudio},
|
||||||
|
{"photo", InputRichBlockPhoto{Type: InputRichTypePhoto, Photo: InputMedia{Type: InputMediaTypePhoto, Media: "photo-id"}, Caption: &caption}, InputRichTypePhoto, "photo", InputMediaTypePhoto},
|
||||||
|
{"video", InputRichBlockVideo{Type: InputRichTypeVideo, Video: InputMedia{Type: InputMediaTypeVideo, Media: "video-id"}, Caption: &caption}, InputRichTypeVideo, "video", InputMediaTypeVideo},
|
||||||
|
{"voice note", InputRichBlockVoiceNote{Type: InputRichTypeVoiceNote, VoiceNote: InputMedia{Type: InputMediaTypeVoiceNote, Media: "voice-id"}, Caption: &caption}, InputRichTypeVoiceNote, "voice_note", InputMediaTypeVoiceNote},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := json.Marshal(InputRichMessage{Blocks: []InputRichBlock{tt.block}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var message struct {
|
||||||
|
Blocks []map[string]json.RawMessage `json:"blocks"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &message); err != nil {
|
||||||
|
t.Fatalf("Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(message.Blocks) != 1 {
|
||||||
|
t.Fatalf("got %d blocks, want 1", len(message.Blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockType InputRichType
|
||||||
|
if err := json.Unmarshal(message.Blocks[0]["type"], &blockType); err != nil {
|
||||||
|
t.Fatalf("unmarshal block type: %v", err)
|
||||||
|
}
|
||||||
|
if blockType != tt.blockType {
|
||||||
|
t.Errorf("block type = %q, want %q", blockType, tt.blockType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var media InputMedia
|
||||||
|
if err := json.Unmarshal(message.Blocks[0][tt.mediaKey], &media); err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", tt.mediaKey, err)
|
||||||
|
}
|
||||||
|
if media.Type != tt.mediaType {
|
||||||
|
t.Errorf("media type = %q, want %q", media.Type, tt.mediaType)
|
||||||
|
}
|
||||||
|
if message.Blocks[0]["caption"] == nil {
|
||||||
|
t.Error("caption is missing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInputRichBlockMapImplementsInputRichBlock(t *testing.T) {
|
||||||
|
var _ InputRichBlock = InputRichBlockMap{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// RichText is a node of the rich formatted text tree: a plain string, an
|
||||||
|
// array, or one of the typed objects below.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichText interface {
|
||||||
|
isRichText()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextPlain is a plain text leaf.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextPlain string
|
||||||
|
|
||||||
|
func (RichTextPlain) isRichText() {}
|
||||||
|
|
||||||
|
// RichTextArray is a concatenation of rich text nodes.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextArray []RichText
|
||||||
|
|
||||||
|
func (RichTextArray) isRichText() {}
|
||||||
|
|
||||||
|
// RichTextWrap covers all "pure" wrapper nodes with a single type.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextWrap struct {
|
||||||
|
// Tag identifies the rich-text formatting wrapper.
|
||||||
|
Tag string
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextWrap) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (w RichTextWrap) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
}{w.Tag, w.Text})
|
||||||
|
}
|
||||||
|
|
||||||
|
var richTextWrapTags = map[string]bool{
|
||||||
|
"bold": true, "italic": true, "underline": true,
|
||||||
|
"strikethrough": true, "spoiler": true, "subscript": true,
|
||||||
|
"superscript": true, "marked": true, "code": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextURL is rich text linking to a URL.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextURL struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// URL contains the HTTP URL.
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextURL) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextURL) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}{"url", v.Text, v.URL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextEmailAddress is rich text linking to an email address.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextEmailAddress struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// EmailAddress is the email address associated with the text.
|
||||||
|
EmailAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextEmailAddress) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextEmailAddress) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
}{"email_address", v.Text, v.EmailAddress})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextPhoneNumber is rich text linking to a phone number.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextPhoneNumber struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// PhoneNumber is the phone number associated with the text.
|
||||||
|
PhoneNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextPhoneNumber) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextPhoneNumber) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
}{"phone_number", v.Text, v.PhoneNumber})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextBankCardNumber is rich text marked as a bank card number.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextBankCardNumber struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// BankCardNumber is the bank card number associated with the text.
|
||||||
|
BankCardNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextBankCardNumber) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextBankCardNumber) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
BankCardNumber string `json:"bank_card_number"`
|
||||||
|
}{"bank_card_number", v.Text, v.BankCardNumber})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextMention is rich text mentioning a user by username.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextMention struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Username is the username associated with the mention.
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextMention) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextMention) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}{"mention", v.Text, v.Username})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextHashtag is rich text marked as a hashtag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextHashtag struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Hashtag is the hashtag associated with the text.
|
||||||
|
Hashtag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextHashtag) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextHashtag) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Hashtag string `json:"hashtag"`
|
||||||
|
}{"hashtag", v.Text, v.Hashtag})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextCashtag is rich text marked as a cashtag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextCashtag struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Cashtag is the cashtag associated with the text.
|
||||||
|
Cashtag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextCashtag) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextCashtag) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Cashtag string `json:"cashtag"`
|
||||||
|
}{"cashtag", v.Text, v.Cashtag})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextBotCommand is rich text marked as a bot command.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextBotCommand struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// BotCommand is the bot command associated with the text.
|
||||||
|
BotCommand string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextBotCommand) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextBotCommand) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
BotCommand string `json:"bot_command"`
|
||||||
|
}{"bot_command", v.Text, v.BotCommand})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextAnchorLink is rich text linking to a named anchor in the same message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextAnchorLink struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// AnchorName names the anchor targeted by the link.
|
||||||
|
AnchorName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextAnchorLink) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextAnchorLink) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
AnchorName string `json:"anchor_name"`
|
||||||
|
}{"anchor_link", v.Text, v.AnchorName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextReference is rich text marked as a named reference target.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextReference struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// Name is the user-facing or reference name of the value.
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextReference) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextReference) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}{"reference", v.Text, v.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextReferenceLink is rich text linking to a named reference.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextReferenceLink struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// ReferenceName names the reference targeted by the link.
|
||||||
|
ReferenceName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextReferenceLink) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextReferenceLink) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
ReferenceName string `json:"reference_name"`
|
||||||
|
}{"reference_link", v.Text, v.ReferenceName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextDateTime is rich text bound to a point in time with a display format.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextDateTime struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// UnixTime is the Unix timestamp associated with the text.
|
||||||
|
UnixTime int64
|
||||||
|
// DateTimeFormat controls how the associated Unix time is displayed.
|
||||||
|
DateTimeFormat string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextDateTime) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextDateTime) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
UnixTime int64 `json:"unix_time"`
|
||||||
|
DateTimeFormat string `json:"date_time_format"`
|
||||||
|
}{"date_time", v.Text, v.UnixTime, v.DateTimeFormat})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextTextMention is rich text mentioning a user without a username.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextTextMention struct {
|
||||||
|
// Text contains the formatted or plain text content.
|
||||||
|
Text RichText
|
||||||
|
// User contains the user associated with the value.
|
||||||
|
User User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextTextMention) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextTextMention) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text RichText `json:"text"`
|
||||||
|
User User `json:"user"`
|
||||||
|
}{"text_mention", v.Text, v.User})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextCustomEmoji is a custom emoji leaf with alternative text.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextCustomEmoji struct {
|
||||||
|
// CustomEmojiID identifies the custom emoji.
|
||||||
|
CustomEmojiID string
|
||||||
|
// AlternativeText is shown when the custom emoji can't be rendered.
|
||||||
|
AlternativeText string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextCustomEmoji) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextCustomEmoji) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
CustomEmojiID string `json:"custom_emoji_id"`
|
||||||
|
AlternativeText string `json:"alternative_text"`
|
||||||
|
}{"custom_emoji", v.CustomEmojiID, v.AlternativeText})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextMathematicalExpression is an inline mathematical expression leaf.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextMathematicalExpression struct {
|
||||||
|
// Expression contains the mathematical expression source.
|
||||||
|
Expression string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextMathematicalExpression) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextMathematicalExpression) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}{"mathematical_expression", v.Expression})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTextAnchor is a named anchor leaf that anchor links can point to.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
type RichTextAnchor struct {
|
||||||
|
// Name is the user-facing or reference name of the value.
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RichTextAnchor) isRichText() {}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (v RichTextAnchor) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}{"anchor", v.Name})
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func roundtripRichText(t *testing.T, in RichText) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
out, err := UnmarshalRichText(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", b, err)
|
||||||
|
}
|
||||||
|
b2, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remarshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(b) != string(b2) {
|
||||||
|
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichTextRoundtrip(t *testing.T) {
|
||||||
|
cases := []RichText{
|
||||||
|
RichTextPlain("hello"),
|
||||||
|
RichTextArray{RichTextPlain("a "), RichTextWrap{"bold", RichTextPlain("b")}, RichTextPlain(" c")},
|
||||||
|
RichTextWrap{"bold", RichTextWrap{"italic", RichTextPlain("nested")}},
|
||||||
|
RichTextURL{RichTextPlain("Anthropic"), "https://anthropic.com"},
|
||||||
|
RichTextCustomEmoji{"5368324170671202286", "👍"},
|
||||||
|
RichTextMathematicalExpression{"x^2 + y^2"},
|
||||||
|
RichTextAnchor{"chapter-1"},
|
||||||
|
RichTextDateTime{RichTextPlain("22:45 tomorrow"), 1647531900, "wDT"},
|
||||||
|
RichTextTextMention{RichTextPlain("Bob"), User{ID: 42, FirstName: "Bob"}},
|
||||||
|
RichTextAnchorLink{RichTextPlain("back to top"), ""},
|
||||||
|
RichTextReference{RichTextPlain("ref"), "note-1"},
|
||||||
|
// deep nesting
|
||||||
|
RichTextWrap{"bold", RichTextArray{
|
||||||
|
RichTextPlain("bold and "),
|
||||||
|
RichTextWrap{"italic", RichTextWrap{"underline", RichTextPlain("deep")}},
|
||||||
|
RichTextWrap{"spoiler", RichTextCustomEmoji{"1", "x"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
roundtripRichText(t, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichTextPlainFormsAreBare(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(RichTextPlain("hi"))
|
||||||
|
if string(b) != `"hi"` {
|
||||||
|
t.Fatalf("string should be bare: %s", b)
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(RichTextArray{RichTextPlain("a"), RichTextPlain("b")})
|
||||||
|
if string(b) != `["a","b"]` {
|
||||||
|
t.Fatalf("array should be bare: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichTextLeafHasNoText(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(RichTextAnchor{"x"})
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
if _, ok := m["text"]; ok {
|
||||||
|
t.Fatalf("anchor must not have text field: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalRichTextRejectsInvalidValues(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
`null`,
|
||||||
|
`{"type":"date_time","text":"now","unix_time":"soon"}`,
|
||||||
|
`{"type":"custom_emoji","custom_emoji_id":42}`,
|
||||||
|
}
|
||||||
|
for _, raw := range tests {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if _, err := UnmarshalRichText([]byte(raw)); err == nil {
|
||||||
|
t.Fatal("expected malformed rich text to be rejected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnmarshalRichText parses a RichText tree from JSON: a string, an array, or
|
||||||
|
// a typed object. Unknown object types that carry a text field are preserved
|
||||||
|
// as RichTextWrap so their nested text remains usable; unmodeled fields are
|
||||||
|
// discarded.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func UnmarshalRichText(data []byte) (RichText, error) {
|
||||||
|
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||||
|
return nil, fmt.Errorf("richtext: null is not a rich text value")
|
||||||
|
}
|
||||||
|
// 1. string
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
|
return RichTextPlain(s), nil
|
||||||
|
}
|
||||||
|
// 2. array
|
||||||
|
var raw []json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err == nil {
|
||||||
|
arr := make(RichTextArray, len(raw))
|
||||||
|
for i, it := range raw {
|
||||||
|
rt, err := UnmarshalRichText(it)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr[i] = rt
|
||||||
|
}
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
// 3. object -> dispatch on type, grabbing the raw text along the way
|
||||||
|
var head struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text json.RawMessage `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &head); err != nil {
|
||||||
|
return nil, fmt.Errorf("richtext: not a string, array or object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively parse the nested text, if any.
|
||||||
|
var inner RichText
|
||||||
|
if len(head.Text) > 0 {
|
||||||
|
var err error
|
||||||
|
if inner, err = UnmarshalRichText(head.Text); err != nil {
|
||||||
|
return nil, fmt.Errorf("richtext %q: bad text: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if richTextWrapTags[head.Type] {
|
||||||
|
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch head.Type {
|
||||||
|
case "url":
|
||||||
|
var v struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextURL{inner, v.URL}, nil
|
||||||
|
case "email_address":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"email_address"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextEmailAddress{inner, v.V}, nil
|
||||||
|
case "phone_number":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"phone_number"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextPhoneNumber{inner, v.V}, nil
|
||||||
|
case "bank_card_number":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"bank_card_number"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextBankCardNumber{inner, v.V}, nil
|
||||||
|
case "mention":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"username"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextMention{inner, v.V}, nil
|
||||||
|
case "hashtag":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"hashtag"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextHashtag{inner, v.V}, nil
|
||||||
|
case "cashtag":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"cashtag"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextCashtag{inner, v.V}, nil
|
||||||
|
case "bot_command":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"bot_command"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextBotCommand{inner, v.V}, nil
|
||||||
|
case "anchor_link":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"anchor_name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextAnchorLink{inner, v.V}, nil
|
||||||
|
case "reference":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextReference{inner, v.V}, nil
|
||||||
|
case "reference_link":
|
||||||
|
var v struct {
|
||||||
|
V string `json:"reference_name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextReferenceLink{inner, v.V}, nil
|
||||||
|
case "date_time":
|
||||||
|
var v struct {
|
||||||
|
UnixTime int64 `json:"unix_time"`
|
||||||
|
DateTimeFormat string `json:"date_time_format"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextDateTime{inner, v.UnixTime, v.DateTimeFormat}, nil
|
||||||
|
case "text_mention":
|
||||||
|
var v struct {
|
||||||
|
User User `json:"user"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextTextMention{inner, v.User}, nil
|
||||||
|
|
||||||
|
// --- leaves without text ---
|
||||||
|
case "custom_emoji":
|
||||||
|
var v struct {
|
||||||
|
ID string `json:"custom_emoji_id"`
|
||||||
|
Alt string `json:"alternative_text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextCustomEmoji{v.ID, v.Alt}, nil
|
||||||
|
case "mathematical_expression":
|
||||||
|
var v struct {
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextMathematicalExpression{v.Expression}, nil
|
||||||
|
case "anchor":
|
||||||
|
var v struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichTextAnchor{v.Name}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// forward-compat: keep an unknown tag with a text field as
|
||||||
|
// RichTextWrap; without text it is an error (the shape cannot be guessed).
|
||||||
|
if inner != nil {
|
||||||
|
return RichTextWrap{Tag: head.Type, Text: inner}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("richtext: unknown type %q", head.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalRichBlock parses a single RichBlock from JSON, dispatching on the
|
||||||
|
// type tag. Unknown types that carry a text field are decoded as RichBlockWrap
|
||||||
|
// so their nested text remains usable; unmodeled fields are discarded.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func UnmarshalRichBlock(data []byte) (RichBlock, error) {
|
||||||
|
var head struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text json.RawMessage `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &head); err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if richBlockWrapTags[head.Type] {
|
||||||
|
text, err := parseOptRichText(head.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockWrap{Tag: head.Type, Text: text}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch head.Type {
|
||||||
|
case "heading":
|
||||||
|
var v struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, err := parseOptRichText(head.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockSectionHeading{text, v.Size}, nil
|
||||||
|
|
||||||
|
case "pre":
|
||||||
|
var v struct {
|
||||||
|
Language string `json:"language"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, err := parseOptRichText(head.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockPreformatted{text, v.Language}, nil
|
||||||
|
|
||||||
|
case "blockquote":
|
||||||
|
var raw struct {
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
Credit json.RawMessage `json:"credit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
credit, err := parseOptRichText(raw.Credit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockQuotation{blocks, credit}, nil
|
||||||
|
|
||||||
|
case "pullquote":
|
||||||
|
var raw struct {
|
||||||
|
Credit json.RawMessage `json:"credit"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, err := parseOptRichText(head.Text)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: text: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
credit, err := parseOptRichText(raw.Credit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: credit: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockPullQuotation{text, credit}, nil
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
var v struct {
|
||||||
|
Items []RichBlockListItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockList{v.Items}, nil
|
||||||
|
|
||||||
|
case "collage":
|
||||||
|
var raw struct {
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockCollage{blocks, raw.Caption}, nil
|
||||||
|
|
||||||
|
case "slideshow":
|
||||||
|
var raw struct {
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockSlideshow{blocks, raw.Caption}, nil
|
||||||
|
|
||||||
|
case "details":
|
||||||
|
var raw struct {
|
||||||
|
Summary json.RawMessage `json:"summary"`
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
IsOpen bool `json:"is_open"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary, err := parseOptRichText(raw.Summary)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: summary: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockDetails{summary, blocks, raw.IsOpen}, nil
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
var raw struct {
|
||||||
|
Cells [][]RichBlockTableCell `json:"cells"`
|
||||||
|
IsBordered bool `json:"is_bordered"`
|
||||||
|
IsStriped bool `json:"is_striped"`
|
||||||
|
Caption json.RawMessage `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
caption, err := parseOptRichText(raw.Caption)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("richblock %q: caption: %w", head.Type, err)
|
||||||
|
}
|
||||||
|
return RichBlockTable{raw.Cells, raw.IsBordered, raw.IsStriped, caption}, nil
|
||||||
|
|
||||||
|
case "map":
|
||||||
|
var v struct {
|
||||||
|
Location Location `json:"location"`
|
||||||
|
Zoom int `json:"zoom"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockMap{v.Location, v.Zoom, v.Width, v.Height, v.Caption}, nil
|
||||||
|
|
||||||
|
case "photo":
|
||||||
|
var v struct {
|
||||||
|
Photo []PhotoSize `json:"photo"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockPhoto{v.Photo, v.HasSpoiler, v.Caption}, nil
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
var v struct {
|
||||||
|
Video Video `json:"video"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockVideo{v.Video, v.HasSpoiler, v.Caption}, nil
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
var v struct {
|
||||||
|
Audio Audio `json:"audio"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockAudio{v.Audio, v.Caption}, nil
|
||||||
|
|
||||||
|
case "animation":
|
||||||
|
var v struct {
|
||||||
|
Animation Animation `json:"animation"`
|
||||||
|
HasSpoiler bool `json:"has_spoiler"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockAnimation{v.Animation, v.HasSpoiler, v.Caption}, nil
|
||||||
|
|
||||||
|
case "voice_note":
|
||||||
|
var v struct {
|
||||||
|
VoiceNote Voice `json:"voice_note"`
|
||||||
|
Caption *RichBlockCaption `json:"caption"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockVoiceNote{v.VoiceNote, v.Caption}, nil
|
||||||
|
|
||||||
|
case "divider":
|
||||||
|
return RichBlockDivider{}, nil
|
||||||
|
|
||||||
|
case "mathematical_expression":
|
||||||
|
var v struct {
|
||||||
|
Expression string `json:"expression"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockMathematicalExpression{v.Expression}, nil
|
||||||
|
|
||||||
|
case "anchor":
|
||||||
|
var v struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RichBlockAnchor{v.Name}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
// forward-compat: unknown type with text -> RichBlockWrap, without text -> error.
|
||||||
|
if text, err := parseOptRichText(head.Text); err == nil && text != nil {
|
||||||
|
return RichBlockWrap{Tag: head.Type, Text: text}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("richblock: unknown type %q", head.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalRichMessage parses a root RichMessage from JSON.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func UnmarshalRichMessage(data []byte) (RichMessage, error) {
|
||||||
|
var raw struct {
|
||||||
|
Blocks json.RawMessage `json:"blocks"`
|
||||||
|
IsRTL bool `json:"is_rtl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return RichMessage{}, fmt.Errorf("richmessage: %w", err)
|
||||||
|
}
|
||||||
|
blocks, err := unmarshalRichBlocks(raw.Blocks)
|
||||||
|
if err != nil {
|
||||||
|
return RichMessage{}, err
|
||||||
|
}
|
||||||
|
return RichMessage{blocks, raw.IsRTL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func (m *RichMessage) UnmarshalJSON(data []byte) error {
|
||||||
|
parsed, err := UnmarshalRichMessage(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*m = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Optional RichText fields treat absent and null values as nil.
|
||||||
|
func parseOptRichText(raw json.RawMessage) (RichText, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return UnmarshalRichText(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalRichBlocks(raw json.RawMessage) ([]RichBlock, error) {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var raws []json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &raws); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks := make([]RichBlock, len(raws))
|
||||||
|
for i, r := range raws {
|
||||||
|
b, err := UnmarshalRichBlock(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks[i] = b
|
||||||
|
}
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package tgapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func roundtripRichBlock(t *testing.T, in RichBlock) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
out, err := UnmarshalRichBlock(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unmarshal %s: %v", b, err)
|
||||||
|
}
|
||||||
|
b2, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remarshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(b) != string(b2) {
|
||||||
|
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func par(s string) RichBlockWrap { return RichBlockWrap{"paragraph", RichTextPlain(s)} }
|
||||||
|
|
||||||
|
func TestRichBlockRoundtrip(t *testing.T) {
|
||||||
|
cases := []RichBlock{
|
||||||
|
// wrap blocks
|
||||||
|
par("Hello, world"),
|
||||||
|
RichBlockWrap{"footer", RichTextPlain("© 2024")},
|
||||||
|
RichBlockWrap{"thinking", RichTextPlain("Let me reason step by step.")},
|
||||||
|
|
||||||
|
// heading
|
||||||
|
RichBlockSectionHeading{RichTextWrap{"bold", RichTextPlain("Chapter 1")}, 1},
|
||||||
|
RichBlockSectionHeading{RichTextPlain("smallest"), 6},
|
||||||
|
|
||||||
|
// preformatted
|
||||||
|
RichBlockPreformatted{RichTextPlain(`fmt.Println("hi")`), "go"},
|
||||||
|
RichBlockPreformatted{Text: RichTextPlain("no language")},
|
||||||
|
|
||||||
|
// quotations
|
||||||
|
RichBlockQuotation{[]RichBlock{par("To be or not to be")}, RichTextPlain("Shakespeare")},
|
||||||
|
RichBlockQuotation{Blocks: []RichBlock{par("anonymous"), par("second block")}},
|
||||||
|
RichBlockPullQuotation{Text: RichTextPlain("Pull me")},
|
||||||
|
RichBlockPullQuotation{RichTextPlain("Wisdom"), RichTextWrap{"italic", RichTextPlain("someone")}},
|
||||||
|
|
||||||
|
// list: label is the ready-made marker, numbering lives on the items
|
||||||
|
RichBlockList{
|
||||||
|
Items: []RichBlockListItem{
|
||||||
|
{Label: "c.", Blocks: []RichBlock{par("item 3")}, Value: 3, Type: "a"},
|
||||||
|
{Label: "vii.", Blocks: []RichBlock{par("item 7")}, Value: 7, Type: "i"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RichBlockList{
|
||||||
|
Items: []RichBlockListItem{
|
||||||
|
{Label: "•", Blocks: []RichBlock{par("todo")}, HasCheckbox: true},
|
||||||
|
{Label: "•", Blocks: []RichBlock{par("done")}, HasCheckbox: true, IsChecked: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// collage and slideshow
|
||||||
|
RichBlockCollage{
|
||||||
|
Blocks: []RichBlock{RichBlockPhoto{Photo: []PhotoSize{{FileID: "abc123", Width: 100, Height: 100}}}},
|
||||||
|
Caption: &RichBlockCaption{Text: RichTextPlain("A photo")},
|
||||||
|
},
|
||||||
|
RichBlockSlideshow{
|
||||||
|
Blocks: []RichBlock{
|
||||||
|
RichBlockVideo{Video: Video{FileID: "vid1", Width: 640, Height: 480, Duration: 10}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// details
|
||||||
|
RichBlockDetails{
|
||||||
|
Summary: RichTextPlain("Spoiler"),
|
||||||
|
Blocks: []RichBlock{par("Hidden content")},
|
||||||
|
},
|
||||||
|
RichBlockDetails{
|
||||||
|
Summary: RichTextWrap{"bold", RichTextPlain("Open details")},
|
||||||
|
Blocks: []RichBlock{RichBlockDivider{}, par("content")},
|
||||||
|
IsOpen: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// table: text cells, headers, spans, alignment, invisible cell
|
||||||
|
RichBlockTable{
|
||||||
|
Cells: [][]RichBlockTableCell{
|
||||||
|
{
|
||||||
|
{Text: RichTextPlain("Name"), IsHeader: true, Align: "center"},
|
||||||
|
{Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Text: RichTextPlain("Alice"), ColSpan: 2},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{}, // invisible cell
|
||||||
|
{Text: RichTextPlain("42"), RowSpan: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IsBordered: true,
|
||||||
|
Caption: RichTextPlain("Results"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// map
|
||||||
|
RichBlockMap{
|
||||||
|
Location: Location{Latitude: 55.7558, Longitude: 37.6173},
|
||||||
|
Zoom: 13, Width: 800, Height: 400,
|
||||||
|
Caption: &RichBlockCaption{Text: RichTextPlain("Moscow"), Credit: RichTextPlain("OpenStreetMap")},
|
||||||
|
},
|
||||||
|
|
||||||
|
// media
|
||||||
|
RichBlockPhoto{
|
||||||
|
Photo: []PhotoSize{{FileID: "p1", Width: 1280, Height: 720}},
|
||||||
|
HasSpoiler: true,
|
||||||
|
Caption: &RichBlockCaption{Text: RichTextPlain("A cat"), Credit: RichTextWrap{"italic", RichTextPlain("photographer")}},
|
||||||
|
},
|
||||||
|
RichBlockVideo{Video: Video{FileID: "v1", Width: 1920, Height: 1080, Duration: 30}, HasSpoiler: true},
|
||||||
|
RichBlockAudio{
|
||||||
|
Audio: Audio{FileID: "a1", Duration: 60},
|
||||||
|
Caption: &RichBlockCaption{Text: RichTextPlain("Podcast ep. 1")},
|
||||||
|
},
|
||||||
|
RichBlockAnimation{Animation: Animation{FileID: "g1", Width: 320, Height: 240, Duration: 2}},
|
||||||
|
RichBlockVoiceNote{VoiceNote: Voice{FileID: "vn1", Duration: 5}},
|
||||||
|
|
||||||
|
// leaves
|
||||||
|
RichBlockDivider{},
|
||||||
|
RichBlockMathematicalExpression{Expression: "E = mc^2"},
|
||||||
|
RichBlockAnchor{Name: "section-2"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
roundtripRichBlock(t, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichMessageRoundtrip(t *testing.T) {
|
||||||
|
for _, msg := range []RichMessage{
|
||||||
|
{
|
||||||
|
Blocks: []RichBlock{
|
||||||
|
RichBlockSectionHeading{RichTextPlain("Title"), 1},
|
||||||
|
RichBlockWrap{"paragraph", RichTextArray{RichTextPlain("Some "), RichTextWrap{"bold", RichTextPlain("bold")}, RichTextPlain(" text")}},
|
||||||
|
RichBlockDivider{},
|
||||||
|
RichBlockList{
|
||||||
|
Items: []RichBlockListItem{
|
||||||
|
{Label: "1.", Blocks: []RichBlock{par("First")}, Value: 1, Type: "1"},
|
||||||
|
{Label: "2.", Blocks: []RichBlock{par("Second")}, Value: 2, Type: "1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RichBlockPhoto{
|
||||||
|
Photo: []PhotoSize{{FileID: "img1", Width: 10, Height: 10}},
|
||||||
|
Caption: &RichBlockCaption{Text: RichTextPlain("Fig. 1")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Blocks: []RichBlock{par("שלום")},
|
||||||
|
IsRTL: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
b, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var out RichMessage
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
b2, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remarshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(b) != string(b2) {
|
||||||
|
t.Fatalf("not stable:\n %s\n %s", b, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlockTags(t *testing.T) {
|
||||||
|
// tags per spec: heading, pre, blockquote, pullquote
|
||||||
|
cases := map[string]RichBlock{
|
||||||
|
"heading": RichBlockSectionHeading{RichTextPlain("h"), 2},
|
||||||
|
"pre": RichBlockPreformatted{Text: RichTextPlain("x")},
|
||||||
|
"blockquote": RichBlockQuotation{Blocks: []RichBlock{par("q")}},
|
||||||
|
"pullquote": RichBlockPullQuotation{Text: RichTextPlain("p")},
|
||||||
|
}
|
||||||
|
for want, block := range cases {
|
||||||
|
b, _ := json.Marshal(block)
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
if m["type"] != want {
|
||||||
|
t.Fatalf("expected type %q, got %s", want, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlockOptionalFieldsOmitted(t *testing.T) {
|
||||||
|
// nil credit/caption and false flags must not appear in the JSON
|
||||||
|
for _, c := range []struct {
|
||||||
|
block RichBlock
|
||||||
|
bad []string
|
||||||
|
}{
|
||||||
|
{RichBlockQuotation{Blocks: []RichBlock{par("q")}}, []string{"credit"}},
|
||||||
|
{RichBlockPullQuotation{Text: RichTextPlain("p")}, []string{"credit"}},
|
||||||
|
{RichBlockPhoto{Photo: []PhotoSize{{FileID: "p"}}}, []string{"caption", "has_spoiler"}},
|
||||||
|
{RichBlockTable{Cells: [][]RichBlockTableCell{}}, []string{"caption", "is_bordered", "is_striped"}},
|
||||||
|
{RichBlockDetails{Summary: RichTextPlain("s")}, []string{"is_open"}},
|
||||||
|
} {
|
||||||
|
b, _ := json.Marshal(c.block)
|
||||||
|
for _, key := range c.bad {
|
||||||
|
if strings.Contains(string(b), `"`+key+`"`) {
|
||||||
|
t.Fatalf("%T: %q must be omitted: %s", c.block, key, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// same for RichMessage.is_rtl
|
||||||
|
b, _ := json.Marshal(RichMessage{Blocks: []RichBlock{par("x")}})
|
||||||
|
if strings.Contains(string(b), "is_rtl") {
|
||||||
|
t.Fatalf("is_rtl must be omitted: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlockDividerHasNoContent(t *testing.T) {
|
||||||
|
b, _ := json.Marshal(RichBlockDivider{})
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(b, &m)
|
||||||
|
if len(m) != 1 {
|
||||||
|
t.Fatalf("divider must only have type field: %s", b)
|
||||||
|
}
|
||||||
|
if m["type"] != "divider" {
|
||||||
|
t.Fatalf("unexpected type: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlockUnknownTypeWithTextIsForwardCompat(t *testing.T) {
|
||||||
|
raw := []byte(`{"type":"future_tag","text":"hello"}`)
|
||||||
|
b, err := UnmarshalRichBlock(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forward-compat failed: %v", err)
|
||||||
|
}
|
||||||
|
w, ok := b.(RichBlockWrap)
|
||||||
|
if !ok || w.Tag != "future_tag" {
|
||||||
|
t.Fatalf("expected RichBlockWrap{future_tag}, got %T", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRichBlockUnknownTypeWithoutTextIsError(t *testing.T) {
|
||||||
|
raw := []byte(`{"type":"mystery_leaf","value":42}`)
|
||||||
|
_, err := UnmarshalRichBlock(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown type without text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalRichBlockRejectsMalformedFields(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
`{"type":"heading","size":"large","text":"hello"}`,
|
||||||
|
`{"type":"blockquote","blocks":[],"credit":{"type":"date_time","text":"now","unix_time":"soon"}}`,
|
||||||
|
`{"type":"table","cells":[],"caption":{"type":"date_time","text":"now","unix_time":"soon"}}`,
|
||||||
|
}
|
||||||
|
for _, raw := range tests {
|
||||||
|
t.Run(raw, func(t *testing.T) {
|
||||||
|
if _, err := UnmarshalRichBlock([]byte(raw)); err == nil {
|
||||||
|
t.Fatal("expected malformed rich block to be rejected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ type SendSticker struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Sticker string `json:"sticker"`
|
||||||
Emoji string `json:"emoji,omitempty"`
|
Emoji string `json:"emoji,omitempty"`
|
||||||
|
|||||||
+51
-4
@@ -63,6 +63,11 @@ const (
|
|||||||
|
|
||||||
// UpdateTypeGuestMessage is a guest message update.
|
// UpdateTypeGuestMessage is a guest message update.
|
||||||
UpdateTypeGuestMessage UpdateType = "guest_message"
|
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.
|
// 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
|
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` // Since: Bot API 7.0
|
||||||
|
|
||||||
ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"` // Since: Bot API 9.6
|
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.
|
// 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
|
u.Type = UpdateTypeRemovedChatBoost
|
||||||
case u.ManagedBot != nil:
|
case u.ManagedBot != nil:
|
||||||
u.Type = UpdateTypeManagedBot
|
u.Type = UpdateTypeManagedBot
|
||||||
|
case u.Subscription != nil:
|
||||||
|
u.Type = UpdateTypeSubscription
|
||||||
default:
|
default:
|
||||||
u.Type = UpdateTypeUnknown
|
u.Type = UpdateTypeUnknown
|
||||||
}
|
}
|
||||||
@@ -255,6 +264,11 @@ type ChatJoinRequest struct {
|
|||||||
Date int64 `json:"date"`
|
Date int64 `json:"date"`
|
||||||
Bio *string `json:"bio,omitempty"`
|
Bio *string `json:"bio,omitempty"`
|
||||||
InviteLink *ChatInviteLink `json:"invite_link,omitempty"`
|
InviteLink *ChatInviteLink `json:"invite_link,omitempty"`
|
||||||
|
|
||||||
|
// QueryID identifies the join request query; present only for bots
|
||||||
|
// assigned to process join requests. When set, the bot must call
|
||||||
|
// SendChatJoinRequestWebApp or AnswerChatJoinRequestQuery within 10 seconds.
|
||||||
|
QueryID *string `json:"query_id,omitempty"` // Since: Bot API 10.1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location represents a point on the map.
|
// Location represents a point on the map.
|
||||||
@@ -552,8 +566,11 @@ type WriteAccessAllowed struct {
|
|||||||
type BackgroundFillType string
|
type BackgroundFillType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BackgroundFillSolidType BackgroundFillType = "solid"
|
// BackgroundFillSolidType identifies a solid fill.
|
||||||
BackgroundFillGradientType BackgroundFillType = "gradient"
|
BackgroundFillSolidType BackgroundFillType = "solid"
|
||||||
|
// BackgroundFillGradientType identifies a two-color gradient.
|
||||||
|
BackgroundFillGradientType BackgroundFillType = "gradient"
|
||||||
|
// BackgroundFillFreeformGradientType identifies a freeform gradient.
|
||||||
BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient"
|
BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -576,9 +593,13 @@ type BackgroundFill struct {
|
|||||||
type BackgroundTypeType string
|
type BackgroundTypeType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BackgroundTypeFillType BackgroundTypeType = "fill"
|
// BackgroundTypeFillType identifies a generated fill.
|
||||||
|
BackgroundTypeFillType BackgroundTypeType = "fill"
|
||||||
|
// BackgroundTypeWallpaperType identifies a wallpaper.
|
||||||
BackgroundTypeWallpaperType BackgroundTypeType = "wallpaper"
|
BackgroundTypeWallpaperType BackgroundTypeType = "wallpaper"
|
||||||
BackgroundTypePatternType BackgroundTypeType = "pattern"
|
// BackgroundTypePatternType identifies a pattern.
|
||||||
|
BackgroundTypePatternType BackgroundTypeType = "pattern"
|
||||||
|
// BackgroundTypeChatThemeType identifies a chat theme.
|
||||||
BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme"
|
BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -599,3 +620,29 @@ type BackgroundType struct {
|
|||||||
|
|
||||||
ThemeName string `json:"theme_name,omitempty"`
|
ThemeName string `json:"theme_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BotSubscriptionState identifies the state of a user's subscription to the bot.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type BotSubscriptionState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BotSubscriptionCanceledState indicates that the user canceled the subscription.
|
||||||
|
BotSubscriptionCanceledState BotSubscriptionState = "canceled"
|
||||||
|
// BotSubscriptionActiveState indicates that the user re-enabled the subscription.
|
||||||
|
BotSubscriptionActiveState BotSubscriptionState = "active"
|
||||||
|
// BotSubscriptionFailedState indicates that subscription payment failed.
|
||||||
|
BotSubscriptionFailedState BotSubscriptionState = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotSubscriptionUpdated describes a change to a user's payment subscription to the bot.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type BotSubscriptionUpdated struct {
|
||||||
|
// User contains the user associated with the value.
|
||||||
|
User User `json:"user"`
|
||||||
|
// InvoicePayload contains the bot-defined subscription invoice payload.
|
||||||
|
InvoicePayload string `json:"invoice_payload"`
|
||||||
|
// State is the new subscription state.
|
||||||
|
State BotSubscriptionState `json:"state"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
|||||||
}`,
|
}`,
|
||||||
want: UpdateTypeManagedBot,
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -89,6 +101,9 @@ func TestUpdateUnmarshalSetsType(t *testing.T) {
|
|||||||
if tt.want == UpdateTypeManagedBot && update.ManagedBot.Bot.ID != 12 {
|
if tt.want == UpdateTypeManagedBot && update.ManagedBot.Bot.ID != 12 {
|
||||||
t.Fatalf("unexpected managed bot id: got %d want %d", update.ManagedBot.Bot.ID, 12)
|
t.Fatalf("unexpected managed bot id: got %d want %d", update.ManagedBot.Bot.ID, 12)
|
||||||
}
|
}
|
||||||
|
if tt.want == UpdateTypeSubscription && update.Subscription.User.ID != 13 {
|
||||||
|
t.Fatalf("unexpected subscription user id: got %d want %d", update.Subscription.User.ID, 13)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -61,6 +61,15 @@ func (f UploaderFile) SetType(t UploaderFileType) UploaderFile {
|
|||||||
return f
|
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.
|
// Uploader is a Telegram Bot API client specialized for multipart file uploads.
|
||||||
//
|
//
|
||||||
// Use Uploader methods when you need to upload binary files directly
|
// 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 {
|
if err != nil {
|
||||||
return zero, err
|
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)
|
response, err := parseBody[R](body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,7 +176,7 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
|
|||||||
after := *response.Parameters.RetryAfter
|
after := *response.Parameters.RetryAfter
|
||||||
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID)
|
up.logger.Warnf("Rate limited, retry after %d seconds (chat: %d)", after, r.chatID)
|
||||||
if up.api.Limiter != nil {
|
if up.api.Limiter != nil {
|
||||||
if r.chatID > 0 {
|
if r.chatID != 0 {
|
||||||
up.api.Limiter.SetChatLock(r.chatID, after)
|
up.api.Limiter.SetChatLock(r.chatID, after)
|
||||||
} else {
|
} else {
|
||||||
up.api.Limiter.SetGlobalLock(after)
|
up.api.Limiter.SetGlobalLock(after)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tgapi
|
package tgapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"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) {
|
func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) {
|
||||||
const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
|
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) {
|
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
|
||||||
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,3 +356,48 @@ func readMultipartRequest(req *http.Request) (map[string]string, string, []byte,
|
|||||||
fields[part.FormName()] = string(data)
|
fields[part.FormName()] = string(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type multipartFile struct {
|
||||||
|
name string
|
||||||
|
data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMultipartFiles(req *http.Request) (map[string]multipartFile, error) {
|
||||||
|
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reader := multipart.NewReader(req.Body, params["boundary"])
|
||||||
|
files := make(map[string]multipartFile)
|
||||||
|
for {
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if part.FileName() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files[part.FormName()] = multipartFile{name: part.FileName(), data: string(data)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMultipartFile(t *testing.T, files map[string]multipartFile, field, name, data string) {
|
||||||
|
t.Helper()
|
||||||
|
file, ok := files[field]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("multipart field %q is missing", field)
|
||||||
|
}
|
||||||
|
if file.name != name {
|
||||||
|
t.Errorf("multipart field %q filename = %q, want %q", field, file.name, name)
|
||||||
|
}
|
||||||
|
if file.data != data {
|
||||||
|
t.Errorf("multipart field %q data = %q, want %q", field, file.data, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,45 @@ package tgapi
|
|||||||
|
|
||||||
import "context"
|
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.
|
// UploadPhoto holds parameters for uploading a photo using the Uploader.
|
||||||
// Since: Bot API 1.0
|
// Since: Bot API 1.0
|
||||||
// See https://core.telegram.org/bots/api#sendphoto
|
// See https://core.telegram.org/bots/api#sendphoto
|
||||||
@@ -10,6 +49,10 @@ type UploadPhoto struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
@@ -53,6 +96,10 @@ type UploadAudio struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
@@ -98,6 +145,10 @@ type UploadDocument struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
@@ -140,6 +191,10 @@ type UploadVideo struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Duration int `json:"duration,omitempty"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
@@ -189,6 +244,10 @@ type UploadAnimation struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Duration int `json:"duration,omitempty"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
@@ -236,6 +295,10 @@ type UploadVoice struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
@@ -278,6 +341,10 @@ type UploadVideoNote struct {
|
|||||||
ChatID int64 `json:"chat_id"`
|
ChatID int64 `json:"chat_id"`
|
||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Duration int `json:"duration,omitempty"`
|
||||||
Length int `json:"length,omitempty"`
|
Length int `json:"length,omitempty"`
|
||||||
@@ -375,6 +442,10 @@ type UploadLivePhoto struct {
|
|||||||
MessageThreadID int `json:"message_thread_id,omitempty"`
|
MessageThreadID int `json:"message_thread_id,omitempty"`
|
||||||
DirectMessagesTopicID int `json:"direct_messages_topic_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"`
|
Caption string `json:"caption,omitempty"`
|
||||||
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
ParseMode ParseMode `json:"parse_mode,omitempty"`
|
||||||
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
|
||||||
@@ -391,20 +462,30 @@ type UploadLivePhoto struct {
|
|||||||
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
|
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
|
// Since: Bot API 10.0
|
||||||
// file is the live photo file to upload.
|
|
||||||
// See https://core.telegram.org/bots/api#sendlivephoto
|
// See https://core.telegram.org/bots/api#sendlivephoto
|
||||||
func (u *Uploader) SendLivePhoto(params UploadLivePhoto, file UploaderFile) (Message, error) {
|
func (u *Uploader) SendLivePhoto(params UploadLivePhoto, livePhoto, photo UploaderFile) (Message, error) {
|
||||||
req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType))
|
req := NewUploaderRequestWithChatID[Message](
|
||||||
|
"sendLivePhoto", params, params.ChatID,
|
||||||
|
livePhoto.SetType(UploaderLivePhotoType),
|
||||||
|
photo.SetType(UploaderPhotoType),
|
||||||
|
)
|
||||||
return req.Do(u)
|
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
|
// 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
|
// See https://core.telegram.org/bots/api#sendlivephoto
|
||||||
func (u *Uploader) SendLivePhotoWithContext(ctx context.Context, params UploadLivePhoto, file UploaderFile) (Message, error) {
|
func (u *Uploader) SendLivePhotoWithContext(
|
||||||
req := NewUploaderRequestWithChatID[Message]("sendLivePhoto", params, params.ChatID, file.SetType(UploaderLivePhotoType))
|
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)
|
return req.DoWithContext(ctx, u)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type User struct {
|
|||||||
AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` // Since: Bot API 9.4
|
AllowsUsersToCreateTopics *bool `json:"allows_users_to_create_topics,omitempty"` // Since: Bot API 9.4
|
||||||
CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6
|
CanManageBots *bool `json:"can_manage_bots,omitempty"` // Since: Bot API 9.6
|
||||||
SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0
|
SupportsGuestQueries *bool `json:"supports_guest_queries,omitempty"` // Since: Bot API 10.0
|
||||||
|
|
||||||
|
// SupportsJoinRequestQueries reports that the bot supports join request
|
||||||
|
// queries and can be assigned to process them. Returned only in getMe.
|
||||||
|
SupportsJoinRequestQueries *bool `json:"supports_join_request_queries,omitempty"` // Since: Bot API 10.1
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserProfilePhotos represents a user's profile photos.
|
// UserProfilePhotos represents a user's profile photos.
|
||||||
|
|||||||
+12
-41
@@ -2,7 +2,6 @@ package tgfmt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HTML is an escaped Telegram HTML fragment.
|
// HTML is an escaped Telegram HTML fragment.
|
||||||
@@ -11,43 +10,25 @@ import (
|
|||||||
type HTML string
|
type HTML string
|
||||||
|
|
||||||
// EscapeHTML escapes special characters for Telegram HTML parse mode.
|
// EscapeHTML escapes special characters for Telegram HTML parse mode.
|
||||||
func EscapeHTML(s string) HTML {
|
func EscapeHTML(s string) HTML { return HTML(escapeHTML(s)) }
|
||||||
s = strings.ReplaceAll(s, "&", "&")
|
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
|
||||||
s = strings.ReplaceAll(s, ">", ">")
|
|
||||||
s = strings.ReplaceAll(s, `"`, """)
|
|
||||||
return HTML(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bold returns h wrapped as bold Telegram HTML text.
|
// Bold returns h wrapped as bold Telegram HTML text.
|
||||||
func (h HTML) Bold() HTML {
|
func (h HTML) Bold() HTML { return "<b>" + h + "</b>" }
|
||||||
return "<b>" + h + "</b>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Italic returns h wrapped as italic Telegram HTML text.
|
// Italic returns h wrapped as italic Telegram HTML text.
|
||||||
func (h HTML) Italic() HTML {
|
func (h HTML) Italic() HTML { return "<i>" + h + "</i>" }
|
||||||
return "<i>" + h + "</i>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Underline returns h wrapped as underlined Telegram HTML text.
|
// Underline returns h wrapped as underlined Telegram HTML text.
|
||||||
func (h HTML) Underline() HTML {
|
func (h HTML) Underline() HTML { return "<u>" + h + "</u>" }
|
||||||
return "<u>" + h + "</u>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
|
// Strikethrough returns h wrapped as strikethrough Telegram HTML text.
|
||||||
func (h HTML) Strikethrough() HTML {
|
func (h HTML) Strikethrough() HTML { return "<s>" + h + "</s>" }
|
||||||
return "<s>" + h + "</s>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spoiler returns h wrapped as spoiler Telegram HTML text.
|
// Spoiler returns h wrapped as spoiler Telegram HTML text.
|
||||||
func (h HTML) Spoiler() HTML {
|
func (h HTML) Spoiler() HTML { return "<tg-spoiler>" + h + "</tg-spoiler>" }
|
||||||
return "<tg-spoiler>" + h + "</tg-spoiler>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link returns h as a Telegram HTML text link.
|
// Link returns h as a Telegram HTML text link.
|
||||||
func (h HTML) Link(url string) HTML {
|
func (h HTML) Link(url string) HTML { return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>" }
|
||||||
return `<a href="` + escapeHTMLAttr(url) + `">` + h + "</a>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mention returns h as a Telegram HTML user mention.
|
// Mention returns h as a Telegram HTML user mention.
|
||||||
func (h HTML) Mention(userID int64) HTML {
|
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.
|
// InlineCode returns h wrapped as inline code Telegram HTML text.
|
||||||
func (h HTML) InlineCode() HTML {
|
func (h HTML) InlineCode() HTML { return "<code>" + h + "</code>" }
|
||||||
return "<code>" + h + "</code>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCode returns h wrapped as a Telegram HTML code block.
|
// BlockCode returns h wrapped as a Telegram HTML code block.
|
||||||
func (h HTML) BlockCode() HTML {
|
func (h HTML) BlockCode() HTML { return "<pre>" + h + "</pre>" }
|
||||||
return "<pre>" + h + "</pre>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
|
// BlockCodeLanguage returns h wrapped as a Telegram HTML code block with language.
|
||||||
func (h HTML) BlockCodeLanguage(lang string) HTML {
|
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.
|
// Quote returns h as a Telegram HTML blockquote.
|
||||||
func (h HTML) Quote() HTML {
|
func (h HTML) Quote() HTML { return "<blockquote>" + h + "</blockquote>" }
|
||||||
return "<blockquote>" + h + "</blockquote>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
|
// QuoteExpandable returns h as a Telegram HTML expandable blockquote.
|
||||||
func (h HTML) QuoteExpandable() HTML {
|
func (h HTML) QuoteExpandable() HTML { return "<blockquote expandable>" + h + "</blockquote>" }
|
||||||
return "<blockquote expandable>" + h + "</blockquote>"
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeHTMLAttr(s string) HTML {
|
func escapeHTMLAttr(s string) HTML { return HTML(escapeHTML(s)) }
|
||||||
return EscapeHTML(s)
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-22
@@ -2,7 +2,6 @@ package tgfmt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Markdown is an escaped legacy Telegram Markdown fragment.
|
// Markdown is an escaped legacy Telegram Markdown fragment.
|
||||||
@@ -13,27 +12,16 @@ type Markdown string
|
|||||||
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
|
// EscapeMarkdown escapes special characters for legacy Telegram Markdown.
|
||||||
//
|
//
|
||||||
// Deprecated: Use EscapeMarkdownV2 instead.
|
// Deprecated: Use EscapeMarkdownV2 instead.
|
||||||
func EscapeMarkdown(s string) Markdown {
|
func EscapeMarkdown(s string) Markdown { return Markdown(escapeMD(s)) }
|
||||||
s = strings.ReplaceAll(s, "_", `\_`)
|
|
||||||
s = strings.ReplaceAll(s, "*", `\*`)
|
|
||||||
s = strings.ReplaceAll(s, "[", `\[`)
|
|
||||||
return Markdown(strings.ReplaceAll(s, "`", "\\`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bold returns s wrapped as bold legacy Telegram Markdown text.
|
// Bold returns s wrapped as bold legacy Telegram Markdown text.
|
||||||
func (s Markdown) Bold() Markdown {
|
func (s Markdown) Bold() Markdown { return "*" + s + "*" }
|
||||||
return "*" + s + "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Italic returns s wrapped as italic legacy Telegram Markdown text.
|
// Italic returns s wrapped as italic legacy Telegram Markdown text.
|
||||||
func (s Markdown) Italic() Markdown {
|
func (s Markdown) Italic() Markdown { return "_" + s + "_" }
|
||||||
return "_" + s + "_"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link returns s as a legacy Telegram Markdown text link.
|
// Link returns s as a legacy Telegram Markdown text link.
|
||||||
func (s Markdown) Link(url string) Markdown {
|
func (s Markdown) Link(url string) Markdown { return "[" + s + "](" + Markdown(url) + ")" }
|
||||||
return "[" + s + "](" + Markdown(url) + ")"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mention returns s as a legacy Telegram Markdown user mention.
|
// Mention returns s as a legacy Telegram Markdown user mention.
|
||||||
func (s Markdown) Mention(userID int64) Markdown {
|
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.
|
// InlineCode returns s wrapped as inline code legacy Telegram Markdown text.
|
||||||
func (s Markdown) InlineCode() Markdown {
|
func (s Markdown) InlineCode() Markdown { return "`" + s + "`" }
|
||||||
return "`" + s + "`"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
|
// BlockCode returns s wrapped as a legacy Telegram Markdown code block.
|
||||||
func (s Markdown) BlockCode() Markdown {
|
func (s Markdown) BlockCode() Markdown { return "```\n" + s + "\n```" }
|
||||||
return "```\n" + s + "\n```"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
|
// BlockCodeLanguage returns s wrapped as a legacy Telegram Markdown code block.
|
||||||
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
|
func (s Markdown) BlockCodeLanguage(lang string) Markdown {
|
||||||
|
|||||||
+9
-31
@@ -12,38 +12,22 @@ type MarkdownV2 string
|
|||||||
|
|
||||||
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
|
// EscapeMarkdownV2 escapes special characters for Telegram MarkdownV2.
|
||||||
// https://core.telegram.org/bots/api#markdownv2-style
|
// https://core.telegram.org/bots/api#markdownv2-style
|
||||||
func EscapeMarkdownV2(s string) MarkdownV2 {
|
func EscapeMarkdownV2(s string) MarkdownV2 { return MarkdownV2(escapeMDv2(s)) }
|
||||||
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
|
||||||
for _, symbol := range symbols {
|
|
||||||
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
|
||||||
}
|
|
||||||
return MarkdownV2(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
|
// Bold returns s wrapped as bold Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) Bold() MarkdownV2 {
|
func (s MarkdownV2) Bold() MarkdownV2 { return "*" + s + "*" }
|
||||||
return "*" + s + "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
|
// Italic returns s wrapped as italic Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) Italic() MarkdownV2 {
|
func (s MarkdownV2) Italic() MarkdownV2 { return "_" + s + "_" }
|
||||||
return "_" + s + "_"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
|
// Underline returns s wrapped as underlined Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) Underline() MarkdownV2 {
|
func (s MarkdownV2) Underline() MarkdownV2 { return "__" + s + "__" }
|
||||||
return "__" + s + "__"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
|
// Strikethrough returns s wrapped as strikethrough Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) Strikethrough() MarkdownV2 {
|
func (s MarkdownV2) Strikethrough() MarkdownV2 { return "~" + s + "~" }
|
||||||
return "~" + s + "~"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
|
// Spoiler returns s wrapped as spoiler Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) Spoiler() MarkdownV2 {
|
func (s MarkdownV2) Spoiler() MarkdownV2 { return "||" + s + "||" }
|
||||||
return "||" + s + "||"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link returns s as a Telegram MarkdownV2 text link.
|
// Link returns s as a Telegram MarkdownV2 text link.
|
||||||
func (s MarkdownV2) Link(url string) MarkdownV2 {
|
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.
|
// InlineCode returns s wrapped as inline code Telegram MarkdownV2 text.
|
||||||
func (s MarkdownV2) InlineCode() MarkdownV2 {
|
func (s MarkdownV2) InlineCode() MarkdownV2 { return "`" + s + "`" }
|
||||||
return "`" + s + "`"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
|
// BlockCode returns s wrapped as a Telegram MarkdownV2 code block.
|
||||||
func (s MarkdownV2) BlockCode() MarkdownV2 {
|
func (s MarkdownV2) BlockCode() MarkdownV2 { return "```\n" + s + "\n```" }
|
||||||
return "```\n" + s + "\n```"
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
|
// BlockCodeLanguage returns s wrapped as a Telegram MarkdownV2 code block with language.
|
||||||
func (s MarkdownV2) BlockCodeLanguage(lang string) MarkdownV2 {
|
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.
|
// QuoteExpandable returns s as a Telegram MarkdownV2 expandable blockquote.
|
||||||
func (s MarkdownV2) QuoteExpandable() MarkdownV2 {
|
func (s MarkdownV2) QuoteExpandable() MarkdownV2 { return "**>" + s }
|
||||||
return "**>" + s
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
|
func escapeMarkdownV2LinkDestination(s string) MarkdownV2 {
|
||||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,31 @@
|
|||||||
package tgfmt
|
package tgfmt
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMD(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "_", `\_`)
|
||||||
|
s = strings.ReplaceAll(s, "*", `\*`)
|
||||||
|
s = strings.ReplaceAll(s, "[", `\[`)
|
||||||
|
s = strings.ReplaceAll(s, "`", "\\`")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func escapeMDv2(s string) string {
|
||||||
|
symbols := []string{"\\", "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
s = strings.ReplaceAll(s, symbol, "\\"+symbol)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments.
|
||||||
func EscapePunctuation(s string) string {
|
func EscapePunctuation(s string) string {
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxRichTextChars = 32768
|
||||||
|
maxRichBlocks = 500
|
||||||
|
maxRichDepth = 16
|
||||||
|
maxRichMedia = 50
|
||||||
|
maxTableColumns = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Text creates a plain rich-text node.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Text(s string) tgapi.RichText { return tgapi.RichTextPlain(s) }
|
||||||
|
|
||||||
|
// Bold applies bold formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Bold(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "bold", Text: t} }
|
||||||
|
|
||||||
|
// Italic applies italic formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Italic(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "italic", Text: t} }
|
||||||
|
|
||||||
|
// Underline applies underline formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Underline(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "underline", Text: t} }
|
||||||
|
|
||||||
|
// Strikethrough applies strikethrough formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Strikethrough(t tgapi.RichText) tgapi.RichText {
|
||||||
|
return tgapi.RichTextWrap{Tag: "strikethrough", Text: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoiler hides t behind a spoiler.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Spoiler(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "spoiler", Text: t} }
|
||||||
|
|
||||||
|
// DateTime associates t with ts using Telegram's default date-time format.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func DateTime(t tgapi.RichText, ts time.Time) tgapi.RichText {
|
||||||
|
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateTimeWithFormat associates t with ts using format.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func DateTimeWithFormat(t tgapi.RichText, ts time.Time, format string) tgapi.RichText {
|
||||||
|
return tgapi.RichTextDateTime{Text: t, UnixTime: ts.Unix(), DateTimeFormat: format}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMention mentions u with the display text t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func TextMention(t tgapi.RichText, u tgapi.User) tgapi.RichText {
|
||||||
|
return tgapi.RichTextTextMention{Text: t, User: u}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscript applies subscript formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Subscript(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "subscript", Text: t} }
|
||||||
|
|
||||||
|
// Superscript applies superscript formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Superscript(t tgapi.RichText) tgapi.RichText {
|
||||||
|
return tgapi.RichTextWrap{Tag: "superscript", Text: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marked highlights t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Marked(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "marked", Text: t} }
|
||||||
|
|
||||||
|
// Code applies monospaced formatting to t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Code(t tgapi.RichText) tgapi.RichText { return tgapi.RichTextWrap{Tag: "code", Text: t} }
|
||||||
|
|
||||||
|
// Emoji creates a custom emoji with emojiID and alternative text t.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Emoji(t, emojiID string) tgapi.RichText {
|
||||||
|
return tgapi.RichTextCustomEmoji{CustomEmojiID: emojiID, AlternativeText: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MathExpression creates an inline LaTeX expression.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func MathExpression(exp string) tgapi.RichTextMathematicalExpression {
|
||||||
|
return tgapi.RichTextMathematicalExpression{Expression: exp}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL links t to url.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func URL(t tgapi.RichText, url string) tgapi.RichTextURL { return tgapi.RichTextURL{Text: t, URL: url} }
|
||||||
|
|
||||||
|
// Email marks t as an email address.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Email(t tgapi.RichText, email string) tgapi.RichTextEmailAddress {
|
||||||
|
return tgapi.RichTextEmailAddress{Text: t, EmailAddress: email}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone marks t as a phone number.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Phone(t tgapi.RichText, phone string) tgapi.RichTextPhoneNumber {
|
||||||
|
return tgapi.RichTextPhoneNumber{Text: t, PhoneNumber: phone}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BankCardNumber marks t as a bank card number.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func BankCardNumber(t tgapi.RichText, number string) tgapi.RichTextBankCardNumber {
|
||||||
|
return tgapi.RichTextBankCardNumber{Text: t, BankCardNumber: number}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mention marks t as a mention of username.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Mention(t tgapi.RichText, username string) tgapi.RichTextMention {
|
||||||
|
return tgapi.RichTextMention{Text: t, Username: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashtag marks t as hashtag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Hashtag(t tgapi.RichText, hashtag string) tgapi.RichTextHashtag {
|
||||||
|
return tgapi.RichTextHashtag{Text: t, Hashtag: hashtag}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cashtag marks t as cashtag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Cashtag(t tgapi.RichText, cashtag string) tgapi.RichTextCashtag {
|
||||||
|
return tgapi.RichTextCashtag{Text: t, Cashtag: cashtag}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotCommand marks t as command.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func BotCommand(t tgapi.RichText, command string) tgapi.RichTextBotCommand {
|
||||||
|
return tgapi.RichTextBotCommand{Text: t, BotCommand: command}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextAnchor creates an inline anchor named name.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func TextAnchor(name string) tgapi.RichTextAnchor {
|
||||||
|
return tgapi.RichTextAnchor{Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnchorLink links t to the anchor named name.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func AnchorLink(t tgapi.RichText, name string) tgapi.RichTextAnchorLink {
|
||||||
|
return tgapi.RichTextAnchorLink{Text: t, AnchorName: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference defines t as a named reference target.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Reference(t tgapi.RichText, name string) tgapi.RichTextReference {
|
||||||
|
return tgapi.RichTextReference{Text: t, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReferenceLink links t to the named reference name.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func ReferenceLink(t tgapi.RichText, name string) tgapi.RichTextReferenceLink {
|
||||||
|
return tgapi.RichTextReferenceLink{Text: t, ReferenceName: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concat concatenates rich-text nodes.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.1
|
||||||
|
func Concat(items ...tgapi.RichText) tgapi.RichText { return tgapi.RichTextArray(items) }
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text tgapi.RichText
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"plain", Text("<text&>"), "<text&>"},
|
||||||
|
{"array", Concat(Bold(Text("bold")), Text(" plain")), "<b>bold</b> plain"},
|
||||||
|
{"spoiler", Spoiler(Text("secret")), "<tg-spoiler>secret</tg-spoiler>"},
|
||||||
|
{"date time", DateTimeWithFormat(Text("tomorrow"), time.Unix(1, 0), `w"DT`), `<tg-time unix="1" format="w"DT">tomorrow</tg-time>`},
|
||||||
|
{"custom emoji", Emoji("🙂", `id"`), `<tg-emoji emoji-id="id"">🙂</tg-emoji>`},
|
||||||
|
{"formula", MathExpression("x < y"), "<tg-math>x < y</tg-math>"},
|
||||||
|
{"automatic entity", Hashtag(Text("#go"), "go"), "#go"},
|
||||||
|
{"reference", Reference(Text("note"), "note-1"), `<tg-reference name="note-1">note</tg-reference>`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := renderText(tt.text, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("renderText() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTextRejectsUnknownWrapper(t *testing.T) {
|
||||||
|
_, err := renderText(tgapi.RichTextWrap{Tag: "unknown", Text: Text("text")}, 0)
|
||||||
|
if !errors.Is(err, ErrRichUnknownTag) {
|
||||||
|
t.Fatalf("renderText() error = %v, want %v", err, ErrRichUnknownTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTextNestingLimit(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
depth int
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"maximum", maxRichDepth, nil},
|
||||||
|
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
text := tgapi.RichText(Text("text"))
|
||||||
|
for range tt.depth {
|
||||||
|
text = Bold(text)
|
||||||
|
}
|
||||||
|
_, err := renderText(text, 0)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("renderText() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderBlockNestingLimit(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
depth int
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"maximum", maxRichDepth, nil},
|
||||||
|
{"too deep", maxRichDepth + 1, ErrRichNestingTooDeep},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var block tgapi.InputRichBlock = P(Text("text"))
|
||||||
|
for range tt.depth - 1 {
|
||||||
|
block = Details(Text("summary"), block)
|
||||||
|
}
|
||||||
|
_, err := renderBlockHTML(block, 0)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderCombinedNestingLimit(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
textDepth int
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"maximum", maxRichDepth - 1, nil},
|
||||||
|
{"too deep", maxRichDepth, ErrRichNestingTooDeep},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
text := tgapi.RichText(Text("text"))
|
||||||
|
for range tt.textDepth {
|
||||||
|
text = Bold(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderBlockHTML(P(text), 0)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("renderBlockHTML() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildHTMLBlocks(t *testing.T) {
|
||||||
|
credit := Text("author")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
block tgapi.InputRichBlock
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"paragraph", P(Bold(Text("text"))), "<p><b>text</b></p>"},
|
||||||
|
{"heading", H2(Text("heading")), "<h2>heading</h2>"},
|
||||||
|
{"pre", CodeBlock(Text("code"), "go"), `<pre><code class="language-go">code</code></pre>`},
|
||||||
|
{"footer", Footer(Text("footer")), "<footer>footer</footer>"},
|
||||||
|
{"divider", Hr(), "<hr/>"},
|
||||||
|
{"math", Math("x < y"), "<tg-math-block>x < y</tg-math-block>"},
|
||||||
|
{"anchor", Anchor(`a"b`), `<a name="a"b"></a>`},
|
||||||
|
{"unordered list", Ul(NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()), "<ul>\n<li><input type=\"checkbox\" checked/><p>item</p></li></ul>"},
|
||||||
|
{"blockquote", BlockQuoteWithCredit(credit, P(Text("quote"))), "<blockquote><p>quote</p><cite>author</cite></blockquote>"},
|
||||||
|
{"pullquote", PullQuoteWithCredit(Text("quote"), credit), "<aside>quote<cite>author</cite></aside>"},
|
||||||
|
{"table", NewTable(Row(CellWithText(Text("value")).Build())).SetCaption(&credit).Build(), "<table><caption>author</caption><tr><td>value</td></tr></table>"},
|
||||||
|
{"details", DetailsOpen(Text("summary"), P(Text("body"))), "<details open><summary>summary</summary><p>body</p></details>"},
|
||||||
|
{"map", Map(tgapi.Location{Latitude: 41.9, Longitude: 12.5}, 14, 640, 320), `<tg-map height="320" lat="41.9" long="12.5" width="640" zoom="14"/>`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
message, err := BuildHTML(tt.block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if message.HTML != tt.want {
|
||||||
|
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.want)
|
||||||
|
}
|
||||||
|
if message.SkipEntityDetection {
|
||||||
|
t.Fatal("BuildHTML() must preserve Telegram's automatic entity detection")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThinkingBlockIsDraftOnly(t *testing.T) {
|
||||||
|
block := Thinking(Text("Thinking…"))
|
||||||
|
if _, err := BuildHTML(block); !errors.Is(err, ErrRichThinkingDraftOnly) {
|
||||||
|
t.Fatalf("BuildHTML() error = %v, want %v", err, ErrRichThinkingDraftOnly)
|
||||||
|
}
|
||||||
|
message, err := BuildDraftHTML(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDraftHTML() returned error: %v", err)
|
||||||
|
}
|
||||||
|
if message.HTML != "<tg-thinking>Thinking…</tg-thinking>" {
|
||||||
|
t.Fatalf("BuildDraftHTML() HTML = %q", message.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHTMLValidatesAutomaticallyDetectedEntities(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
valid tgapi.RichText
|
||||||
|
invalid tgapi.RichText
|
||||||
|
}{
|
||||||
|
{name: "bank card", valid: BankCardNumber(Text("1234"), "1234"), invalid: BankCardNumber(Text("5678"), "1234")},
|
||||||
|
{name: "mention", valid: Mention(Text("@alice"), "alice"), invalid: Mention(Text("Alice"), "alice")},
|
||||||
|
{name: "hashtag", valid: Hashtag(Text("#go"), "go"), invalid: Hashtag(Text("Go"), "go")},
|
||||||
|
{name: "cashtag", valid: Cashtag(Text("$TON"), "TON"), invalid: Cashtag(Text("Toncoin"), "TON")},
|
||||||
|
{name: "bot command", valid: BotCommand(Text("/start"), "start"), invalid: BotCommand(Text("Start"), "start")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := BuildHTML(P(tt.valid)); err != nil {
|
||||||
|
t.Fatalf("valid entity returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := BuildHTML(P(tt.invalid)); !errors.Is(err, ErrRichEntityMismatch) {
|
||||||
|
t.Fatalf("invalid entity error = %v, want ErrRichEntityMismatch", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDraftHTMLRejectsDirectUpload(t *testing.T) {
|
||||||
|
_, err := BuildDraftHTML(Photo(tgapi.InputMedia{
|
||||||
|
Type: tgapi.InputMediaTypePhoto,
|
||||||
|
Media: "attach://photo",
|
||||||
|
}))
|
||||||
|
if !errors.Is(err, tgapi.ErrRichMessageDraftUploadUnsupported) {
|
||||||
|
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHTMLMedia(t *testing.T) {
|
||||||
|
spoiler := true
|
||||||
|
caption := CaptionWithCredit(Text("caption"), Text("credit"))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
block tgapi.InputRichBlock
|
||||||
|
wantHTML string
|
||||||
|
wantType tgapi.InputMediaType
|
||||||
|
wantMedia string
|
||||||
|
}{
|
||||||
|
{"multipart photo", Photo(tgapi.InputMedia{Media: "attach://photo"}), `<img src="tg://photo?id=media_1"/>`, tgapi.InputMediaTypePhoto, "attach://photo"},
|
||||||
|
{"video", Video(tgapi.InputMedia{Media: "video-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeVideo, "video-id"},
|
||||||
|
{"animation", Animation(tgapi.InputMedia{Media: "animation-id"}), `<video src="tg://video?id=media_1"></video>`, tgapi.InputMediaTypeAnimation, "animation-id"},
|
||||||
|
{"audio", Audio(tgapi.InputMedia{Media: "audio-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeAudio, "audio-id"},
|
||||||
|
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice-id"}), `<audio src="tg://audio?id=media_1"></audio>`, tgapi.InputMediaTypeVoiceNote, "voice-id"},
|
||||||
|
{"caption and spoiler", PhotoWithCaption(tgapi.InputMedia{Media: "photo-id", HasSpoiler: &spoiler}, caption), `<figure><img src="tg://photo?id=media_1" tg-spoiler/><figcaption>caption<cite>credit</cite></figcaption></figure>`, tgapi.InputMediaTypePhoto, "photo-id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
message, err := BuildHTML(tt.block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if message.HTML != tt.wantHTML {
|
||||||
|
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, tt.wantHTML)
|
||||||
|
}
|
||||||
|
if len(message.Media) != 1 {
|
||||||
|
t.Fatalf("BuildHTML() media count = %d, want 1", len(message.Media))
|
||||||
|
}
|
||||||
|
media := message.Media[0]
|
||||||
|
if media.ID != "media_1" || media.Media.Type != tt.wantType || media.Media.Media != tt.wantMedia {
|
||||||
|
t.Fatalf("BuildHTML() media = %#v", media)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHTMLNestedMediaUsesSharedIDs(t *testing.T) {
|
||||||
|
message, err := BuildHTML(
|
||||||
|
Collage(Photo(tgapi.InputMedia{Media: "photo"}), Video(tgapi.InputMedia{Media: "video"})),
|
||||||
|
Audio(tgapi.InputMedia{Media: "audio"}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := `<tg-collage><img src="tg://photo?id=media_1"/><video src="tg://video?id=media_2"></video></tg-collage><audio src="tg://audio?id=media_3"></audio>`
|
||||||
|
if message.HTML != want {
|
||||||
|
t.Fatalf("BuildHTML() HTML = %q, want %q", message.HTML, want)
|
||||||
|
}
|
||||||
|
if len(message.Media) != 3 {
|
||||||
|
t.Fatalf("BuildHTML() media count = %d, want 3", len(message.Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHTMLLimits(t *testing.T) {
|
||||||
|
media := func() tgapi.InputRichBlock {
|
||||||
|
return Photo(tgapi.InputMedia{Media: "photo"})
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
blocks func() []tgapi.InputRichBlock
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"maximum UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars)))}
|
||||||
|
}, nil},
|
||||||
|
{"too many UTF-8 characters", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{P(Text(strings.Repeat("я", maxRichTextChars+1)))}
|
||||||
|
}, ErrRichTextTooLong},
|
||||||
|
{"maximum blocks", func() []tgapi.InputRichBlock {
|
||||||
|
blocks := make([]tgapi.InputRichBlock, maxRichBlocks)
|
||||||
|
for i := range blocks {
|
||||||
|
blocks[i] = Hr()
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}, nil},
|
||||||
|
{"too many blocks", func() []tgapi.InputRichBlock {
|
||||||
|
blocks := make([]tgapi.InputRichBlock, maxRichBlocks+1)
|
||||||
|
for i := range blocks {
|
||||||
|
blocks[i] = Hr()
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}, ErrRichTooManyBlocks},
|
||||||
|
{"list items count as blocks", func() []tgapi.InputRichBlock {
|
||||||
|
items := make([]tgapi.InputRichBlockListItem, maxRichBlocks)
|
||||||
|
return []tgapi.InputRichBlock{List(items...)}
|
||||||
|
}, ErrRichTooManyBlocks},
|
||||||
|
{"table rows count as blocks", func() []tgapi.InputRichBlock {
|
||||||
|
rows := make([][]tgapi.RichBlockTableCell, maxRichBlocks)
|
||||||
|
return []tgapi.InputRichBlock{NewTable(rows...).Build()}
|
||||||
|
}, ErrRichTooManyBlocks},
|
||||||
|
{"maximum media", func() []tgapi.InputRichBlock {
|
||||||
|
blocks := make([]tgapi.InputRichBlock, maxRichMedia)
|
||||||
|
for i := range blocks {
|
||||||
|
blocks[i] = media()
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}, nil},
|
||||||
|
{"too many media", func() []tgapi.InputRichBlock {
|
||||||
|
blocks := make([]tgapi.InputRichBlock, maxRichMedia+1)
|
||||||
|
for i := range blocks {
|
||||||
|
blocks[i] = media()
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}, ErrRichTooManyMedia},
|
||||||
|
{"maximum table width", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns)...)).Build()}
|
||||||
|
}, nil},
|
||||||
|
{"table too wide", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{NewTable(Row(makeCells(maxTableColumns + 1)...)).Build()}
|
||||||
|
}, ErrRichTableTooWide},
|
||||||
|
{"colspan counts toward width", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{NewTable(Row(Cell().SetSpan(maxTableColumns+1, 1).Build())).Build()}
|
||||||
|
}, ErrRichTableTooWide},
|
||||||
|
{"maximum combined nesting", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth-1))}
|
||||||
|
}, nil},
|
||||||
|
{"combined nesting too deep", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{P(wrapBold(Text("text"), maxRichDepth))}
|
||||||
|
}, ErrRichNestingTooDeep},
|
||||||
|
{"maximum block nesting", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth-1)}
|
||||||
|
}, nil},
|
||||||
|
{"block nesting too deep", func() []tgapi.InputRichBlock {
|
||||||
|
return []tgapi.InputRichBlock{wrapDetails(P(Text("text")), maxRichDepth)}
|
||||||
|
}, ErrRichNestingTooDeep},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := BuildHTML(tt.blocks()...)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHTMLRejectsInvalidFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
block tgapi.InputRichBlock
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"mixed list", List(NewListItem().SetType(tgapi.InputRichBlockListItemTypeDecimal).Build(), NewListItem().Build()), ErrRichListItemMix},
|
||||||
|
{"unordered value", List(NewListItem().SetValue(2).Build()), ErrRichInvalidListItem},
|
||||||
|
{"invalid list type", List(NewListItem().SetType("x").Build()), ErrRichInvalidListItemType},
|
||||||
|
{"checked without checkbox", List(NewListItem().SetChecked().Build()), ErrRichInvalidCheckbox},
|
||||||
|
{"invalid block type", tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeFooter, Text: Text("text")}, ErrRichInvalidBlockType},
|
||||||
|
{"heading too small", H(Text("heading"), 0), ErrRichInvalidHeading},
|
||||||
|
{"heading too large", H(Text("heading"), 7), ErrRichInvalidHeading},
|
||||||
|
{"media mismatch", tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: tgapi.InputMedia{Type: tgapi.InputMediaTypeVideo, Media: "video"}}, ErrRichInvalidMedia},
|
||||||
|
{"empty media", Photo(tgapi.InputMedia{}), ErrRichInvalidMedia},
|
||||||
|
{"map latitude", Map(tgapi.Location{Latitude: 91}, 0, 0, 0), ErrRichInvalidMap},
|
||||||
|
{"map longitude", Map(tgapi.Location{Longitude: 181}, 0, 0, 0), ErrRichInvalidMap},
|
||||||
|
{"map zoom", Map(tgapi.Location{}, 25, 0, 0), ErrRichInvalidMap},
|
||||||
|
{"map total dimensions", Map(tgapi.Location{}, 0, 9000, 1001), ErrRichInvalidMap},
|
||||||
|
{"map aspect ratio", Map(tgapi.Location{}, 0, 100, 4), ErrRichInvalidMap},
|
||||||
|
{"negative colspan", NewTable(Row(Cell().SetSpan(-1, 0).Build())).Build(), ErrRichInvalidTableCell},
|
||||||
|
{"invalid horizontal alignment", NewTable(Row(Cell().SetAlign("justify").Build())).Build(), ErrRichInvalidTableCell},
|
||||||
|
{"invalid vertical alignment", NewTable(Row(Cell().SetVAlign("center").Build())).Build(), ErrRichInvalidTableCell},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := BuildHTML(tt.block)
|
||||||
|
if !errors.Is(err, tt.want) {
|
||||||
|
t.Fatalf("BuildHTML() error = %v, want %v", err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToHTML(t *testing.T) {
|
||||||
|
message, err := ToHTML(P(Text("text")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if message.HTML != "<p>text</p>" {
|
||||||
|
t.Fatalf("ToHTML() HTML = %q", message.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCells(n int) []tgapi.RichBlockTableCell {
|
||||||
|
return make([]tgapi.RichBlockTableCell, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapBold(text tgapi.RichText, depth int) tgapi.RichText {
|
||||||
|
for range depth {
|
||||||
|
text = Bold(text)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapDetails(block tgapi.InputRichBlock, depth int) tgapi.InputRichBlock {
|
||||||
|
for range depth {
|
||||||
|
block = Details(Text("summary"), block)
|
||||||
|
}
|
||||||
|
return block
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrRichTextTooLong indicates that rich-message text exceeds 32768 UTF-8 characters.
|
||||||
|
ErrRichTextTooLong = errors.New("rich text too long")
|
||||||
|
// ErrRichTooManyBlocks indicates that a rich message exceeds 500 blocks and counted nested items.
|
||||||
|
ErrRichTooManyBlocks = errors.New("rich text too many blocks")
|
||||||
|
// ErrRichNestingTooDeep indicates that rich formatting exceeds 16 nested levels.
|
||||||
|
ErrRichNestingTooDeep = errors.New("rich text nesting deep")
|
||||||
|
// ErrRichTooManyMedia indicates that a rich message contains more than 50 media attachments.
|
||||||
|
ErrRichTooManyMedia = errors.New("rich text too many media")
|
||||||
|
// ErrRichTableTooWide indicates that a table contains more than 20 columns.
|
||||||
|
ErrRichTableTooWide = errors.New("rich text table too wide")
|
||||||
|
// ErrRichUnknownTag indicates that a rich-text or rich-block implementation is unsupported.
|
||||||
|
ErrRichUnknownTag = errors.New("rich unknown tag")
|
||||||
|
// ErrRichListItemMix indicates that ordered and unordered items were mixed in one list.
|
||||||
|
ErrRichListItemMix = errors.New("rich list items mixed: ordered and unordered")
|
||||||
|
// ErrRichInvalidListItem indicates that an unordered item uses ordered-list attributes.
|
||||||
|
ErrRichInvalidListItem = errors.New("rich unordered list item has ordered attributes")
|
||||||
|
// ErrRichInvalidMedia indicates that a media block contains an incompatible media type.
|
||||||
|
ErrRichInvalidMedia = errors.New("rich block has incompatible media type")
|
||||||
|
// ErrRichInvalidBlockType indicates that a block's type discriminator doesn't match its Go type.
|
||||||
|
ErrRichInvalidBlockType = errors.New("rich block has invalid type")
|
||||||
|
// ErrRichInvalidHeading indicates that a heading has a size outside 1-6.
|
||||||
|
ErrRichInvalidHeading = errors.New("rich heading has invalid size")
|
||||||
|
// ErrRichInvalidMap indicates that a map has invalid coordinates, zoom, or dimensions.
|
||||||
|
ErrRichInvalidMap = errors.New("rich map has invalid parameters")
|
||||||
|
// ErrRichInvalidListItemType indicates that an ordered-list marker type is unsupported.
|
||||||
|
ErrRichInvalidListItemType = errors.New("rich list item has invalid type")
|
||||||
|
// ErrRichInvalidCheckbox indicates that a checked list item has no checkbox.
|
||||||
|
ErrRichInvalidCheckbox = errors.New("rich list item is checked without a checkbox")
|
||||||
|
// ErrRichInvalidTableCell indicates that a table cell has invalid spans or alignment.
|
||||||
|
ErrRichInvalidTableCell = errors.New("rich table cell has invalid parameters")
|
||||||
|
// ErrRichThinkingDraftOnly indicates that a thinking block was used outside a draft.
|
||||||
|
ErrRichThinkingDraftOnly = errors.New("thinking blocks are valid only in rich-message drafts")
|
||||||
|
// ErrRichEntityMismatch indicates that HTML conversion would lose an explicit entity value.
|
||||||
|
ErrRichEntityMismatch = errors.New("rich entity value doesn't match visible text")
|
||||||
|
)
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type buildState struct {
|
||||||
|
media []tgapi.InputRichMessageMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildHTML converts input rich blocks to an HTML-based rich message and
|
||||||
|
// collects media blocks into InputRichMessage.Media.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func BuildHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||||
|
return buildHTML(false, blocks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDraftHTML converts draft-compatible input rich blocks to HTML.
|
||||||
|
// Unlike BuildHTML, it permits the draft-only thinking block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func BuildDraftHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||||
|
return buildHTML(true, blocks...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHTML(allowThinking bool, blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
||||||
|
if err := validateRichBlocks(blocks, allowThinking); err != nil {
|
||||||
|
return tgapi.InputRichMessage{}, err
|
||||||
|
}
|
||||||
|
state := new(buildState)
|
||||||
|
rendered, err := renderBlocksHTMLState(blocks, 0, state)
|
||||||
|
if err != nil {
|
||||||
|
return tgapi.InputRichMessage{}, err
|
||||||
|
}
|
||||||
|
if allowThinking {
|
||||||
|
for _, item := range state.media {
|
||||||
|
if strings.HasPrefix(item.Media.Media, "attach://") {
|
||||||
|
return tgapi.InputRichMessage{}, tgapi.ErrRichMessageDraftUploadUnsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tgapi.InputRichMessage{
|
||||||
|
HTML: strings.Join(rendered, ""),
|
||||||
|
Media: state.media,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToHTML converts one input rich block to an HTML-based rich message.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func ToHTML(block tgapi.InputRichBlock) (tgapi.InputRichMessage, error) { return BuildHTML(block) }
|
||||||
|
|
||||||
|
func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
|
||||||
|
if len(s.media) >= maxRichMedia {
|
||||||
|
return "", ErrRichTooManyMedia
|
||||||
|
}
|
||||||
|
var kind string
|
||||||
|
switch media.Type {
|
||||||
|
case tgapi.InputMediaTypePhoto:
|
||||||
|
kind = "photo"
|
||||||
|
case tgapi.InputMediaTypeAnimation, tgapi.InputMediaTypeVideo:
|
||||||
|
kind = "video"
|
||||||
|
case tgapi.InputMediaTypeAudio, tgapi.InputMediaTypeVoiceNote:
|
||||||
|
kind = "audio"
|
||||||
|
default:
|
||||||
|
return "", ErrRichInvalidMedia
|
||||||
|
}
|
||||||
|
id := "media_" + strconv.Itoa(len(s.media)+1)
|
||||||
|
s.media = append(s.media, tgapi.InputRichMessageMedia{ID: id, Media: media})
|
||||||
|
return "tg://" + kind + "?id=" + id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderText(t tgapi.RichText, step int) (string, error) {
|
||||||
|
switch el := t.(type) {
|
||||||
|
case tgapi.RichTextPlain:
|
||||||
|
return escapeHTML(string(el)), nil
|
||||||
|
case tgapi.RichTextArray:
|
||||||
|
var b strings.Builder
|
||||||
|
for _, item := range el {
|
||||||
|
part, err := renderText(item, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteString(part)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
case tgapi.RichTextWrap:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tagMap := map[string]string{
|
||||||
|
"bold": "b", "italic": "i", "underline": "u", "strikethrough": "s",
|
||||||
|
"subscript": "sub", "superscript": "sup", "marked": "mark",
|
||||||
|
"spoiler": "tg-spoiler", "code": "code",
|
||||||
|
}
|
||||||
|
tag, ok := tagMap[el.Tag]
|
||||||
|
if !ok {
|
||||||
|
return "", ErrRichUnknownTag
|
||||||
|
}
|
||||||
|
return "<" + tag + ">" + s + "</" + tag + ">", nil
|
||||||
|
case tgapi.RichTextDateTime:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := []string{`unix="` + strconv.FormatInt(el.UnixTime, 10) + `"`}
|
||||||
|
if el.DateTimeFormat != "" {
|
||||||
|
attrs = append(attrs, `format="`+escapeHTML(el.DateTimeFormat)+`"`)
|
||||||
|
}
|
||||||
|
return openTag("tg-time", attrs) + s + "</tg-time>", nil
|
||||||
|
case tgapi.RichTextTextMention:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := []string{`href="` + "tg://user?id=" + strconv.FormatInt(el.User.ID, 10) + `"`}
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
case tgapi.RichTextCustomEmoji:
|
||||||
|
s := escapeHTML(el.AlternativeText)
|
||||||
|
attrs := formatAttrs(map[string]string{"emoji-id": el.CustomEmojiID})
|
||||||
|
return openTag("tg-emoji", attrs) + s + "</tg-emoji>", nil
|
||||||
|
case tgapi.RichTextMathematicalExpression:
|
||||||
|
s := escapeHTML(el.Expression)
|
||||||
|
return "<tg-math>" + s + "</tg-math>", nil
|
||||||
|
case tgapi.RichTextURL:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"href": el.URL})
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
case tgapi.RichTextEmailAddress:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"href": "mailto:" + el.EmailAddress})
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
case tgapi.RichTextPhoneNumber:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"href": "tel:" + el.PhoneNumber})
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
case tgapi.RichTextBankCardNumber:
|
||||||
|
return renderTextChild(el.Text, step)
|
||||||
|
case tgapi.RichTextMention:
|
||||||
|
return renderTextChild(el.Text, step)
|
||||||
|
case tgapi.RichTextHashtag:
|
||||||
|
return renderTextChild(el.Text, step)
|
||||||
|
case tgapi.RichTextCashtag:
|
||||||
|
return renderTextChild(el.Text, step)
|
||||||
|
case tgapi.RichTextBotCommand:
|
||||||
|
return renderTextChild(el.Text, step)
|
||||||
|
case tgapi.RichTextAnchor:
|
||||||
|
attrs := formatAttrs(map[string]string{"name": el.Name})
|
||||||
|
return openTag("a", attrs) + "</a>", nil
|
||||||
|
case tgapi.RichTextAnchorLink:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"href": "#" + el.AnchorName})
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
case tgapi.RichTextReference:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"name": el.Name})
|
||||||
|
return openTag("tg-reference", attrs) + s + "</tg-reference>", nil
|
||||||
|
case tgapi.RichTextReferenceLink:
|
||||||
|
s, err := renderTextChild(el.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := formatAttrs(map[string]string{"href": "#" + el.ReferenceName})
|
||||||
|
return openTag("a", attrs) + s + "</a>", nil
|
||||||
|
}
|
||||||
|
return "", ErrRichUnknownTag
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextChild(t tgapi.RichText, step int) (string, error) {
|
||||||
|
if step >= maxRichDepth {
|
||||||
|
return "", ErrRichNestingTooDeep
|
||||||
|
}
|
||||||
|
return renderText(t, step+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTMLTable(t tgapi.InputRichBlockTable, step int, state *buildState) (string, error) {
|
||||||
|
attrs := map[string]string{}
|
||||||
|
if t.IsBordered {
|
||||||
|
attrs["bordered"] = ""
|
||||||
|
}
|
||||||
|
if t.IsStriped {
|
||||||
|
attrs["striped"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows strings.Builder
|
||||||
|
for _, row := range t.Cells {
|
||||||
|
r, err := renderHTMLTableRow(row, step, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rows.WriteString(r)
|
||||||
|
}
|
||||||
|
caption := ""
|
||||||
|
if t.Caption != nil {
|
||||||
|
text, err := renderText(*t.Caption, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caption = "<caption>" + text + "</caption>"
|
||||||
|
}
|
||||||
|
return openTag("table", formatAttrs(attrs)) + caption + rows.String() + "</table>", nil
|
||||||
|
}
|
||||||
|
func renderHTMLTableRow(rows []tgapi.RichBlockTableCell, step int, state *buildState) (string, error) {
|
||||||
|
var out strings.Builder
|
||||||
|
for _, cell := range rows {
|
||||||
|
row, err := renderHTMLTableCell(cell, step, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out.WriteString(row)
|
||||||
|
}
|
||||||
|
return "<tr>" + out.String() + "</tr>", nil
|
||||||
|
}
|
||||||
|
func renderHTMLTableCell(c tgapi.RichBlockTableCell, step int, _ *buildState) (string, error) {
|
||||||
|
var renderedText string
|
||||||
|
var err error
|
||||||
|
if c.Text != nil {
|
||||||
|
renderedText, err = renderText(c.Text, step+1)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mapAttrs := make(map[string]string)
|
||||||
|
tag := "td"
|
||||||
|
if c.IsHeader {
|
||||||
|
tag = "th"
|
||||||
|
}
|
||||||
|
if c.RowSpan > 0 {
|
||||||
|
mapAttrs["rowspan"] = strconv.Itoa(c.RowSpan)
|
||||||
|
}
|
||||||
|
if c.ColSpan > 0 {
|
||||||
|
mapAttrs["colspan"] = strconv.Itoa(c.ColSpan)
|
||||||
|
}
|
||||||
|
if c.Align != "" {
|
||||||
|
mapAttrs["align"] = c.Align
|
||||||
|
}
|
||||||
|
if c.VAlign != "" {
|
||||||
|
mapAttrs["valign"] = c.VAlign
|
||||||
|
}
|
||||||
|
return openTag(tag, formatAttrs(mapAttrs)) + renderedText + "</" + tag + ">", nil
|
||||||
|
}
|
||||||
|
func renderHTMLList(l tgapi.InputRichBlockList, step int, state *buildState) (string, error) {
|
||||||
|
if len(l.Items) == 0 {
|
||||||
|
return "<ul></ul>", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasType := false
|
||||||
|
hasNoType := false
|
||||||
|
|
||||||
|
var items []string
|
||||||
|
for _, item := range l.Items {
|
||||||
|
if item.Type != "" {
|
||||||
|
hasType = true
|
||||||
|
} else {
|
||||||
|
hasNoType = true
|
||||||
|
if item.Value != 0 {
|
||||||
|
return "", ErrRichInvalidListItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rendered, err := renderHTMLListItem(item, step, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
items = append(items, rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasType && !hasNoType:
|
||||||
|
return "<ol>\n" + strings.Join(items, "\n") + "</ol>", nil
|
||||||
|
case hasNoType && !hasType:
|
||||||
|
return "<ul>\n" + strings.Join(items, "\n") + "</ul>", nil
|
||||||
|
}
|
||||||
|
return "", ErrRichListItemMix
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTMLCaption(cap tgapi.RichBlockCaption, step int) (string, error) {
|
||||||
|
caption := "<figcaption>"
|
||||||
|
text, err := renderText(cap.Text, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caption += text
|
||||||
|
if cap.Credit != nil {
|
||||||
|
cred, err := renderText(cap.Credit, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caption += "<cite>" + cred + "</cite>"
|
||||||
|
}
|
||||||
|
caption += "</figcaption>"
|
||||||
|
return caption, nil
|
||||||
|
}
|
||||||
|
func renderHTMLListItem(i tgapi.InputRichBlockListItem, step int, state *buildState) (string, error) {
|
||||||
|
mapAttrs := map[string]string{}
|
||||||
|
|
||||||
|
renderedBlocks, err := renderBlocksHTMLState(i.Blocks, step+1, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := strings.Join(renderedBlocks, "")
|
||||||
|
if i.HasCheckbox {
|
||||||
|
var input string
|
||||||
|
if i.IsChecked {
|
||||||
|
input = `<input type="checkbox" checked/>`
|
||||||
|
} else {
|
||||||
|
input = `<input type="checkbox"/>`
|
||||||
|
}
|
||||||
|
blocks = input + blocks
|
||||||
|
}
|
||||||
|
if i.Value > 0 {
|
||||||
|
mapAttrs["value"] = strconv.Itoa(i.Value)
|
||||||
|
}
|
||||||
|
if i.Type != "" {
|
||||||
|
mapAttrs["type"] = string(i.Type)
|
||||||
|
}
|
||||||
|
return openTag("li", formatAttrs(mapAttrs)) + blocks + "</li>", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTMLMap(block tgapi.InputRichBlockMap, step int) (string, error) {
|
||||||
|
attrs := map[string]string{
|
||||||
|
"lat": strconv.FormatFloat(block.Location.Latitude, 'f', -1, 64),
|
||||||
|
"long": strconv.FormatFloat(block.Location.Longitude, 'f', -1, 64),
|
||||||
|
}
|
||||||
|
if block.Zoom != 0 {
|
||||||
|
attrs["zoom"] = strconv.Itoa(int(block.Zoom))
|
||||||
|
}
|
||||||
|
if block.Width != 0 {
|
||||||
|
attrs["width"] = strconv.Itoa(int(block.Width))
|
||||||
|
}
|
||||||
|
if block.Height != 0 {
|
||||||
|
attrs["height"] = strconv.Itoa(int(block.Height))
|
||||||
|
}
|
||||||
|
media := selfClosingTag("tg-map", formatAttrs(attrs))
|
||||||
|
if block.Caption == nil {
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
caption, err := renderHTMLCaption(*block.Caption, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "<figure>" + media + caption + "</figure>", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHTMLMedia(media tgapi.InputMedia, caption *tgapi.RichBlockCaption, tag string, step int, state *buildState) (string, error) {
|
||||||
|
src, err := state.addMedia(media)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
attrs := map[string]string{"src": src}
|
||||||
|
if media.HasSpoiler != nil && *media.HasSpoiler {
|
||||||
|
attrs["tg-spoiler"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var element string
|
||||||
|
if tag == "img" {
|
||||||
|
element = selfClosingTag(tag, formatAttrs(attrs))
|
||||||
|
} else {
|
||||||
|
element = openTag(tag, formatAttrs(attrs)) + "</" + tag + ">"
|
||||||
|
}
|
||||||
|
if caption == nil {
|
||||||
|
return element, nil
|
||||||
|
}
|
||||||
|
renderedCaption, err := renderHTMLCaption(*caption, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "<figure>" + element + renderedCaption + "</figure>", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// func renderBlocksHTML(blocks []tgapi.InputRichBlock, step int) ([]string, error) {
|
||||||
|
// return renderBlocksHTMLState(blocks, step, new(buildState))
|
||||||
|
// }
|
||||||
|
|
||||||
|
func renderBlocksHTMLState(blocks []tgapi.InputRichBlock, step int, state *buildState) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
html, err := renderBlockHTMLState(b, step, state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, html)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlockHTML(block tgapi.InputRichBlock, step int) (string, error) {
|
||||||
|
return renderBlockHTMLState(block, step, new(buildState))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildState) (string, error) {
|
||||||
|
if step >= maxRichDepth {
|
||||||
|
return "", ErrRichNestingTooDeep
|
||||||
|
}
|
||||||
|
switch el := block.(type) {
|
||||||
|
case tgapi.InputRichBlockParagraph:
|
||||||
|
t, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "<p>" + t + "</p>", nil
|
||||||
|
case tgapi.InputRichBlockSectionHeading:
|
||||||
|
t, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
size := strconv.Itoa(int(el.Size))
|
||||||
|
return "<h" + size + ">" + t + "</h" + size + ">", nil
|
||||||
|
case tgapi.InputRichBlockPreformatted:
|
||||||
|
t, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if el.Language != "" {
|
||||||
|
t = fmt.Sprintf(`<code class="language-%s">%s</code>`, escapeHTML(el.Language), t)
|
||||||
|
}
|
||||||
|
return "<pre>" + t + "</pre>", nil
|
||||||
|
case tgapi.InputRichBlockFooter:
|
||||||
|
t, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "<footer>" + t + "</footer>", nil
|
||||||
|
case tgapi.InputRichBlockDivider:
|
||||||
|
return "<hr/>", nil
|
||||||
|
case tgapi.InputRichBlockMath:
|
||||||
|
exp := escapeHTML(el.Expression)
|
||||||
|
return "<tg-math-block>" + exp + "</tg-math-block>", nil
|
||||||
|
case tgapi.InputRichBlockAnchor:
|
||||||
|
return fmt.Sprintf(`<a name="%s"></a>`, escapeHTML(el.Name)), nil
|
||||||
|
case tgapi.InputRichBlockList:
|
||||||
|
return renderHTMLList(el, step, state)
|
||||||
|
case tgapi.InputRichBlockBlockQuotation:
|
||||||
|
content, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
credit := ""
|
||||||
|
if el.Credit != nil {
|
||||||
|
credit, err = renderText(*el.Credit, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
credit = "<cite>" + credit + "</cite>"
|
||||||
|
}
|
||||||
|
return "<blockquote>" + strings.Join(content, "") + credit + "</blockquote>", nil
|
||||||
|
case tgapi.InputRichBlockPullQuotation:
|
||||||
|
text, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
credit := ""
|
||||||
|
if el.Credit != nil {
|
||||||
|
credit, err = renderText(*el.Credit, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
credit = "<cite>" + credit + "</cite>"
|
||||||
|
}
|
||||||
|
return "<aside>" + text + credit + "</aside>", nil
|
||||||
|
case tgapi.InputRichBlockCollage:
|
||||||
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caption := ""
|
||||||
|
if el.Caption != nil {
|
||||||
|
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<tg-collage>" + strings.Join(blocks, "") + caption + "</tg-collage>", err
|
||||||
|
case tgapi.InputRichBlockSlideshow:
|
||||||
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
caption := ""
|
||||||
|
if el.Caption != nil {
|
||||||
|
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<tg-slideshow>" + strings.Join(blocks, "") + caption + "</tg-slideshow>", err
|
||||||
|
case tgapi.InputRichBlockTable:
|
||||||
|
return renderHTMLTable(el, step, state)
|
||||||
|
case tgapi.InputRichBlockDetails:
|
||||||
|
summary, err := renderText(el.Summary, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := make(map[string]string)
|
||||||
|
if el.IsOpen {
|
||||||
|
attrs["open"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return openTag("details", formatAttrs(attrs)) + "<summary>" + summary + "</summary>" + strings.Join(blocks, "") + "</details>", nil
|
||||||
|
case tgapi.InputRichBlockMap:
|
||||||
|
return renderHTMLMap(el, step)
|
||||||
|
case tgapi.InputRichBlockAnimation:
|
||||||
|
return renderHTMLMedia(el.Animation, el.Caption, "video", step, state)
|
||||||
|
case tgapi.InputRichBlockAudio:
|
||||||
|
return renderHTMLMedia(el.Audio, el.Caption, "audio", step, state)
|
||||||
|
case tgapi.InputRichBlockPhoto:
|
||||||
|
return renderHTMLMedia(el.Photo, el.Caption, "img", step, state)
|
||||||
|
case tgapi.InputRichBlockVideo:
|
||||||
|
return renderHTMLMedia(el.Video, el.Caption, "video", step, state)
|
||||||
|
case tgapi.InputRichBlockVoiceNote:
|
||||||
|
return renderHTMLMedia(el.VoiceNote, el.Caption, "audio", step, state)
|
||||||
|
case tgapi.InputRichBlockThinking:
|
||||||
|
text, err := renderText(el.Text, step+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "<tg-thinking>" + text + "</tg-thinking>", nil
|
||||||
|
}
|
||||||
|
return "", ErrRichUnknownTag
|
||||||
|
}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import "git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
|
||||||
|
// Caption creates a media-block caption without a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Caption(text tgapi.RichText) tgapi.RichBlockCaption { return tgapi.RichBlockCaption{Text: text} }
|
||||||
|
|
||||||
|
// CaptionWithCredit creates a media-block caption with a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func CaptionWithCredit(text, credit tgapi.RichText) tgapi.RichBlockCaption {
|
||||||
|
return tgapi.RichBlockCaption{Text: text, Credit: credit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// P creates a text paragraph corresponding to the HTML <p> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func P(text tgapi.RichText) tgapi.InputRichBlockParagraph {
|
||||||
|
return tgapi.InputRichBlockParagraph{Type: tgapi.InputRichTypeParagraph, Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H creates a section heading with a relative font size from 1 (largest) to 6 (smallest).
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H(text tgapi.RichText, size uint8) tgapi.InputRichBlockSectionHeading {
|
||||||
|
return tgapi.InputRichBlockSectionHeading{
|
||||||
|
Type: tgapi.InputRichTypeSectionHeading,
|
||||||
|
Text: text, Size: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H1 creates a level-one section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H1(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 1) }
|
||||||
|
|
||||||
|
// H2 creates a level-two section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H2(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 2) }
|
||||||
|
|
||||||
|
// H3 creates a level-three section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H3(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 3) }
|
||||||
|
|
||||||
|
// H4 creates a level-four section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H4(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 4) }
|
||||||
|
|
||||||
|
// H5 creates a level-five section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H5(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 5) }
|
||||||
|
|
||||||
|
// H6 creates a level-six section heading.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func H6(text tgapi.RichText) tgapi.InputRichBlockSectionHeading { return H(text, 6) }
|
||||||
|
|
||||||
|
// Pre creates a preformatted text block without a programming language.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Pre(text tgapi.RichText) tgapi.InputRichBlockPreformatted {
|
||||||
|
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeBlock creates a preformatted text block with its programming language.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func CodeBlock(text tgapi.RichText, lang string) tgapi.InputRichBlockPreformatted {
|
||||||
|
return tgapi.InputRichBlockPreformatted{Type: tgapi.InputRichTypePre, Text: text, Language: lang}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer creates a footer block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Footer(text tgapi.RichText) tgapi.InputRichBlockFooter {
|
||||||
|
return tgapi.InputRichBlockFooter{Type: tgapi.InputRichTypeFooter, Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hr creates a divider corresponding to the HTML <hr/> tag.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Hr() tgapi.InputRichBlockDivider {
|
||||||
|
return tgapi.InputRichBlockDivider{Type: tgapi.InputRichTypeDivider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Math creates a mathematical expression block from a LaTeX expression.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Math(expression string) tgapi.InputRichBlockMath {
|
||||||
|
return tgapi.InputRichBlockMath{Type: tgapi.InputRichTypeMathematicalExpression, Expression: expression}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor creates a block containing an anchor with the given name.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Anchor(name string) tgapi.InputRichBlockAnchor {
|
||||||
|
return tgapi.InputRichBlockAnchor{Type: tgapi.InputRichTypeAnchor, Name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListItem builds an input rich-message list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type ListItem struct {
|
||||||
|
blocks []tgapi.InputRichBlock
|
||||||
|
hasCheckbox bool
|
||||||
|
isChecked bool
|
||||||
|
value int
|
||||||
|
t tgapi.RichBlockListItemType
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewListItem creates a list-item builder containing blocks.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func NewListItem(blocks ...tgapi.InputRichBlock) *ListItem {
|
||||||
|
return &ListItem{blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlocks replaces the blocks in the list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) SetBlocks(blocks ...tgapi.InputRichBlock) *ListItem {
|
||||||
|
i.blocks = blocks
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCheckbox adds an unchecked checkbox to the list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) SetCheckbox() *ListItem {
|
||||||
|
i.hasCheckbox = true
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChecked marks the list item's checkbox as checked.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) SetChecked() *ListItem {
|
||||||
|
i.isChecked = true
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetValue sets the explicit number of an ordered-list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) SetValue(val int) *ListItem {
|
||||||
|
i.value = val
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetType sets the marker style of an ordered-list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) SetType(t tgapi.RichBlockListItemType) *ListItem {
|
||||||
|
i.t = t
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the configured input rich-message list item.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (i *ListItem) Build() tgapi.InputRichBlockListItem {
|
||||||
|
return tgapi.InputRichBlockListItem{
|
||||||
|
Blocks: i.blocks,
|
||||||
|
HasCheckbox: i.hasCheckbox,
|
||||||
|
IsChecked: i.isChecked,
|
||||||
|
Value: i.value,
|
||||||
|
Type: i.t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List creates a list block from fully configured items.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func List(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||||
|
return tgapi.InputRichBlockList{Type: tgapi.InputRichTypeList, Items: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ul creates an unordered list and clears ordered-list attributes.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Ul(items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||||
|
newItems := make([]tgapi.InputRichBlockListItem, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
newItems[index] = tgapi.InputRichBlockListItem{
|
||||||
|
Blocks: item.Blocks,
|
||||||
|
HasCheckbox: item.HasCheckbox,
|
||||||
|
IsChecked: item.IsChecked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List(newItems...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OlOpts configures ordered-list numbering.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type OlOpts struct {
|
||||||
|
// Type is the marker style: "1", "a", "A", "i", or "I".
|
||||||
|
Type tgapi.RichBlockListItemType
|
||||||
|
// Start is the number of the first item; values below 1 use the default.
|
||||||
|
Start int
|
||||||
|
// IsReversed reports whether numbering decreases from Start.
|
||||||
|
IsReversed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ol creates an ordered list using opts for numbering.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Ol(opts OlOpts, items ...tgapi.InputRichBlockListItem) tgapi.InputRichBlockList {
|
||||||
|
newItems := make([]tgapi.InputRichBlockListItem, len(items))
|
||||||
|
start := opts.Start
|
||||||
|
if start < 1 {
|
||||||
|
start = 1
|
||||||
|
if opts.IsReversed {
|
||||||
|
start = len(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typ := opts.Type
|
||||||
|
if typ == "" {
|
||||||
|
typ = tgapi.InputRichBlockListItemTypeDecimal
|
||||||
|
}
|
||||||
|
for index, item := range items {
|
||||||
|
val := index + start
|
||||||
|
if opts.IsReversed {
|
||||||
|
val = start - index
|
||||||
|
}
|
||||||
|
newItems[index] = tgapi.InputRichBlockListItem{
|
||||||
|
Blocks: item.Blocks,
|
||||||
|
HasCheckbox: item.HasCheckbox,
|
||||||
|
IsChecked: item.IsChecked,
|
||||||
|
Value: val,
|
||||||
|
Type: typ,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List(newItems...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockQuote creates a block quotation without a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func BlockQuote(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
|
||||||
|
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockQuoteWithCredit creates a block quotation with a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func BlockQuoteWithCredit(credit tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockBlockQuotation {
|
||||||
|
return tgapi.InputRichBlockBlockQuotation{Type: tgapi.InputRichTypeBlockQuotation, Blocks: blocks, Credit: &credit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullQuote creates a centered quotation without a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func PullQuote(text tgapi.RichText) tgapi.InputRichBlockPullQuotation {
|
||||||
|
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullQuoteWithCredit creates a centered quotation with a credit.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func PullQuoteWithCredit(text tgapi.RichText, credit tgapi.RichText) tgapi.InputRichBlockPullQuotation {
|
||||||
|
return tgapi.InputRichBlockPullQuotation{Type: tgapi.InputRichTypePullQuotation, Text: text, Credit: &credit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collage creates a media collage without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Collage(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
|
||||||
|
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollageWithCaption creates a media collage with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func CollageWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockCollage {
|
||||||
|
return tgapi.InputRichBlockCollage{Type: tgapi.InputRichTypeCollage, Blocks: blocks, Caption: &caption}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slideshow creates a media slideshow without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Slideshow(blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
|
||||||
|
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlideshowWithCaption creates a media slideshow with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func SlideshowWithCaption(caption tgapi.RichBlockCaption, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockSlideshow {
|
||||||
|
return tgapi.InputRichBlockSlideshow{Type: tgapi.InputRichTypeSlideshow, Blocks: blocks, Caption: &caption}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableCell builds a rich-message table cell.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type TableCell struct {
|
||||||
|
text tgapi.RichText
|
||||||
|
isHeader bool
|
||||||
|
colSpan int
|
||||||
|
rowSpan int
|
||||||
|
align string
|
||||||
|
vAlign string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell creates an empty table-cell builder.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Cell() *TableCell { return &TableCell{} }
|
||||||
|
|
||||||
|
// CellWithText creates a table-cell builder containing text.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func CellWithText(text tgapi.RichText) *TableCell { return &TableCell{text: text} }
|
||||||
|
|
||||||
|
// SetText replaces the text in the table cell.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) SetText(text tgapi.RichText) *TableCell {
|
||||||
|
c.text = text
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHeader marks the cell as a table header.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) SetHeader() *TableCell {
|
||||||
|
c.isHeader = true
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSpan sets the cell's column and row spans.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) SetSpan(col, row int) *TableCell {
|
||||||
|
c.colSpan = col
|
||||||
|
c.rowSpan = row
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAlign sets horizontal alignment to left, center, or right.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) SetAlign(align string) *TableCell {
|
||||||
|
c.align = align
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVAlign sets vertical alignment to top, middle, or bottom.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) SetVAlign(vAlign string) *TableCell {
|
||||||
|
c.vAlign = vAlign
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the configured rich-message table cell.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (c *TableCell) Build() tgapi.RichBlockTableCell {
|
||||||
|
return tgapi.RichBlockTableCell{
|
||||||
|
Text: c.text,
|
||||||
|
IsHeader: c.isHeader,
|
||||||
|
ColSpan: c.colSpan,
|
||||||
|
RowSpan: c.rowSpan,
|
||||||
|
Align: c.align,
|
||||||
|
VAlign: c.vAlign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row creates a table row containing cells.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Row(cells ...tgapi.RichBlockTableCell) []tgapi.RichBlockTableCell {
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
// RichTable builds an input rich-message table block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
type RichTable struct {
|
||||||
|
// Cells contains table rows and their cells.
|
||||||
|
Cells [][]tgapi.RichBlockTableCell
|
||||||
|
// IsBordered reports whether the table has borders.
|
||||||
|
IsBordered bool
|
||||||
|
// IsStriped reports whether the table has striped rows.
|
||||||
|
IsStriped bool
|
||||||
|
// Caption is the optional table caption.
|
||||||
|
Caption *tgapi.RichText
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTable creates a table builder containing rows.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func NewTable(rows ...[]tgapi.RichBlockTableCell) *RichTable {
|
||||||
|
return &RichTable{Cells: rows}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRows appends rows to the table.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (t *RichTable) AddRows(rows ...[]tgapi.RichBlockTableCell) *RichTable {
|
||||||
|
t.Cells = append(t.Cells, rows...)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBordered controls whether the table has borders.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (t *RichTable) SetBordered(b bool) *RichTable {
|
||||||
|
t.IsBordered = b
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStriped controls whether the table has striped rows.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (t *RichTable) SetStriped(b bool) *RichTable {
|
||||||
|
t.IsStriped = b
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCaption sets the table caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (t *RichTable) SetCaption(cap *tgapi.RichText) *RichTable {
|
||||||
|
t.Caption = cap
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build returns the configured input rich-message table.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func (t *RichTable) Build() tgapi.InputRichBlockTable {
|
||||||
|
return tgapi.InputRichBlockTable{
|
||||||
|
Type: tgapi.InputRichTypeTable,
|
||||||
|
Cells: t.Cells,
|
||||||
|
IsBordered: t.IsBordered,
|
||||||
|
IsStriped: t.IsStriped,
|
||||||
|
Caption: t.Caption,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details creates a collapsed details block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Details(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
|
||||||
|
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailsOpen creates a details block expanded by default.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func DetailsOpen(sum tgapi.RichText, blocks ...tgapi.InputRichBlock) tgapi.InputRichBlockDetails {
|
||||||
|
return tgapi.InputRichBlockDetails{Type: tgapi.InputRichTypeDetails, Summary: sum, Blocks: blocks, IsOpen: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map creates a map block centered on loc.
|
||||||
|
//
|
||||||
|
// Zoom accepts 0-24; width and height accept 0-10000 subject to Telegram's
|
||||||
|
// total-size and aspect-ratio restrictions.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Map(loc tgapi.Location, zoom uint8, width, height uint16) tgapi.InputRichBlockMap {
|
||||||
|
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapWithCaption creates a map block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func MapWithCaption(loc tgapi.Location, zoom uint8, width, height uint16, caption tgapi.RichBlockCaption) tgapi.InputRichBlockMap {
|
||||||
|
return tgapi.InputRichBlockMap{Type: tgapi.InputRichTypeMap, Location: loc, Zoom: zoom, Width: width, Height: height, Caption: &caption}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation creates an animation block without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Animation(animation tgapi.InputMedia) tgapi.InputRichBlockAnimation {
|
||||||
|
animation.Type = tgapi.InputMediaTypeAnimation
|
||||||
|
return tgapi.InputRichBlockAnimation{Type: tgapi.InputRichTypeAnimation, Animation: animation}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnimationWithCaption creates an animation block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func AnimationWithCaption(animation tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAnimation {
|
||||||
|
block := Animation(animation)
|
||||||
|
block.Caption = &caption
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio creates a music-file block without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Audio(audio tgapi.InputMedia) tgapi.InputRichBlockAudio {
|
||||||
|
audio.Type = tgapi.InputMediaTypeAudio
|
||||||
|
return tgapi.InputRichBlockAudio{Type: tgapi.InputRichTypeAudio, Audio: audio}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioWithCaption creates a music-file block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func AudioWithCaption(audio tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockAudio {
|
||||||
|
block := Audio(audio)
|
||||||
|
block.Caption = &caption
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo creates a photo block without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Photo(photo tgapi.InputMedia) tgapi.InputRichBlockPhoto {
|
||||||
|
photo.Type = tgapi.InputMediaTypePhoto
|
||||||
|
return tgapi.InputRichBlockPhoto{Type: tgapi.InputRichTypePhoto, Photo: photo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhotoWithCaption creates a photo block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func PhotoWithCaption(photo tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockPhoto {
|
||||||
|
block := Photo(photo)
|
||||||
|
block.Caption = &caption
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video creates a video block without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Video(video tgapi.InputMedia) tgapi.InputRichBlockVideo {
|
||||||
|
video.Type = tgapi.InputMediaTypeVideo
|
||||||
|
return tgapi.InputRichBlockVideo{Type: tgapi.InputRichTypeVideo, Video: video}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VideoWithCaption creates a video block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func VideoWithCaption(video tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVideo {
|
||||||
|
block := Video(video)
|
||||||
|
block.Caption = &caption
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceNote creates a voice-note block without a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func VoiceNote(voiceNote tgapi.InputMedia) tgapi.InputRichBlockVoiceNote {
|
||||||
|
voiceNote.Type = tgapi.InputMediaTypeVoiceNote
|
||||||
|
return tgapi.InputRichBlockVoiceNote{Type: tgapi.InputRichTypeVoiceNote, VoiceNote: voiceNote}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VoiceNoteWithCaption creates a voice-note block with a caption.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func VoiceNoteWithCaption(voiceNote tgapi.InputMedia, caption tgapi.RichBlockCaption) tgapi.InputRichBlockVoiceNote {
|
||||||
|
block := VoiceNote(voiceNote)
|
||||||
|
block.Caption = &caption
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking creates a draft-only thinking placeholder block.
|
||||||
|
//
|
||||||
|
// Since: Bot API 10.2
|
||||||
|
func Thinking(text tgapi.RichText) tgapi.InputRichBlockThinking {
|
||||||
|
return tgapi.InputRichBlockThinking{Type: tgapi.InputRichTypeThinking, Text: text}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaConstructorsSetTypes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
blockType tgapi.InputRichType
|
||||||
|
mediaType tgapi.InputMediaType
|
||||||
|
}{
|
||||||
|
{"animation", Animation(tgapi.InputMedia{Media: "animation"}).Type, Animation(tgapi.InputMedia{Media: "animation"}).Animation.Type},
|
||||||
|
{"audio", Audio(tgapi.InputMedia{Media: "audio"}).Type, Audio(tgapi.InputMedia{Media: "audio"}).Audio.Type},
|
||||||
|
{"photo", Photo(tgapi.InputMedia{Media: "photo"}).Type, Photo(tgapi.InputMedia{Media: "photo"}).Photo.Type},
|
||||||
|
{"video", Video(tgapi.InputMedia{Media: "video"}).Type, Video(tgapi.InputMedia{Media: "video"}).Video.Type},
|
||||||
|
{"voice note", VoiceNote(tgapi.InputMedia{Media: "voice"}).Type, VoiceNote(tgapi.InputMedia{Media: "voice"}).VoiceNote.Type},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if string(tt.blockType) != string(tt.mediaType) {
|
||||||
|
t.Errorf("block type = %q, media type = %q", tt.blockType, tt.mediaType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaConstructorWithCaption(t *testing.T) {
|
||||||
|
caption := tgapi.RichBlockCaption{Text: tgapi.RichTextPlain("caption")}
|
||||||
|
if block := AnimationWithCaption(tgapi.InputMedia{Media: "animation"}, caption); block.Caption == nil || *block.Caption != caption {
|
||||||
|
t.Fatal("AnimationWithCaption did not preserve the caption")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOlPreservesCheckboxState(t *testing.T) {
|
||||||
|
item := NewListItem(P(Text("item"))).SetCheckbox().SetChecked().Build()
|
||||||
|
list := Ol(OlOpts{}, item)
|
||||||
|
if len(list.Items) != 1 || !list.Items[0].HasCheckbox || !list.Items[0].IsChecked {
|
||||||
|
t.Fatalf("Ol() item = %#v", list.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func escapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func formatAttrs(attrs map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(attrs))
|
||||||
|
for k, v := range attrs {
|
||||||
|
if v == "" {
|
||||||
|
out = append(out, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf(`%s="%s"`, k, escapeHTML(v)))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTag(name string, attrs []string) string {
|
||||||
|
if len(attrs) == 0 {
|
||||||
|
return "<" + name + ">"
|
||||||
|
}
|
||||||
|
return "<" + name + " " + strings.Join(attrs, " ") + ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfClosingTag(name string, attrs []string) string {
|
||||||
|
if len(attrs) == 0 {
|
||||||
|
return "<" + name + "/>"
|
||||||
|
}
|
||||||
|
return "<" + name + " " + strings.Join(attrs, " ") + "/>"
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package tgrich
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type richValidator struct {
|
||||||
|
chars int
|
||||||
|
blocks int
|
||||||
|
media int
|
||||||
|
allowThinking bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRichBlocks(blocks []tgapi.InputRichBlock, allowThinking bool) error {
|
||||||
|
v := &richValidator{allowThinking: allowThinking}
|
||||||
|
for _, block := range blocks {
|
||||||
|
if err := v.block(block, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) addChars(s string) error {
|
||||||
|
v.chars += utf8.RuneCountInString(s)
|
||||||
|
if v.chars > maxRichTextChars {
|
||||||
|
return ErrRichTextTooLong
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) addBlocks(n int) error {
|
||||||
|
v.blocks += n
|
||||||
|
if v.blocks > maxRichBlocks {
|
||||||
|
return ErrRichTooManyBlocks
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) addMedia(media tgapi.InputMedia, want tgapi.InputMediaType) error {
|
||||||
|
if media.Type != want || media.Media == "" {
|
||||||
|
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidMedia, media.Type, want)
|
||||||
|
}
|
||||||
|
v.media++
|
||||||
|
if v.media > maxRichMedia {
|
||||||
|
return ErrRichTooManyMedia
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectBlockType(got, want tgapi.InputRichType) error {
|
||||||
|
if got != want {
|
||||||
|
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidBlockType, got, want)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validListItemType(t tgapi.RichBlockListItemType) bool {
|
||||||
|
switch t {
|
||||||
|
case tgapi.InputRichBlockListItemTypeLower,
|
||||||
|
tgapi.InputRichBlockListItemTypeUpper,
|
||||||
|
tgapi.InputRichBlockListItemTypeRomanLow,
|
||||||
|
tgapi.InputRichBlockListItemTypeRomanUpper,
|
||||||
|
tgapi.InputRichBlockListItemTypeDecimal:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTableCell(cell tgapi.RichBlockTableCell) error {
|
||||||
|
if cell.ColSpan < 0 || cell.RowSpan < 0 {
|
||||||
|
return ErrRichInvalidTableCell
|
||||||
|
}
|
||||||
|
switch cell.Align {
|
||||||
|
case "", "left", "center", "right":
|
||||||
|
default:
|
||||||
|
return ErrRichInvalidTableCell
|
||||||
|
}
|
||||||
|
switch cell.VAlign {
|
||||||
|
case "", "top", "middle", "bottom":
|
||||||
|
default:
|
||||||
|
return ErrRichInvalidTableCell
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMap(block tgapi.InputRichBlockMap) error {
|
||||||
|
if math.IsNaN(block.Location.Latitude) || math.IsInf(block.Location.Latitude, 0) ||
|
||||||
|
math.IsNaN(block.Location.Longitude) || math.IsInf(block.Location.Longitude, 0) ||
|
||||||
|
block.Location.Latitude < -90 || block.Location.Latitude > 90 ||
|
||||||
|
block.Location.Longitude < -180 || block.Location.Longitude > 180 ||
|
||||||
|
block.Zoom > 24 || block.Width > 10000 || block.Height > 10000 ||
|
||||||
|
uint32(block.Width)+uint32(block.Height) > 10000 {
|
||||||
|
return ErrRichInvalidMap
|
||||||
|
}
|
||||||
|
if block.Width != 0 && block.Height != 0 {
|
||||||
|
longer := float64(block.Width)
|
||||||
|
shorter := float64(block.Height)
|
||||||
|
if longer < shorter {
|
||||||
|
longer, shorter = shorter, longer
|
||||||
|
}
|
||||||
|
if longer/shorter > 20 {
|
||||||
|
return ErrRichInvalidMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) text(text tgapi.RichText, depth int) error {
|
||||||
|
switch el := text.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case tgapi.RichTextPlain:
|
||||||
|
return v.addChars(string(el))
|
||||||
|
case tgapi.RichTextArray:
|
||||||
|
for _, item := range el {
|
||||||
|
if err := v.text(item, depth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case tgapi.RichTextCustomEmoji:
|
||||||
|
return v.addChars(el.AlternativeText)
|
||||||
|
case tgapi.RichTextMathematicalExpression:
|
||||||
|
return v.addChars(el.Expression)
|
||||||
|
case tgapi.RichTextAnchor:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth >= maxRichDepth {
|
||||||
|
return ErrRichNestingTooDeep
|
||||||
|
}
|
||||||
|
|
||||||
|
var child tgapi.RichText
|
||||||
|
switch el := text.(type) {
|
||||||
|
case tgapi.RichTextWrap:
|
||||||
|
case tgapi.RichTextURL:
|
||||||
|
case tgapi.RichTextEmailAddress:
|
||||||
|
case tgapi.RichTextPhoneNumber:
|
||||||
|
case tgapi.RichTextBankCardNumber:
|
||||||
|
if err := validateAutomaticEntity(el.Text, el.BankCardNumber); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tgapi.RichTextMention:
|
||||||
|
if err := validateAutomaticEntity(el.Text, "@"+strings.TrimPrefix(el.Username, "@")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tgapi.RichTextHashtag:
|
||||||
|
if err := validateAutomaticEntity(el.Text, "#"+strings.TrimPrefix(el.Hashtag, "#")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tgapi.RichTextCashtag:
|
||||||
|
if err := validateAutomaticEntity(el.Text, "$"+strings.TrimPrefix(el.Cashtag, "$")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tgapi.RichTextBotCommand:
|
||||||
|
if err := validateAutomaticEntity(el.Text, "/"+strings.TrimPrefix(el.BotCommand, "/")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tgapi.RichTextAnchorLink:
|
||||||
|
case tgapi.RichTextReference:
|
||||||
|
case tgapi.RichTextReferenceLink:
|
||||||
|
case tgapi.RichTextDateTime:
|
||||||
|
case tgapi.RichTextTextMention:
|
||||||
|
child = el.Text
|
||||||
|
default:
|
||||||
|
return ErrRichUnknownTag
|
||||||
|
}
|
||||||
|
return v.text(child, depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutomaticEntity(text tgapi.RichText, semantic string) error {
|
||||||
|
visible, err := visibleRichText(text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if visible != semantic {
|
||||||
|
return fmt.Errorf("%w: visible %q, semantic %q", ErrRichEntityMismatch, visible, semantic)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleRichText(text tgapi.RichText) (string, error) {
|
||||||
|
switch el := text.(type) {
|
||||||
|
case nil:
|
||||||
|
return "", nil
|
||||||
|
case tgapi.RichTextPlain:
|
||||||
|
return string(el), nil
|
||||||
|
case tgapi.RichTextArray:
|
||||||
|
var result strings.Builder
|
||||||
|
for _, item := range el {
|
||||||
|
part, err := visibleRichText(item)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
result.WriteString(part)
|
||||||
|
}
|
||||||
|
return result.String(), nil
|
||||||
|
case tgapi.RichTextCustomEmoji:
|
||||||
|
return el.AlternativeText, nil
|
||||||
|
case tgapi.RichTextMathematicalExpression:
|
||||||
|
return el.Expression, nil
|
||||||
|
case tgapi.RichTextAnchor:
|
||||||
|
return "", nil
|
||||||
|
case tgapi.RichTextWrap:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextURL:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextEmailAddress:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextPhoneNumber:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextBankCardNumber:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextMention:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextHashtag:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextCashtag:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextBotCommand:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextAnchorLink:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextReference:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextReferenceLink:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextDateTime:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
case tgapi.RichTextTextMention:
|
||||||
|
return visibleRichText(el.Text)
|
||||||
|
default:
|
||||||
|
return "", ErrRichUnknownTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) caption(caption *tgapi.RichBlockCaption, depth int) error {
|
||||||
|
if caption == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := v.text(caption.Text, depth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.text(caption.Credit, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) nested(blocks []tgapi.InputRichBlock, depth int) error {
|
||||||
|
for _, block := range blocks {
|
||||||
|
if err := v.block(block, depth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
|
||||||
|
if depth >= maxRichDepth {
|
||||||
|
return ErrRichNestingTooDeep
|
||||||
|
}
|
||||||
|
if err := v.addBlocks(1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch el := block.(type) {
|
||||||
|
case tgapi.InputRichBlockParagraph:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeParagraph); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.text(el.Text, depth+1)
|
||||||
|
case tgapi.InputRichBlockSectionHeading:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeSectionHeading); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if el.Size < 1 || el.Size > 6 {
|
||||||
|
return ErrRichInvalidHeading
|
||||||
|
}
|
||||||
|
return v.text(el.Text, depth+1)
|
||||||
|
case tgapi.InputRichBlockPreformatted:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypePre); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.text(el.Text, depth+1)
|
||||||
|
case tgapi.InputRichBlockFooter:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeFooter); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.text(el.Text, depth+1)
|
||||||
|
case tgapi.InputRichBlockDivider:
|
||||||
|
return expectBlockType(el.Type, tgapi.InputRichTypeDivider)
|
||||||
|
case tgapi.InputRichBlockAnchor:
|
||||||
|
return expectBlockType(el.Type, tgapi.InputRichTypeAnchor)
|
||||||
|
case tgapi.InputRichBlockMath:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeMathematicalExpression); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.addChars(el.Expression)
|
||||||
|
case tgapi.InputRichBlockList:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeList); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addBlocks(len(el.Items)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ordered := false
|
||||||
|
unordered := false
|
||||||
|
for _, item := range el.Items {
|
||||||
|
if item.IsChecked && !item.HasCheckbox {
|
||||||
|
return ErrRichInvalidCheckbox
|
||||||
|
}
|
||||||
|
if item.Type == "" {
|
||||||
|
unordered = true
|
||||||
|
if item.Value != 0 {
|
||||||
|
return ErrRichInvalidListItem
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ordered = true
|
||||||
|
if !validListItemType(item.Type) {
|
||||||
|
return ErrRichInvalidListItemType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := v.nested(item.Blocks, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ordered && unordered {
|
||||||
|
return ErrRichListItemMix
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case tgapi.InputRichBlockBlockQuotation:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeBlockQuotation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.textValue(el.Credit, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.nested(el.Blocks, depth+1)
|
||||||
|
case tgapi.InputRichBlockPullQuotation:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypePullQuotation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.text(el.Text, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.textValue(el.Credit, depth+1)
|
||||||
|
case tgapi.InputRichBlockCollage:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeCollage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.caption(el.Caption, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.nested(el.Blocks, depth+1)
|
||||||
|
case tgapi.InputRichBlockSlideshow:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeSlideshow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.caption(el.Caption, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.nested(el.Blocks, depth+1)
|
||||||
|
case tgapi.InputRichBlockTable:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeTable); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addBlocks(len(el.Cells)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.textValue(el.Caption, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, row := range el.Cells {
|
||||||
|
columns := 0
|
||||||
|
for _, cell := range row {
|
||||||
|
if err := validateTableCell(cell); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
span := cell.ColSpan
|
||||||
|
if span < 1 {
|
||||||
|
span = 1
|
||||||
|
}
|
||||||
|
columns += span
|
||||||
|
if err := v.text(cell.Text, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if columns > maxTableColumns {
|
||||||
|
return ErrRichTableTooWide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case tgapi.InputRichBlockDetails:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeDetails); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.text(el.Summary, depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.nested(el.Blocks, depth+1)
|
||||||
|
case tgapi.InputRichBlockMap:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeMap); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateMap(el); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockAnimation:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeAnimation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addMedia(el.Animation, tgapi.InputMediaTypeAnimation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockAudio:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeAudio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addMedia(el.Audio, tgapi.InputMediaTypeAudio); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockPhoto:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypePhoto); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addMedia(el.Photo, tgapi.InputMediaTypePhoto); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockVideo:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeVideo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addMedia(el.Video, tgapi.InputMediaTypeVideo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockVoiceNote:
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeVoiceNote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.addMedia(el.VoiceNote, tgapi.InputMediaTypeVoiceNote); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.caption(el.Caption, depth+1)
|
||||||
|
case tgapi.InputRichBlockThinking:
|
||||||
|
if !v.allowThinking {
|
||||||
|
return ErrRichThinkingDraftOnly
|
||||||
|
}
|
||||||
|
if err := expectBlockType(el.Type, tgapi.InputRichTypeThinking); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.text(el.Text, depth+1)
|
||||||
|
default:
|
||||||
|
return ErrRichUnknownTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *richValidator) textValue(text *tgapi.RichText, depth int) error {
|
||||||
|
if text == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.text(*text, depth)
|
||||||
|
}
|
||||||
+37
-3
@@ -130,6 +130,20 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) {
|
|||||||
from = u.EditedBusinessMessage.From
|
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:
|
case tgapi.UpdateTypeInlineQuery:
|
||||||
if u.InlineQuery != nil {
|
if u.InlineQuery != nil {
|
||||||
from = &u.InlineQuery.From
|
from = &u.InlineQuery.From
|
||||||
@@ -187,23 +201,43 @@ func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MessageContext) {
|
|||||||
}
|
}
|
||||||
case tgapi.UpdateTypePollAnswer:
|
case tgapi.UpdateTypePollAnswer:
|
||||||
if u.PollAnswer != nil {
|
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:
|
case tgapi.UpdateTypeMessageReaction:
|
||||||
if u.MessageReaction != nil {
|
if u.MessageReaction != nil {
|
||||||
from = u.MessageReaction.User
|
from = u.MessageReaction.User
|
||||||
chat = u.MessageReaction.Chat
|
chat = u.MessageReaction.Chat
|
||||||
}
|
}
|
||||||
|
case tgapi.UpdateTypeMessageReactionCount:
|
||||||
|
if u.MessageReactionCount != nil {
|
||||||
|
chat = u.MessageReactionCount.Chat
|
||||||
|
}
|
||||||
case tgapi.UpdateTypeChatBoost:
|
case tgapi.UpdateTypeChatBoost:
|
||||||
if u.ChatBoost != nil {
|
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
|
chat = &u.ChatBoost.Chat
|
||||||
}
|
}
|
||||||
case tgapi.UpdateTypeRemovedChatBoost:
|
case tgapi.UpdateTypeRemovedChatBoost:
|
||||||
if u.RemovedChatBoost != nil {
|
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
|
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 {
|
if ctx.Msg != nil && from == nil {
|
||||||
from = ctx.Msg.From
|
from = ctx.Msg.From
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Ptr returns a pointer to v.
|
// 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.
|
// Val returns dereferenced pointer value or def when p is nil.
|
||||||
func Val[T any](p *T, def T) T {
|
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 {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return base64.URLEncoding.EncodeToString(bytes), nil
|
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-9
@@ -157,9 +157,8 @@ func (rl *RateLimiter) GlobalAllow() bool {
|
|||||||
return limiter.Allow()
|
return limiter.Allow()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow checks if a request for the given chat can be made without blocking.
|
// Allow checks whether a request for the given chat can be made without blocking.
|
||||||
// Returns false if: global cooldown, chat cooldown, global limiter, or chat limiter denies.
|
// A rejected chat reservation does not consume global capacity.
|
||||||
// Note: Global limiter is checked before chat limiter — upstream limits take priority.
|
|
||||||
func (rl *RateLimiter) Allow(chatID int64) bool {
|
func (rl *RateLimiter) Allow(chatID int64) bool {
|
||||||
// Check global cooldown
|
// Check global cooldown
|
||||||
rl.globalMu.RLock()
|
rl.globalMu.RLock()
|
||||||
@@ -177,15 +176,27 @@ func (rl *RateLimiter) Allow(chatID int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check global token bucket
|
now := time.Now()
|
||||||
limiter := rl.getGlobalLimiter()
|
globalLimiter := rl.getGlobalLimiter()
|
||||||
if limiter != nil && !limiter.Allow() {
|
var globalReservation *rate.Reservation
|
||||||
return false
|
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)
|
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.
|
// Check applies rate limiting based on configuration.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) {
|
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) {
|
func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) {
|
||||||
rl := NewRateLimiter()
|
rl := NewRateLimiter()
|
||||||
rl.SetGlobalLock(1)
|
rl.SetGlobalLock(1)
|
||||||
|
|||||||
+3
-3
@@ -2,13 +2,13 @@ package utils
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// VersionString is the module version string.
|
// VersionString is the module version string.
|
||||||
VersionString = "1.0.2"
|
VersionString = "1.1.0"
|
||||||
// VersionMajor is the module major version.
|
// VersionMajor is the module major version.
|
||||||
VersionMajor = 1
|
VersionMajor = 1
|
||||||
// VersionMinor is the module minor version.
|
// VersionMinor is the module minor version.
|
||||||
VersionMinor = 0
|
VersionMinor = 1
|
||||||
// VersionPatch is the module patch version.
|
// VersionPatch is the module patch version.
|
||||||
VersionPatch = 2
|
VersionPatch = 0
|
||||||
// VersionBeta is the prerelease counter for the current version.
|
// VersionBeta is the prerelease counter for the current version.
|
||||||
VersionBeta = 0
|
VersionBeta = 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package laniakea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateTokenUsesRawURLAlphabet(t *testing.T) {
|
||||||
|
token, err := generateToken(32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(token, "=") {
|
||||||
|
t.Fatalf("token contains forbidden padding: %q", token)
|
||||||
|
}
|
||||||
|
for _, r := range token {
|
||||||
|
if !strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-", r) {
|
||||||
|
t.Fatalf("token contains a character forbidden by Telegram: %q", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user