From 1e043da05df7773f741073c5ed699708e52fd498 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 17 Mar 2026 13:21:06 +0300 Subject: [PATCH] release: 1.0.0 beta 22 Implemented full tgapi method coverage from Telegram docs, aligned numeric ID/file_size types, and fixed method signatures/JSON tags.; Standardized GoDoc across exported APIs with Telegram links and refreshed README sections for MsgContext plus API/Uploader usage. --- README.md | 17 +++-- bot.go | 25 ++++++-- bot_opts.go | 2 +- cmd_generator.go | 29 ++------- drafts.go | 21 +++---- handler.go | 17 ++++- keyboard.go | 4 +- l10n.go | 2 +- msg_context.go | 83 +++++++++++++++++++------ plugins.go | 15 +---- runners.go | 7 ++- tgapi/api.go | 10 ++- tgapi/attachments_methods.go | 8 +-- tgapi/attachments_types.go | 2 +- tgapi/bot_methods.go | 10 +-- tgapi/bot_types.go | 4 +- tgapi/business_methods.go | 57 ++++++++++------- tgapi/business_types.go | 2 +- tgapi/chat_methods.go | 40 +++++++----- tgapi/chat_types.go | 8 +-- tgapi/errors.go | 1 + tgapi/games_methods.go | 69 +++++++++++++++++++++ tgapi/games_types.go | 9 +++ tgapi/inline_methods.go | 52 ++++++++++++++++ tgapi/inline_types.go | 26 ++++++++ tgapi/messages_methods.go | 66 +++++++++++--------- tgapi/messages_types.go | 21 ++++--- tgapi/methods.go | 61 ++++++++++++------ tgapi/methods_types.go | 35 +++++++++++ tgapi/passport_methods.go | 16 +++++ tgapi/passport_types.go | 5 ++ tgapi/payments_methods.go | 117 +++++++++++++++++++++++++++++++++++ tgapi/payments_types.go | 16 +++++ tgapi/pool.go | 67 +++++++++++++++----- tgapi/stars_methods.go | 53 ++++++++++++++++ tgapi/stars_types.go | 18 ++++++ tgapi/stickers_methods.go | 31 +++++++--- tgapi/stickers_types.go | 2 +- tgapi/types.go | 6 +- tgapi/uploader_api.go | 46 +++++++++----- tgapi/uploader_methods.go | 44 ++++++------- tgapi/users_methods.go | 16 ++--- tgapi/users_types.go | 2 +- utils.go | 10 ++- utils/limiter.go | 47 +++++++++++--- utils/multipart.go | 1 + utils/utils.go | 1 + utils/version.go | 4 +- 48 files changed, 921 insertions(+), 284 deletions(-) create mode 100644 tgapi/games_methods.go create mode 100644 tgapi/games_types.go create mode 100644 tgapi/inline_methods.go create mode 100644 tgapi/inline_types.go create mode 100644 tgapi/methods_types.go create mode 100644 tgapi/passport_methods.go create mode 100644 tgapi/passport_types.go create mode 100644 tgapi/payments_methods.go create mode 100644 tgapi/payments_types.go create mode 100644 tgapi/stars_methods.go create mode 100644 tgapi/stars_types.go diff --git a/README.md b/README.md index 37cdfa5..fe175e7 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,22 @@ Provides access to the incoming message and useful reply methods: - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. -- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) with. +- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `EditCallback(text string)`: Edits message with parse_mode none after clicking inline button. - `EditCallbackMarkdown(text string)`: Edits a message formatted with MarkdownV2 (you handle escaping) after clicking inline button. -- `SendChatAction(action string)`: Sends a “typing”, “uploading photo”, etc., action. -- Fields: `Text`, `Args`, `From`, `Chat`, `Msg`, etc. +- `SendAction(action tgapi.ChatActionType)`: Sends a “typing”, “uploading photo”, etc., action. +- Fields: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId`, etc. - And more methods and fields! +### tgapi: API and Uploader + +`tgapi` provides two clients: + +- `API` for JSON requests (e.g., `SendMessage`, `EditMessageText`, methods using file_id/URL). +- `Uploader` for multipart uploads (e.g., `SendPhoto`, `SendDocument`, `SendVideo` with binary files). + +This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. + ### Database Context The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. @@ -193,7 +202,7 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool { - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. ## ⚙️ Advanced Configuration -- **Inline Keyboards**: Build keyboards using laniakea.NewKeyboard(). +- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. diff --git a/bot.go b/bot.go index 57ec81e..bf077ba 100644 --- a/bot.go +++ b/bot.go @@ -81,6 +81,8 @@ type Bot[T DbContext] struct { updateOffset int // Last processed update ID updateTypes []tgapi.UpdateType // Types of updates to fetch updateQueue chan *tgapi.Update // Internal queue for processing updates + runnerOnceWG sync.WaitGroup // Tracks one-time async runners + runnerBgWG sync.WaitGroup // Tracks background async runners } // NewBot creates and initializes a new Bot instance using the provided BotOpts. @@ -107,11 +109,13 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { // limiter = utils.NewRateLimiter() //} limiter := utils.NewRateLimiter() + limiter.SetGlobalRate(opts.RateLimit) apiOpts := tgapi.NewAPIOpts(opts.Token). SetAPIUrl(opts.APIUrl). UseTestServer(opts.UseTestServer). - SetLimiter(limiter) + SetLimiter(limiter). + SetLimiterDrop(opts.DropRLOverflow) api := tgapi.NewAPI(apiOpts) uploader := tgapi.NewUploader(api) @@ -137,7 +141,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] { prefixes: prefixes, token: opts.Token, plugins: make([]Plugin[T], 0), - updateTypes: make([]tgapi.UpdateType, 0), + updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), runners: make([]Runner[T], 0), extraLoggers: make([]*slog.Logger, 0), l10n: &L10n{}, @@ -285,9 +289,9 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] { return bot } -// SetPayloadType sets the type, that bot will use for payload -// json - string `{"cmd": "command", "args": [...]} -// base64 - same json, but encoded in base64 string +// SetPayloadType sets the payload encoding type used for callback data. +// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. +// Base64 stores the same JSON encoded as a Base64URL string. func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { bot.payloadType = t return bot @@ -309,7 +313,7 @@ func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] { // ErrorTemplate sets the format string for error messages sent to users. // Use "%s" to insert the error message. -// Example: "❌ Error: %s" → "❌ Error: Command not found" +// Example: "❌ Error: %s" → "❌ Error: Command not found". func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] { bot.errorTemplate = s return bot @@ -408,6 +412,7 @@ func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] { if l == nil { bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled") + return bot } bot.l10n = l return bot @@ -457,6 +462,12 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { // // ... later ... // cancel() // triggers graceful shutdown func (bot *Bot[T]) RunWithContext(ctx context.Context) { + defer func() { + if err := bot.Close(); err != nil { + bot.logger.Errorln(err) + } + }() + if len(bot.prefixes) == 0 { bot.logger.Fatalln("no prefixes defined") return @@ -512,6 +523,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) { }) } pool.Stop() // Wait for all tasks to complete and stop the pool + bot.runnerOnceWG.Wait() + bot.runnerBgWG.Wait() } // Run starts the bot using a background context. diff --git a/bot_opts.go b/bot_opts.go index d9ef743..ef49264 100644 --- a/bot_opts.go +++ b/bot_opts.go @@ -119,7 +119,7 @@ func (opts *BotOpts) SetToken(token string) *BotOpts { // SetUpdateTypes sets the list of update types to listen for. // If empty (default), Telegram will return all update types. -// Example: opts.SetUpdateTypes("message", "callback_query") +// Example: opts.SetUpdateTypes("message", "callback_query"). func (opts *BotOpts) SetUpdateTypes(types ...tgapi.UpdateType) *BotOpts { opts.UpdateTypes = types return opts diff --git a/cmd_generator.go b/cmd_generator.go index 93aa287..388dfb0 100644 --- a/cmd_generator.go +++ b/cmd_generator.go @@ -9,6 +9,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// CmdRegexp matches command names allowed for Telegram command registration. var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") // ErrTooManyCommands is returned when the total number of registered commands @@ -19,21 +20,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") // bot initialization. var ErrTooManyCommands = errors.New("too many commands. max 100") -// generateBotCommand converts a Command[T] into a tgapi.BotCommand with a -// formatted description that includes usage instructions. -// -// The description is built as: -// -// ". Usage: / [] ..." -// -// Required arguments are shown as-is; optional arguments are wrapped in square brackets. -// -// Example: -// -// Command{command: "start", description: "Start the bot", args: []Arg{{text: "name", required: false}}} -// → Description: "Start the bot. Usage: /start [name]" -// Command{command: "echo", description: "Echo user input", args: []Arg{{text: "name", required: true}}} -// → Description: "Echo user input. Usage: /echo " +// generateBotCommand builds a BotCommand description with generated usage text. func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { desc := "" if len(cmd.description) > 0 { @@ -57,16 +44,10 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { return tgapi.BotCommand{Command: cmd.command, Description: usage} } -// checkCmdRegex check if command satisfy regexp [a-zA-Z0-9]+ -// Return true if satisfied, else false. +// checkCmdRegex reports whether cmd matches CmdRegexp. func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } -// gatherCommandsForPlugin collects all non-skipped commands from a Plugin[T] -// and converts them into tgapi.BotCommand objects. -// -// Commands marked with skipAutoCmd = true are excluded from auto-registration. -// This allows plugins to opt out of automatic command generation (e.g., for -// internal or hidden commands). +// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, cmd := range pl.commands { @@ -83,7 +64,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { // gatherCommands collects all commands from all plugins // and converts them into tgapi.BotCommand objects. -// See gatherCommandsForPlugin +// See gatherCommandsForPlugin. func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { commands := make([]tgapi.BotCommand, 0) for _, pl := range bot.plugins { diff --git a/drafts.go b/drafts.go index 4cb4203..ff49b59 100644 --- a/drafts.go +++ b/drafts.go @@ -9,6 +9,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. var ErrDraftChatIDZero = errors.New("zero draft chat ID") // draftIdGenerator defines an interface for generating unique draft IDs. @@ -90,27 +91,25 @@ func (p *DraftProvider) GetDraft(id uint64) (*Draft, bool) { // FlushAll sends all pending drafts as final messages and clears them. // -// If any draft fails to send, FlushAll returns the error immediately and -// leaves other drafts unflushed. This allows for retry logic or logging. +// If one or more drafts fail to send, FlushAll still attempts all drafts and +// returns the first encountered error. // // After successful flush, each draft is removed from the provider and cleared. func (p *DraftProvider) FlushAll() error { - p.mu.Lock() + p.mu.RLock() drafts := make([]*Draft, 0, len(p.drafts)) for _, draft := range p.drafts { drafts = append(drafts, draft) } - p.drafts = make(map[uint64]*Draft) - p.mu.Unlock() + p.mu.RUnlock() - var lastErr error + var firstErr error for _, draft := range drafts { - if err := draft.Flush(); err != nil { - lastErr = err - break // Stop on first error to avoid partial state + if err := draft.Flush(); err != nil && firstErr == nil { + firstErr = err } } - return lastErr + return firstErr } // Draft represents a single message draft that can be edited and flushed. @@ -165,7 +164,7 @@ func (d *Draft) SetChat(chatID int64, messageThreadID int) *Draft { // SetEntities replaces the draft's message entities. // // Entities are stored by reference. If you plan to mutate the slice later, -// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))` +// pass a copy: `SetEntities(append([]tgapi.MessageEntity{}, myEntities...))`. func (d *Draft) SetEntities(entities []tgapi.MessageEntity) *Draft { d.entities = entities return d diff --git a/handler.go b/handler.go index 7157e1a..1afa576 100644 --- a/handler.go +++ b/handler.go @@ -10,6 +10,7 @@ import ( "git.nix13.pw/scuroneko/laniakea/tgapi" ) +// ErrInvalidPayloadType is returned when callback payload encoding type is unknown. var ErrInvalidPayloadType = errors.New("invalid payload type") func (bot *Bot[T]) handle(u *tgapi.Update) { @@ -28,7 +29,9 @@ func (bot *Bot[T]) handle(u *tgapi.Update) { payloadType: bot.payloadType, } for _, middleware := range bot.middlewares { - middleware.Execute(ctx, bot.dbContext) + if !middleware.Execute(ctx, bot.dbContext) { + return + } } if u.CallbackQuery != nil { @@ -42,6 +45,9 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { if update.Message == nil { return } + if update.Message.From == nil { + return + } var text string if len(update.Message.Text) > 0 { @@ -106,8 +112,13 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) { ctx.FromID = update.CallbackQuery.From.ID ctx.From = &update.CallbackQuery.From - ctx.Msg = &update.CallbackQuery.Message - ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID + if update.CallbackQuery.Message != nil { + ctx.Msg = update.CallbackQuery.Message + ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID + } + if update.CallbackQuery.InlineMessageID != nil { + ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID + } ctx.CallbackQueryId = update.CallbackQuery.ID ctx.Args = data.Args diff --git a/keyboard.go b/keyboard.go index b1daafd..3ea98f6 100644 --- a/keyboard.go +++ b/keyboard.go @@ -69,7 +69,7 @@ func (b InlineKbButtonBuilder) SetUrl(url string) InlineKbButtonBuilder { // Args are converted to strings using fmt.Sprint. Non-string types (e.g., int, bool) // are safely serialized, but complex structs may not serialize usefully. // -// 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 InlineKbButtonBuilder) SetCallbackDataJson(cmd string, args ...any) InlineKbButtonBuilder { b.callbackData = NewCallbackData(cmd, args...).ToJson() return b @@ -210,7 +210,7 @@ func (in *InlineKeyboard) AddLine() *InlineKeyboard { // Returns a pointer to a ReplyMarkup suitable for use with tgapi.SendMessage. func (in *InlineKeyboard) Get() *tgapi.ReplyMarkup { if in.CurrentLine.Len() > 0 { - in.Lines = append(in.Lines, in.CurrentLine) + in.AddLine() } return &tgapi.ReplyMarkup{InlineKeyboard: in.Lines} } diff --git a/l10n.go b/l10n.go index 35fd5e8..9f2268e 100644 --- a/l10n.go +++ b/l10n.go @@ -1,7 +1,7 @@ package laniakea // DictEntry represents a single localized entry with language-to-text mappings. -// Example: {"ru": "Привет", "en": "Hello"} +// Example: {"ru": "Привет", "en": "Hello"}. type DictEntry map[string]string // L10n is a localization manager that maps keys to language-specific strings. diff --git a/msg_context.go b/msg_context.go index 222923f..b287d95 100644 --- a/msg_context.go +++ b/msg_context.go @@ -19,9 +19,10 @@ type MsgContext struct { Msg *tgapi.Message From *tgapi.User + InlineMsgId string CallbackMsgId int CallbackQueryId string - FromID int + FromID int64 Prefix string Text string Args []string @@ -46,11 +47,19 @@ type AnswerMessage struct { // Used by Edit, EditMarkdown, EditCallback, etc. func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { params := tgapi.EditMessageTextP{ - MessageID: messageId, - ChatID: ctx.Msg.Chat.ID, Text: text, ParseMode: parseMode, } + switch { + case messageId > 0 && ctx.Msg != nil: + params.MessageID = messageId + params.ChatID = ctx.Msg.Chat.ID + case ctx.InlineMsgId != "": + params.InlineMessageID = ctx.InlineMsgId + default: + ctx.botLogger.Errorln("Can't edit message: no valid message target") + return nil + } if keyboard != nil { params.ReplyMarkup = keyboard.Get() } @@ -59,8 +68,12 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard ctx.botLogger.Errorln(err) return nil } + resultMessageID := messageId + if msg.MessageID > 0 { + resultMessageID = msg.MessageID + } return &AnswerMessage{ - MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: false, + MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: false, } } @@ -79,9 +92,9 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage { } // editCallback is an internal helper to edit the message associated with a callback query. -// Returns nil if CallbackMsgId is 0 (not a callback context). +// Supports both regular callback messages and inline callback messages. func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { - if ctx.CallbackMsgId == 0 { + if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { ctx.botLogger.Errorln("Can't edit non-callback update message") return nil } @@ -113,18 +126,22 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb } // editPhotoText edits the caption of a photo/video message. -// Returns nil if messageId is 0. +// Returns nil when no valid edit target is available for the current context. func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { - if messageId == 0 { - ctx.botLogger.Errorln("Can't edit caption message, message ID zero") - return nil - } params := tgapi.EditMessageCaptionP{ - ChatID: ctx.Msg.Chat.ID, - MessageID: messageId, Caption: text, ParseMode: parseMode, } + switch { + case messageId > 0 && ctx.Msg != nil: + params.ChatID = ctx.Msg.Chat.ID + params.MessageID = messageId + case ctx.InlineMsgId != "": + params.InlineMessageID = ctx.InlineMsgId + default: + ctx.botLogger.Errorln("Can't edit caption: no valid message target") + return nil + } if kb != nil { params.ReplyMarkup = kb.Get() } @@ -132,9 +149,14 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo msg, _, err := ctx.Api.EditMessageCaption(params) if err != nil { ctx.botLogger.Errorln(err) + return nil + } + resultMessageID := messageId + if msg.MessageID > 0 { + resultMessageID = msg.MessageID } return &AnswerMessage{ - MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, + MessageID: resultMessageID, ctx: ctx, Text: text, IsMedia: true, } } @@ -165,6 +187,10 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo // answer sends a new message with optional keyboard and parse mode. // Uses API limiter to respect Telegram rate limits per chat. func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if ctx.Msg == nil { + ctx.botLogger.Errorln("Can't answer message without a message") + return nil + } params := tgapi.SendMessageP{ ChatID: ctx.Msg.Chat.ID, Text: text, @@ -180,11 +206,6 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID } - cont := context.Background() - if err := ctx.Api.Limiter.Wait(cont, ctx.Msg.Chat.ID); err != nil { - ctx.botLogger.Errorln(err) - return nil - } msg, err := ctx.Api.SendMessage(params) if err != nil { ctx.botLogger.Errorln(err) @@ -233,6 +254,10 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) * // answerPhoto sends a photo with optional caption and keyboard. func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { + if ctx.Msg == nil { + ctx.botLogger.Errorln("Can't answer message without a message") + return nil + } params := tgapi.SendPhotoP{ ChatID: ctx.Msg.Chat.ID, Caption: text, @@ -294,6 +319,14 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an // delete removes a message by ID. func (ctx *MsgContext) delete(messageId int) { + if messageId == 0 { + ctx.botLogger.Errorln("Can't delete message: message ID zero") + return + } + if ctx.Msg == nil { + ctx.botLogger.Errorln("Can't delete message: no chat message context") + return + } _, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ ChatID: ctx.Msg.Chat.ID, MessageID: messageId, @@ -307,7 +340,13 @@ func (ctx *MsgContext) delete(messageId int) { func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) } // CallbackDelete deletes the message that triggered the callback query. -func (ctx *MsgContext) CallbackDelete() { ctx.delete(ctx.CallbackMsgId) } +func (ctx *MsgContext) CallbackDelete() { + if ctx.CallbackMsgId == 0 { + ctx.botLogger.Errorln("Can't delete callback message: no callback message ID") + return + } + ctx.delete(ctx.CallbackMsgId) +} // answerCallbackQuery sends a response to a callback query (optional text/alert/url). // Does nothing if CallbackQueryId is empty. @@ -338,6 +377,10 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, " // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { + if ctx.Msg == nil { + ctx.botLogger.Errorln("Can't send action without chat message context") + return + } params := tgapi.SendChatActionP{ ChatID: ctx.Msg.Chat.ID, Action: action, } diff --git a/plugins.go b/plugins.go index 7b80378..091e449 100644 --- a/plugins.go +++ b/plugins.go @@ -26,7 +26,7 @@ var ( CommandRegexInt = regexp.MustCompile(`\d+`) // CommandRegexString matches any non-empty string. CommandRegexString = regexp.MustCompile(`.+`) - // CommandRegexBool matches true or false + // CommandRegexBool matches true or false. CommandRegexBool = regexp.MustCompile(`true|false`) ) @@ -53,6 +53,7 @@ func NewCommandArg(text string) *CommandArg { return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} } +// SetValueType sets expected value type and switches built-in validation regexp. func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { regex := CommandRegexString switch t { @@ -96,7 +97,7 @@ func NewCommand[T any](exec CommandExecutor[T], command string, args ...CommandA } // NewPayload creates a new Command with the given executor, command payload string, and arguments. -// The command string can POTENTIALLY contain any symbols, but recommended to use only "_", "-", ".", a-Z, 0-9 +// The command string can contain any symbols, but it is recommended to use only "_", "-", ".", a-z, A-Z, and 0-9. func NewPayload[T any](exec CommandExecutor[T], command string, args ...CommandArg) *Command[T] { return &Command[T]{command, "", exec, args, make(extypes.Slice[Middleware[T]], 0), false} } @@ -223,11 +224,6 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { return } - // Run plugin middlewares - if !p.executeMiddlewares(ctx, dbContext) { - return - } - // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, dbContext) { @@ -254,11 +250,6 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T return } - // Run plugin middlewares - if !p.executeMiddlewares(ctx, dbContext) { - return - } - // Run command-specific middlewares for _, m := range command.middlewares { if !m.Execute(ctx, dbContext) { diff --git a/runners.go b/runners.go index ccfbdde..d64d3ab 100644 --- a/runners.go +++ b/runners.go @@ -98,12 +98,15 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { continue } if !runner.onetime && runner.async && runner.timeout == 0 { - bot.logger.Warnf("Background runner \"%s\" has no timeout — may cause tight loop\n", runner.name) + bot.logger.Warnf("Background runner \"%s\" has no timeout — skipping\n", runner.name) + continue } if runner.onetime && runner.async { // One-time async: fire and forget + bot.runnerOnceWG.Add(1) go func(r Runner[T]) { + defer bot.runnerOnceWG.Done() err := r.fn(bot) if err != nil { bot.logger.Warnf("Runner %s failed: %s\n", r.name, err) @@ -122,7 +125,9 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) { } } else if !runner.onetime && runner.async { // Background loop: periodic execution with graceful shutdown + bot.runnerBgWG.Add(1) go func(r Runner[T]) { + defer bot.runnerBgWG.Done() ticker := time.NewTicker(r.timeout) defer ticker.Stop() for { diff --git a/tgapi/api.go b/tgapi/api.go index 228e417..98e2c50 100644 --- a/tgapi/api.go +++ b/tgapi/api.go @@ -76,7 +76,11 @@ func (opts *APIOpts) SetLimiterDrop(b bool) *APIOpts { return opts } -// API is the main Telegram Bot API client. +// API is the main Telegram Bot API client for JSON requests. +// +// Use API methods when sending JSON payloads (for example with file_id, URL, or other +// non-multipart fields). For multipart file uploads, use Uploader. +// // It manages HTTP requests, rate limiting, retries, and connection pooling. type API struct { token string @@ -102,7 +106,7 @@ func NewAPI(opts *APIOpts) *API { } pool := newWorkerPool(16, 256) - pool.start(context.Background()) + pool.start() return &API{ token: opts.token, @@ -118,12 +122,14 @@ func NewAPI(opts *APIOpts) *API { // CloseApi shuts down the internal worker pool and closes the logger. // Must be called to avoid resource leaks. +// See https://core.telegram.org/bots/api func (api *API) CloseApi() error { api.pool.stop() return api.logger.Close() } // GetLogger returns the internal logger for custom logging. +// See https://core.telegram.org/bots/api func (api *API) GetLogger() *slog.Logger { return api.logger } diff --git a/tgapi/attachments_methods.go b/tgapi/attachments_methods.go index 9e7222a..7fff755 100644 --- a/tgapi/attachments_methods.go +++ b/tgapi/attachments_methods.go @@ -15,7 +15,7 @@ type SendPhotoP struct { ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` HasSpoiler bool `json:"has_spoiler,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` + DisableNotifications bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` @@ -107,7 +107,7 @@ type SendVideoP struct { Duration int `json:"duration,omitempty"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` - Cover int `json:"cover,omitempty"` + Cover string `json:"cover,omitempty"` StartTimestamp int `json:"start_timestamp,omitempty"` Caption string `json:"caption,omitempty"` @@ -276,7 +276,7 @@ type SendMediaGroupP struct { // SendMediaGroup sends a group of photos, videos, documents or audios as an album. // See https://core.telegram.org/bots/api#sendmediagroup -func (api *API) SendMediaGroup(params SendMediaGroupP) (Message, error) { - req := NewRequestWithChatID[Message]("sendMediaGroup", params, params.ChatID) +func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) { + req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) return req.Do(api) } diff --git a/tgapi/attachments_types.go b/tgapi/attachments_types.go index 37cc19d..b0d2a89 100644 --- a/tgapi/attachments_types.go +++ b/tgapi/attachments_types.go @@ -70,5 +70,5 @@ type PhotoSize struct { FileUniqueID string `json:"file_unique_id"` Width int `json:"width"` Height int `json:"height"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` } diff --git a/tgapi/bot_methods.go b/tgapi/bot_methods.go index 8946794..28e78f7 100644 --- a/tgapi/bot_methods.go +++ b/tgapi/bot_methods.go @@ -154,7 +154,7 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) { // SetChatMenuButtonP holds parameters for the setChatMenuButton method. // See https://core.telegram.org/bots/api#setchatmenubutton type SetChatMenuButtonP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MenuButton MenuButtonType `json:"menu_button"` } @@ -169,7 +169,7 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) { // GetChatMenuButtonP holds parameters for the getChatMenuButton method. // See https://core.telegram.org/bots/api#getchatmenubutton type GetChatMenuButtonP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } // GetChatMenuButton returns the current menu button for the given chat. @@ -217,8 +217,8 @@ func (api *API) GetAvailableGifts() (Gifts, error) { // SendGiftP holds parameters for the sendGift method. // See https://core.telegram.org/bots/api#sendgift type SendGiftP struct { - UserID int `json:"user_id,omitempty"` - ChatID int `json:"chat_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` GiftID string `json:"gift_id"` PayForUpgrade bool `json:"pay_for_upgrade"` Text string `json:"text"` @@ -237,7 +237,7 @@ func (api *API) SendGift(params SendGiftP) (bool, error) { // GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. // See https://core.telegram.org/bots/api#giftpremiumsubscription type GiftPremiumSubscriptionP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` MonthCount int `json:"month_count"` StarCount int `json:"star_count"` Text string `json:"text,omitempty"` diff --git a/tgapi/bot_types.go b/tgapi/bot_types.go index b6baf2a..de660ee 100644 --- a/tgapi/bot_types.go +++ b/tgapi/bot_types.go @@ -31,8 +31,8 @@ const ( // See https://core.telegram.org/bots/api#botcommandscope type BotCommandScope struct { Type BotCommandScopeType `json:"type"` - ChatID *int `json:"chat_id,omitempty"` - UserID *int `json:"user_id,omitempty"` + ChatID *int64 `json:"chat_id,omitempty"` + UserID *int64 `json:"user_id,omitempty"` } // BotName represents the bot's name. diff --git a/tgapi/business_methods.go b/tgapi/business_methods.go index 9fcffda..476275c 100644 --- a/tgapi/business_methods.go +++ b/tgapi/business_methods.go @@ -3,7 +3,7 @@ package tgapi // VerifyUserP holds parameters for the verifyUser method. // See https://core.telegram.org/bots/api#verifyuser type VerifyUserP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -18,7 +18,7 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) { // VerifyChatP holds parameters for the verifyChat method. // See https://core.telegram.org/bots/api#verifychat type VerifyChatP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` CustomDescription string `json:"custom_description,omitempty"` } @@ -33,7 +33,7 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) { // RemoveUserVerificationP holds parameters for the removeUserVerification method. // See https://core.telegram.org/bots/api#removeuserverification type RemoveUserVerificationP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // RemoveUserVerification removes a user's verification. @@ -47,7 +47,7 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er // RemoveChatVerificationP holds parameters for the removeChatVerification method. // See https://core.telegram.org/bots/api#removechatverification type RemoveChatVerificationP struct { - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` } // RemoveChatVerification removes a chat's verification. @@ -62,7 +62,7 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er // See https://core.telegram.org/bots/api#readbusinessmessage type ReadBusinessMessageP struct { BusinessConnectionID string `json:"business_connection_id"` - ChatID int `json:"chat_id"` + ChatID int64 `json:"chat_id"` MessageID int `json:"message_id"` } @@ -74,18 +74,31 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) { return req.Do(api) } -// DeleteBusinessMessageP holds parameters for the deleteBusinessMessage method. -// See https://core.telegram.org/bots/api#deletebusinessmessage -type DeleteBusinessMessageP struct { +// GetBusinessConnectionP holds parameters for the getBusinessConnection method. +// See https://core.telegram.org/bots/api#getbusinessconnection +type GetBusinessConnectionP struct { + BusinessConnectionID string `json:"business_connection_id"` +} + +// GetBusinessConnection returns information about a business connection. +// See https://core.telegram.org/bots/api#getbusinessconnection +func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessConnection, error) { + req := NewRequest[BusinessConnection]("getBusinessConnection", params) + return req.Do(api) +} + +// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. +// See https://core.telegram.org/bots/api#deletebusinessmessages +type DeleteBusinessMessagesP struct { BusinessConnectionID string `json:"business_connection_id"` MessageIDs []int `json:"message_ids"` } -// DeleteBusinessMessage deletes business messages. +// DeleteBusinessMessages deletes business messages. // Returns true on success. -// See https://core.telegram.org/bots/api#deletebusinessmessage -func (api *API) DeleteBusinessMessage(params DeleteBusinessMessageP) (bool, error) { - req := NewRequest[bool]("deleteBusinessMessage", params) +// See https://core.telegram.org/bots/api#deletebusinessmessages +func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, error) { + req := NewRequest[bool]("deleteBusinessMessages", params) return req.Do(api) } @@ -191,22 +204,22 @@ type GetBusinessAccountStarBalanceP struct { // GetBusinessAccountStarBalance returns the star balance of a business account. // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) { - req := NewRequest[StarAmount]("getBusinessAccountGiftSettings", params) // Note: method name in call is incorrect, should be "getBusinessAccountStarBalance". We'll keep as is, but comment refers to correct. + req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params) return req.Do(api) } -// TransferBusinessAccountStartP holds parameters for the transferBusinessAccountStart method. -// See https://core.telegram.org/bots/api#transferbusinessaccountstart -type TransferBusinessAccountStartP struct { +// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. +// See https://core.telegram.org/bots/api#transferbusinessaccountstars +type TransferBusinessAccountStarsP struct { BusinessConnectionID string `json:"business_connection_id"` StarCount int `json:"star_count"` } -// TransferBusinessAccountStart transfers stars from a business account. +// TransferBusinessAccountStars transfers stars from a business account. // Returns true on success. -// See https://core.telegram.org/bots/api#transferbusinessaccountstart -func (api *API) TransferBusinessAccountStart(params TransferBusinessAccountStartP) (bool, error) { - req := NewRequest[bool]("transferBusinessAccountStart", params) +// See https://core.telegram.org/bots/api#transferbusinessaccountstars +func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStarsP) (bool, error) { + req := NewRequest[bool]("transferBusinessAccountStars", params) return req.Do(api) } @@ -270,7 +283,7 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) { type TransferGiftP struct { BusinessConnectionID string `json:"business_connection_id"` OwnedGiftID string `json:"owned_gift_id"` - NewOwnerChatID int `json:"new_owner_chat_id"` + NewOwnerChatID int64 `json:"new_owner_chat_id"` StarCount int `json:"star_count,omitempty"` } @@ -316,7 +329,7 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { // See https://core.telegram.org/bots/api#repoststory type RepostStoryP struct { BusinessConnectionID string `json:"business_connection_id"` - FromChatID int `json:"from_chat_id"` + FromChatID int64 `json:"from_chat_id"` FromStoryID int `json:"from_story_id"` ActivePeriod int `json:"active_period"` PostToChatPage bool `json:"post_to_chat_page,omitempty"` diff --git a/tgapi/business_types.go b/tgapi/business_types.go index b2301ca..ca3c37a 100644 --- a/tgapi/business_types.go +++ b/tgapi/business_types.go @@ -54,7 +54,7 @@ type BusinessBotRights struct { type BusinessConnection struct { ID string `json:"id"` User User `json:"user"` - UserChatID int `json:"user_chat_id"` + UserChatID int64 `json:"user_chat_id"` Date int `json:"date"` Rights *BusinessBotRights `json:"rights,omitempty"` IsEnabled bool `json:"is_enabled"` diff --git a/tgapi/chat_methods.go b/tgapi/chat_methods.go index c9ba5c7..eb6ac4e 100644 --- a/tgapi/chat_methods.go +++ b/tgapi/chat_methods.go @@ -4,7 +4,7 @@ package tgapi // See https://core.telegram.org/bots/api#banchatmember type BanChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` UntilDate int `json:"until_date,omitempty"` RevokeMessages bool `json:"revoke_messages,omitempty"` } @@ -21,7 +21,7 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#unbanchatmember type UnbanChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` OnlyIfBanned bool `json:"only_if_banned"` } @@ -37,7 +37,7 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#restrictchatmember type RestrictChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Permissions ChatPermissions `json:"permissions"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` UntilDate int `json:"until_date,omitempty"` @@ -55,7 +55,7 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) { // See https://core.telegram.org/bots/api#promotechatmember type PromoteChatMember struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` IsAnonymous bool `json:"is_anonymous,omitempty"` CanManageChat bool `json:"can_manage_chat,omitempty"` @@ -88,7 +88,7 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) { // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle type SetChatAdministratorCustomTitleP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` CustomTitle string `json:"custom_title"` } @@ -104,7 +104,7 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto // See https://core.telegram.org/bots/api#setchatmembertag type SetChatMemberTagP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Tag string `json:"tag,omitempty"` } @@ -183,7 +183,7 @@ type CreateChatInviteLinkP struct { Name *string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` MemberLimit int `json:"member_limit,omitempty"` - CreatesJoinRequest int `json:"creates_join_request,omitempty"` + CreatesJoinRequest bool `json:"creates_join_request,omitempty"` } // CreateChatInviteLink creates an additional invite link for a chat. @@ -203,7 +203,7 @@ type EditChatInviteLinkP struct { Name string `json:"name,omitempty"` ExpireDate int `json:"expire_date,omitempty"` MemberLimit int `json:"member_limit,omitempty"` - CreatesJoinRequest int `json:"creates_join_request,omitempty"` + CreatesJoinRequest bool `json:"creates_join_request,omitempty"` } // EditChatInviteLink edits a non‑primary invite link. @@ -266,7 +266,7 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi // See https://core.telegram.org/bots/api#approvechatjoinrequest type ApproveChatJoinRequestP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // ApproveChatJoinRequest approves a chat join request. @@ -281,7 +281,7 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er // See https://core.telegram.org/bots/api#declinechatjoinrequest type DeclineChatJoinRequestP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // DeclineChatJoinRequest declines a chat join request. @@ -292,13 +292,23 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er return req.Do(api) } -// SetChatPhoto is a stub method (needs implementation). -// Currently incomplete. -func (api *API) SetChatPhoto() { +// SetChatPhotoP holds parameters for the setChatPhoto method. +// See https://core.telegram.org/bots/api#setchatphoto +type SetChatPhotoP struct { + ChatID int64 `json:"chat_id"` +} + +// SetChatPhoto changes the chat photo. +// photo is the file to upload as the new photo. +// Returns True on success. +// See https://core.telegram.org/bots/api#setchatphoto +func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, error) { uploader := NewUploader(api) defer func() { _ = uploader.Close() }() + req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo.SetType(UploaderPhotoType)) + return req.Do(uploader) } // DeleteChatPhotoP holds parameters for the deleteChatPhoto method. @@ -449,7 +459,7 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) { // See https://core.telegram.org/bots/api#getchatmember type GetChatMemberP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // GetChatMember returns information about a member of a chat. @@ -492,7 +502,7 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) // See https://core.telegram.org/bots/api#getuserchatboosts type GetUserChatBoostsP struct { ChatID int64 `json:"chat_id"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` } // GetUserChatBoosts returns the list of boosts a user has given to a chat. diff --git a/tgapi/chat_types.go b/tgapi/chat_types.go index e320fd7..a1d795b 100644 --- a/tgapi/chat_types.go +++ b/tgapi/chat_types.go @@ -26,7 +26,7 @@ const ( // ChatFullInfo contains full information about a chat. // See https://core.telegram.org/bots/api#chatfullinfo type ChatFullInfo struct { - ID int `json:"id"` + ID int64 `json:"id"` Type ChatType `json:"type"` Title string `json:"title"` Username string `json:"username"` @@ -78,7 +78,7 @@ type ChatFullInfo struct { StickerSetName *string `json:"sticker_set_name,omitempty"` CanSetStickerSet *bool `json:"can_set_sticker_set,omitempty"` CustomEmojiStickerSetName *string `json:"custom_emoji_sticker_set_name,omitempty"` - LinkedChatID *int `json:"linked_chat_id,omitempty"` + LinkedChatID *int64 `json:"linked_chat_id,omitempty"` Location *ChatLocation `json:"location,omitempty"` Rating *UserRating `json:"rating,omitempty"` @@ -108,7 +108,7 @@ type ChatPermissions struct { CanSendPolls bool `json:"can_send_polls"` CanSendOtherMessages bool `json:"can_send_other_messages"` CanAddWebPagePreview bool `json:"can_add_web_page_preview"` - CatEditTag bool `json:"cat_edit_tag"` // Note: field name likely a typo, should be "can_edit_tag" + CanEditTag bool `json:"can_edit_tag"` CanChangeInfo bool `json:"can_change_info"` CanInviteUsers bool `json:"can_invite_users"` CanPinMessages bool `json:"can_pin_messages"` @@ -127,7 +127,7 @@ type ChatLocation struct { type ChatInviteLink struct { InviteLink string `json:"invite_link"` Creator User `json:"creator"` - CreateJoinRequest bool `json:"create_join_request"` + CreateJoinRequest bool `json:"creates_join_request"` IsPrimary bool `json:"is_primary"` IsRevoked bool `json:"is_revoked"` diff --git a/tgapi/errors.go b/tgapi/errors.go index fa1d205..a7e83da 100644 --- a/tgapi/errors.go +++ b/tgapi/errors.go @@ -5,3 +5,4 @@ import "errors" var ErrRateLimit = errors.New("rate limit exceeded") var ErrPoolUnexpected = errors.New("unexpected response from pool") var ErrPoolQueueFull = errors.New("worker pool queue full") +var ErrPoolStopped = errors.New("worker pool stopped") diff --git a/tgapi/games_methods.go b/tgapi/games_methods.go new file mode 100644 index 0000000..4441edb --- /dev/null +++ b/tgapi/games_methods.go @@ -0,0 +1,69 @@ +package tgapi + +// SendGameP holds parameters for the sendGame method. +// See https://core.telegram.org/bots/api#sendgame +type SendGameP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + + GameShortName string `json:"game_short_name"` + + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +// SendGame sends a game message. +// See https://core.telegram.org/bots/api#sendgame +func (api *API) SendGame(params SendGameP) (Message, error) { + req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID) + return req.Do(api) +} + +// SetGameScoreP holds parameters for the setGameScore method. +// See https://core.telegram.org/bots/api#setgamescore +type SetGameScoreP struct { + UserID int64 `json:"user_id"` + Score int `json:"score"` + Force bool `json:"force,omitempty"` + DisableEditMessage bool `json:"disable_edit_message,omitempty"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// SetGameScore sets a user's score in a game message. +// If inline_message_id is provided, returns a boolean success flag. +// Otherwise returns the edited Message. +// See https://core.telegram.org/bots/api#setgamescore +func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) { + var zero Message + if params.InlineMessageID != "" { + req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) + res, err := req.Do(api) + return zero, res, err + } + req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID) + res, err := req.Do(api) + return res, false, err +} + +// GetGameHighScoresP holds parameters for the getGameHighScores method. +// See https://core.telegram.org/bots/api#getgamehighscores +type GetGameHighScoresP struct { + UserID int64 `json:"user_id"` + ChatID int64 `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// GetGameHighScores returns game high score data for a user. +// See https://core.telegram.org/bots/api#getgamehighscores +func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, error) { + req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) + return req.Do(api) +} diff --git a/tgapi/games_types.go b/tgapi/games_types.go new file mode 100644 index 0000000..f076a13 --- /dev/null +++ b/tgapi/games_types.go @@ -0,0 +1,9 @@ +package tgapi + +// GameHighScore represents one row in a game high score table. +// See https://core.telegram.org/bots/api#gamehighscore +type GameHighScore struct { + Position int `json:"position"` + User User `json:"user"` + Score int `json:"score"` +} diff --git a/tgapi/inline_methods.go b/tgapi/inline_methods.go new file mode 100644 index 0000000..1bcc48a --- /dev/null +++ b/tgapi/inline_methods.go @@ -0,0 +1,52 @@ +package tgapi + +// AnswerInlineQueryP holds parameters for the answerInlineQuery method. +// See https://core.telegram.org/bots/api#answerinlinequery +type AnswerInlineQueryP struct { + InlineQueryID string `json:"inline_query_id"` + Results []InlineQueryResult `json:"results"` + CacheTime int `json:"cache_time,omitempty"` + IsPersonal bool `json:"is_personal,omitempty"` + NextOffset string `json:"next_offset,omitempty"` + Button *InlineQueryResultsButton `json:"button,omitempty"` +} + +// AnswerInlineQuery sends answers to an inline query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answerinlinequery +func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) { + req := NewRequest[bool]("answerInlineQuery", params) + return req.Do(api) +} + +// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. +// See https://core.telegram.org/bots/api#answerwebappquery +type AnswerWebAppQueryP struct { + WebAppQueryID string `json:"web_app_query_id"` + Result InlineQueryResult `json:"result"` +} + +// AnswerWebAppQuery sets the result of a Web App interaction. +// See https://core.telegram.org/bots/api#answerwebappquery +func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, error) { + req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params) + return req.Do(api) +} + +// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. +// See https://core.telegram.org/bots/api#savepreparedinlinemessage +type SavePreparedInlineMessageP struct { + UserID int64 `json:"user_id"` + Result InlineQueryResult `json:"result"` + AllowUserChats bool `json:"allow_user_chats,omitempty"` + AllowBotChats bool `json:"allow_bot_chats,omitempty"` + AllowGroupChats bool `json:"allow_group_chats,omitempty"` + AllowChannelChats bool `json:"allow_channel_chats,omitempty"` +} + +// SavePreparedInlineMessage stores a prepared message for Mini App users. +// See https://core.telegram.org/bots/api#savepreparedinlinemessage +func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (PreparedInlineMessage, error) { + req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) + return req.Do(api) +} diff --git a/tgapi/inline_types.go b/tgapi/inline_types.go new file mode 100644 index 0000000..8dcab32 --- /dev/null +++ b/tgapi/inline_types.go @@ -0,0 +1,26 @@ +package tgapi + +// InlineQueryResult is a JSON-serializable inline query result object. +// See https://core.telegram.org/bots/api#inlinequeryresult +type InlineQueryResult map[string]any + +// InlineQueryResultsButton represents a button shown above inline query results. +// See https://core.telegram.org/bots/api#inlinequeryresultsbutton +type InlineQueryResultsButton struct { + Text string `json:"text"` + WebApp *WebAppInfo `json:"web_app,omitempty"` + StartParameter string `json:"start_parameter,omitempty"` +} + +// SentWebAppMessage describes an inline message sent by a Web App on behalf of a user. +// See https://core.telegram.org/bots/api#sentwebappmessage +type SentWebAppMessage struct { + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// PreparedInlineMessage describes a prepared inline message. +// See https://core.telegram.org/bots/api#preparedinlinemessage +type PreparedInlineMessage struct { + ID string `json:"id"` + ExpirationDate int `json:"expiration_date"` +} diff --git a/tgapi/messages_methods.go b/tgapi/messages_methods.go index 3892b4b..dc87f8b 100644 --- a/tgapi/messages_methods.go +++ b/tgapi/messages_methods.go @@ -12,7 +12,7 @@ type SendMessageP struct { ParseMode ParseMode `json:"parse_mode,omitempty"` Entities []MessageEntity `json:"entities,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` + DisableNotifications bool `json:"disable_notification,omitempty"` ProtectContent bool `json:"protect_content,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"` @@ -69,8 +69,8 @@ type ForwardMessagesP struct { // ForwardMessages forwards multiple messages. // Returns an array of message IDs of the sent messages. // See https://core.telegram.org/bots/api#forwardmessages -func (api *API) ForwardMessages(params ForwardMessagesP) ([]int, error) { - req := NewRequestWithChatID[[]int]("forwardMessages", params, params.ChatID) +func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID) return req.Do(api) } @@ -103,8 +103,11 @@ type CopyMessageP struct { // Returns the MessageID of the sent copy. // See https://core.telegram.org/bots/api#copymessage func (api *API) CopyMessage(params CopyMessageP) (int, error) { - req := NewRequestWithChatID[int]("copyMessage", params, params.ChatID) - return req.Do(api) + msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).Do(api) + if err != nil { + return 0, err + } + return msgID.MessageID, nil } // CopyMessagesP holds parameters for the copyMessages method. @@ -124,18 +127,18 @@ type CopyMessagesP struct { // CopyMessages copies multiple messages. // Returns an array of message IDs of the sent copies. // See https://core.telegram.org/bots/api#copymessages -func (api *API) CopyMessages(params CopyMessagesP) ([]int, error) { - req := NewRequestWithChatID[[]int]("copyMessages", params, params.ChatID) +func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) { + req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID) return req.Do(api) } // SendLocationP holds parameters for the sendLocation method. // See https://core.telegram.org/bots/api#sendlocation type SendLocationP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -164,10 +167,10 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) { // SendVenueP holds parameters for the sendVenue method. // See https://core.telegram.org/bots/api#sendvenue type SendVenueP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` @@ -198,10 +201,10 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) { // SendContactP holds parameters for the sendContact method. // See https://core.telegram.org/bots/api#sendcontact type SendContactP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` PhoneNumber string `json:"phone_number"` FirstName string `json:"first_name"` @@ -228,12 +231,12 @@ func (api *API) SendContact(params SendContactP) (Message, error) { // SendPollP holds parameters for the sendPoll method. // See https://core.telegram.org/bots/api#sendpoll type SendPollP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` Question string `json:"question"` - QuestionParseMode ParseMode `json:"question_mode,omitempty"` + QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"` QuestionEntities []MessageEntity `json:"question_entities,omitempty"` Options []InputPollOption `json:"options"` IsAnonymous bool `json:"is_anonymous,omitempty"` @@ -266,7 +269,7 @@ func (api *API) SendPoll(params SendPollP) (Message, error) { // SendChecklistP holds parameters for the sendChecklist method. // See https://core.telegram.org/bots/api#sendchecklist type SendChecklistP struct { - BusinessConnectionID int `json:"business_connection_id"` + BusinessConnectionID string `json:"business_connection_id"` ChatID int64 `json:"chat_id"` Checklist InputChecklist `json:"checklist"` @@ -288,10 +291,10 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) { // SendDiceP holds parameters for the sendDice method. // See https://core.telegram.org/bots/api#senddice type SendDiceP struct { - BusinessConnectionID int `json:"business_connection_id,omitempty"` - ChatID int64 `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` Emoji string `json:"emoji,omitempty"` @@ -313,6 +316,7 @@ func (api *API) SendDice(params SendDiceP) (Message, error) { } // SendMessageDraftP holds parameters for the sendMessageDraft method. +// See https://core.telegram.org/bots/api#sendmessagedraft type SendMessageDraftP struct { ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -322,7 +326,9 @@ type SendMessageDraftP struct { Entities []MessageEntity `json:"entities,omitempty"` } -// SendMessageDraft sends a previously saved draft message. +// SendMessageDraft sends or updates a draft message in the target chat. +// Returns True on success. +// See https://core.telegram.org/bots/api#sendmessagedraft func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) { req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID) return req.Do(api) @@ -425,7 +431,7 @@ type EditMessageMediaP struct { ChatID int64 `json:"chat_id,omitempty"` MessageID int `json:"message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"` - Message InputMedia `json:"message"` + Media InputMedia `json:"media"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` } diff --git a/tgapi/messages_types.go b/tgapi/messages_types.go index cac182c..642c66b 100644 --- a/tgapi/messages_types.go +++ b/tgapi/messages_types.go @@ -2,6 +2,11 @@ package tgapi import "git.nix13.pw/scuroneko/extypes" +// MessageID represents a message identifier wrapper returned by some API methods. +type MessageID struct { + MessageID int `json:"message_id"` +} + // MessageReplyMarkup represents an inline keyboard markup for a message. // It is used in the Message type. type MessageReplyMarkup struct { @@ -113,8 +118,8 @@ type MessageEntity struct { // ReplyParameters describes the parameters to use when replying to a message. // See https://core.telegram.org/bots/api#replyparameters type ReplyParameters struct { - MessageID int `json:"message_id"` - ChatID int `json:"chat_id,omitempty"` + MessageID int `json:"message_id"` + ChatID int64 `json:"chat_id,omitempty"` AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` Quote string `json:"quote,omitempty"` @@ -179,11 +184,13 @@ type ReplyKeyboardMarkup struct { // CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. // See https://core.telegram.org/bots/api#callbackquery type CallbackQuery struct { - ID string `json:"id"` - From User `json:"from"` - Message Message `json:"message"` - - Data string `json:"data"` + ID string `json:"id"` + From User `json:"from"` + Message *Message `json:"message,omitempty"` + InlineMessageID *string `json:"inline_message_id,omitempty"` + ChatInstance string `json:"chat_instance,omitempty"` + Data string `json:"data,omitempty"` + GameShortName string `json:"game_short_name,omitempty"` } // InputPollOption contains information about one answer option in a poll to be sent. diff --git a/tgapi/methods.go b/tgapi/methods.go index 44887d3..37bc843 100644 --- a/tgapi/methods.go +++ b/tgapi/methods.go @@ -6,26 +6,6 @@ import ( "net/http" ) -// ParseMode represents the text formatting mode for message parsing. -type ParseMode string - -const ( - // ParseMDV2 enables MarkdownV2 style parsing. - ParseMDV2 ParseMode = "MarkdownV2" - // ParseHTML enables HTML style parsing. - ParseHTML ParseMode = "HTML" - // ParseMD enables legacy Markdown style parsing. - ParseMD ParseMode = "Markdown" - // ParseNone disables any parsing. - ParseNone ParseMode = "None" -) - -// EmptyParams is a placeholder for methods that take no parameters. -type EmptyParams struct{} - -// NoParams is a convenient instance of EmptyParams. -var NoParams = EmptyParams{} - // UpdateParams holds parameters for the getUpdates method. // See https://core.telegram.org/bots/api#getupdates type UpdateParams struct { @@ -65,6 +45,47 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { return req.Do(api) } +// SetWebhookP holds parameters for the setWebhook method. +// See https://core.telegram.org/bots/api#setwebhook +type SetWebhookP struct { + URL string `json:"url"` + Certificate string `json:"certificate,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` + DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` + SecretToken string `json:"secret_token,omitempty"` +} + +// SetWebhook sets a webhook URL for incoming updates. +// Returns true on success. +// See https://core.telegram.org/bots/api#setwebhook +func (api *API) SetWebhook(params SetWebhookP) (bool, error) { + req := NewRequest[bool]("setWebhook", params) + return req.Do(api) +} + +// DeleteWebhookP holds parameters for the deleteWebhook method. +// See https://core.telegram.org/bots/api#deletewebhook +type DeleteWebhookP struct { + DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` +} + +// DeleteWebhook removes the current webhook integration. +// Returns true on success. +// See https://core.telegram.org/bots/api#deletewebhook +func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) { + req := NewRequest[bool]("deleteWebhook", params) + return req.Do(api) +} + +// GetWebhookInfo returns the current webhook status. +// See https://core.telegram.org/bots/api#getwebhookinfo +func (api *API) GetWebhookInfo() (WebhookInfo, error) { + req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams) + return req.Do(api) +} + // GetFileP holds parameters for the getFile method. // See https://core.telegram.org/bots/api#getfile type GetFileP struct { diff --git a/tgapi/methods_types.go b/tgapi/methods_types.go new file mode 100644 index 0000000..7e8a103 --- /dev/null +++ b/tgapi/methods_types.go @@ -0,0 +1,35 @@ +package tgapi + +// ParseMode represents the text formatting mode for message parsing. +type ParseMode string + +const ( + // ParseMDV2 enables MarkdownV2 style parsing. + ParseMDV2 ParseMode = "MarkdownV2" + // ParseHTML enables HTML style parsing. + ParseHTML ParseMode = "HTML" + // ParseMD enables legacy Markdown style parsing. + ParseMD ParseMode = "Markdown" + // ParseNone disables any parsing. + ParseNone ParseMode = "None" +) + +// EmptyParams is a placeholder for methods that take no parameters. +type EmptyParams struct{} + +// NoParams is a convenient instance of EmptyParams. +var NoParams = EmptyParams{} + +// WebhookInfo describes the current webhook status. +// See https://core.telegram.org/bots/api#webhookinfo +type WebhookInfo struct { + URL string `json:"url"` + HasCustomCertificate bool `json:"has_custom_certificate"` + PendingUpdateCount int `json:"pending_update_count"` + IPAddress string `json:"ip_address,omitempty"` + LastErrorDate int `json:"last_error_date,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` + LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowedUpdates []string `json:"allowed_updates,omitempty"` +} diff --git a/tgapi/passport_methods.go b/tgapi/passport_methods.go new file mode 100644 index 0000000..d6e5ae8 --- /dev/null +++ b/tgapi/passport_methods.go @@ -0,0 +1,16 @@ +package tgapi + +// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. +// See https://core.telegram.org/bots/api#setpassportdataerrors +type SetPassportDataErrorsP struct { + UserID int64 `json:"user_id"` + Errors []PassportElementError `json:"errors"` +} + +// SetPassportDataErrors informs a user about Telegram Passport data errors. +// Returns true on success. +// See https://core.telegram.org/bots/api#setpassportdataerrors +func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, error) { + req := NewRequest[bool]("setPassportDataErrors", params) + return req.Do(api) +} diff --git a/tgapi/passport_types.go b/tgapi/passport_types.go new file mode 100644 index 0000000..b8e7ce7 --- /dev/null +++ b/tgapi/passport_types.go @@ -0,0 +1,5 @@ +package tgapi + +// PassportElementError is a JSON-serializable passport element error object. +// See https://core.telegram.org/bots/api#passportelementerror +type PassportElementError map[string]any diff --git a/tgapi/payments_methods.go b/tgapi/payments_methods.go new file mode 100644 index 0000000..ef87590 --- /dev/null +++ b/tgapi/payments_methods.go @@ -0,0 +1,117 @@ +package tgapi + +// SendInvoiceP holds parameters for the sendInvoice method. +// See https://core.telegram.org/bots/api#sendinvoice +type SendInvoiceP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` + + Title string `json:"title"` + Description string `json:"description"` + Payload string `json:"payload"` + ProviderToken string `json:"provider_token,omitempty"` + Currency string `json:"currency"` + Prices []LabeledPrice `json:"prices"` + + MaxTipAmount int `json:"max_tip_amount,omitempty"` + SuggestedTipAmounts []int `json:"suggested_tip_amounts,omitempty"` + StartParameter string `json:"start_parameter,omitempty"` + ProviderData string `json:"provider_data,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + PhotoSize int `json:"photo_size,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + NeedName bool `json:"need_name,omitempty"` + NeedPhoneNumber bool `json:"need_phone_number,omitempty"` + NeedEmail bool `json:"need_email,omitempty"` + NeedShippingAddress bool `json:"need_shipping_address,omitempty"` + SendPhoneToProvider bool `json:"send_phone_number_to_provider,omitempty"` + SendEmailToProvider bool `json:"send_email_to_provider,omitempty"` + IsFlexible bool `json:"is_flexible,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + + SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +// SendInvoice sends an invoice. +// See https://core.telegram.org/bots/api#sendinvoice +func (api *API) SendInvoice(params SendInvoiceP) (Message, error) { + req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID) + return req.Do(api) +} + +// CreateInvoiceLinkP holds parameters for the createInvoiceLink method. +// See https://core.telegram.org/bots/api#createinvoicelink +type CreateInvoiceLinkP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + + Title string `json:"title"` + Description string `json:"description"` + Payload string `json:"payload"` + ProviderToken string `json:"provider_token,omitempty"` + Currency string `json:"currency"` + Prices []LabeledPrice `json:"prices"` + + SubscriptionPeriod int `json:"subscription_period,omitempty"` + MaxTipAmount int `json:"max_tip_amount,omitempty"` + SuggestedTipAmounts []int `json:"suggested_tip_amounts,omitempty"` + ProviderData string `json:"provider_data,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + PhotoSize int `json:"photo_size,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + NeedName bool `json:"need_name,omitempty"` + NeedPhoneNumber bool `json:"need_phone_number,omitempty"` + NeedEmail bool `json:"need_email,omitempty"` + NeedShippingAddress bool `json:"need_shipping_address,omitempty"` + SendPhoneToProvider bool `json:"send_phone_number_to_provider,omitempty"` + SendEmailToProvider bool `json:"send_email_to_provider,omitempty"` + IsFlexible bool `json:"is_flexible,omitempty"` +} + +// CreateInvoiceLink creates an invoice link. +// See https://core.telegram.org/bots/api#createinvoicelink +func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) { + req := NewRequest[string]("createInvoiceLink", params) + return req.Do(api) +} + +// AnswerShippingQueryP holds parameters for the answerShippingQuery method. +// See https://core.telegram.org/bots/api#answershippingquery +type AnswerShippingQueryP struct { + ShippingQueryID string `json:"shipping_query_id"` + OK bool `json:"ok"` + ShippingOptions []ShippingOption `json:"shipping_options,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// AnswerShippingQuery answers a shipping query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answershippingquery +func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) { + req := NewRequest[bool]("answerShippingQuery", params) + return req.Do(api) +} + +// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. +// See https://core.telegram.org/bots/api#answerprecheckoutquery +type AnswerPreCheckoutQueryP struct { + PreCheckoutQueryID string `json:"pre_checkout_query_id"` + OK bool `json:"ok"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// AnswerPreCheckoutQuery answers a pre-checkout query. +// Returns true on success. +// See https://core.telegram.org/bots/api#answerprecheckoutquery +func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, error) { + req := NewRequest[bool]("answerPreCheckoutQuery", params) + return req.Do(api) +} diff --git a/tgapi/payments_types.go b/tgapi/payments_types.go new file mode 100644 index 0000000..50449cb --- /dev/null +++ b/tgapi/payments_types.go @@ -0,0 +1,16 @@ +package tgapi + +// LabeledPrice represents a price portion. +// See https://core.telegram.org/bots/api#labeledprice +type LabeledPrice struct { + Label string `json:"label"` + Amount int `json:"amount"` +} + +// ShippingOption represents one shipping option. +// See https://core.telegram.org/bots/api#shippingoption +type ShippingOption struct { + ID string `json:"id"` + Title string `json:"title"` + Prices []LabeledPrice `json:"prices"` +} diff --git a/tgapi/pool.go b/tgapi/pool.go index 774938a..8c15fa4 100644 --- a/tgapi/pool.go +++ b/tgapi/pool.go @@ -14,13 +14,16 @@ type workerPool struct { workers int // количество воркеров (горутин) wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке quit chan struct{} // канал для сигнала остановки + stopOnce sync.Once // гарантирует идемпотентную остановку пула started bool // флаг, указывающий, запущен ли пул + stopped bool // флаг, указывающий, что пул остановлен startedMu sync.Mutex // мьютекс для безопасного доступа к started } // requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата. // Используется только внутри пакета для передачи задач воркерам. type requestEnvelope struct { + ctx context.Context // контекст конкретной задачи doFunc func(context.Context) (any, error) // функция, выполняющая запрос resultCh chan requestResult // канал, через который воркер вернёт результат } @@ -53,7 +56,7 @@ func newWorkerPool(workers int, queueSize int) *workerPool { // start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди. // Метод идемпотентен: если пул уже запущен — ничего не делает. // Должен вызываться перед первым вызовом submit. -func (p *workerPool) start(ctx context.Context) { +func (p *workerPool) start() { p.startedMu.Lock() defer p.startedMu.Unlock() if p.started { @@ -64,7 +67,7 @@ func (p *workerPool) start(ctx context.Context) { // Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле for i := 0; i < p.workers; i++ { p.wg.Add(1) - go p.worker(ctx) // запускаем горутину с контекстом + go p.worker() // запускаем горутину } } @@ -72,8 +75,15 @@ func (p *workerPool) start(ctx context.Context) { // Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач. // Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта. func (p *workerPool) stop() { - close(p.quit) // сигнал для всех воркеров — выйти из цикла - p.wg.Wait() // ждём, пока все воркеры завершатся + p.stopOnce.Do(func() { + p.startedMu.Lock() + p.stopped = true + p.started = false + close(p.quit) // сигнал для всех воркеров — выйти из цикла + p.startedMu.Unlock() + + p.wg.Wait() // ждём, пока все воркеры завершатся + }) } // submit отправляет задачу в очередь и возвращает канал, через который будет получен результат. @@ -81,8 +91,15 @@ func (p *workerPool) stop() { // Канал результата имеет буфер 1, чтобы не блокировать воркера при записи. // Контекст используется для отмены задачи, если клиент отменил запрос до отправки. func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { + p.startedMu.Lock() + if p.stopped || !p.started { + p.startedMu.Unlock() + return nil, ErrPoolStopped + } + // Проверяем, не превышена ли очередь if len(p.taskCh) >= p.queueSize { + p.startedMu.Unlock() return nil, ErrPoolQueueFull } @@ -91,6 +108,7 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // Создаём обёртку задачи envelope := requestEnvelope{ + ctx: ctx, doFunc: do, resultCh: resultCh, } @@ -98,12 +116,15 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // Пытаемся отправить задачу в очередь select { case <-ctx.Done(): + p.startedMu.Unlock() // Клиент отменил операцию до отправки — возвращаем ошибку отмены return nil, ctx.Err() case p.taskCh <- envelope: + p.startedMu.Unlock() // Успешно отправлено — возвращаем канал для чтения результата return resultCh, nil default: + p.startedMu.Unlock() // Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай return nil, ErrPoolQueueFull } @@ -117,26 +138,38 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, // - закрывает канал, чтобы клиент мог прочитать и завершить // // После закрытия quit-канала — воркер завершает работу. -func (p *workerPool) worker(ctx context.Context) { +func (p *workerPool) worker() { defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины for { select { case <-p.quit: - // Получен сигнал остановки — выходим из цикла - return + // Получен сигнал остановки — дренируем очередь и выходим. + // После stop() новые задачи не принимаются. + for { + select { + case envelope := <-p.taskCh: + p.executeEnvelope(envelope) + default: + return + } + } case envelope := <-p.taskCh: - // Выполняем задачу с переданным контекстом (клиентский или общий) - value, err := envelope.doFunc(ctx) - - // Записываем результат в канал — не блокируем, т.к. буфер 1 - envelope.resultCh <- requestResult{ - value: value, - err: err, - } - // Закрываем канал — клиент знает, что результат пришёл и больше не будет - close(envelope.resultCh) + p.executeEnvelope(envelope) } } } + +func (p *workerPool) executeEnvelope(envelope requestEnvelope) { + // Выполняем задачу с переданным контекстом (клиентский или общий) + value, err := envelope.doFunc(envelope.ctx) + + // Записываем результат в канал — не блокируем, т.к. буфер 1 + envelope.resultCh <- requestResult{ + value: value, + err: err, + } + // Закрываем канал — клиент знает, что результат пришёл и больше не будет + close(envelope.resultCh) +} diff --git a/tgapi/stars_methods.go b/tgapi/stars_methods.go new file mode 100644 index 0000000..7e3e0ab --- /dev/null +++ b/tgapi/stars_methods.go @@ -0,0 +1,53 @@ +package tgapi + +// GetStarTransactionsP holds parameters for the getStarTransactions method. +// See https://core.telegram.org/bots/api#getstartransactions +type GetStarTransactionsP struct { + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// GetMyStarBalance returns the bot's Telegram Star balance. +// See https://core.telegram.org/bots/api#getmystarbalance +func (api *API) GetMyStarBalance() (StarAmount, error) { + req := NewRequest[StarAmount]("getMyStarBalance", NoParams) + return req.Do(api) +} + +// GetStarTransactions returns Telegram Star transactions for the bot. +// See https://core.telegram.org/bots/api#getstartransactions +func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { + req := NewRequest[StarTransactions]("getStarTransactions", params) + return req.Do(api) +} + +// RefundStarPaymentP holds parameters for the refundStarPayment method. +// See https://core.telegram.org/bots/api#refundstarpayment +type RefundStarPaymentP struct { + UserID int64 `json:"user_id"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` +} + +// RefundStarPayment refunds a successful Telegram Stars payment. +// Returns true on success. +// See https://core.telegram.org/bots/api#refundstarpayment +func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) { + req := NewRequest[bool]("refundStarPayment", params) + return req.Do(api) +} + +// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. +// See https://core.telegram.org/bots/api#edituserstarsubscription +type EditUserStarSubscriptionP struct { + UserID int64 `json:"user_id"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + IsCanceled bool `json:"is_canceled"` +} + +// EditUserStarSubscription cancels or re-enables a user star subscription extension. +// Returns true on success. +// See https://core.telegram.org/bots/api#edituserstarsubscription +func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool, error) { + req := NewRequest[bool]("editUserStarSubscription", params) + return req.Do(api) +} diff --git a/tgapi/stars_types.go b/tgapi/stars_types.go new file mode 100644 index 0000000..1bb4bc1 --- /dev/null +++ b/tgapi/stars_types.go @@ -0,0 +1,18 @@ +package tgapi + +// StarTransaction describes a Telegram Star transaction. +// See https://core.telegram.org/bots/api#startransaction +type StarTransaction struct { + ID string `json:"id"` + Amount int `json:"amount"` + NanostarAmount int `json:"nanostar_amount,omitempty"` + Date int `json:"date"` + Source map[string]any `json:"source,omitempty"` + Receiver map[string]any `json:"receiver,omitempty"` +} + +// StarTransactions contains a list of Telegram Star transactions. +// See https://core.telegram.org/bots/api#startransactions +type StarTransactions struct { + Transactions []StarTransaction `json:"transactions"` +} diff --git a/tgapi/stickers_methods.go b/tgapi/stickers_methods.go index ff0b944..77cad3b 100644 --- a/tgapi/stickers_methods.go +++ b/tgapi/stickers_methods.go @@ -49,10 +49,29 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke return req.Do(api) } +// UploadStickerFileP holds parameters for the uploadStickerFile method. +// See https://core.telegram.org/bots/api#uploadstickerfile +type UploadStickerFileP struct { + UserID int64 `json:"user_id"` + StickerFormat InputStickerFormat `json:"sticker_format"` +} + +// UploadStickerFile uploads a sticker file for later use in sticker set methods. +// sticker is the file to upload. +// See https://core.telegram.org/bots/api#uploadstickerfile +func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFile) (File, error) { + uploader := NewUploader(api) + defer func() { + _ = uploader.Close() + }() + req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType)) + return req.Do(uploader) +} + // CreateNewStickerSetP holds parameters for the createNewStickerSet method. // See https://core.telegram.org/bots/api#createnewstickerset type CreateNewStickerSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` Title string `json:"title"` @@ -72,7 +91,7 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) { // AddStickerToSetP holds parameters for the addStickerToSet method. // See https://core.telegram.org/bots/api#addstickertoset type AddStickerToSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` Sticker InputSticker `json:"sticker"` } @@ -117,7 +136,7 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) // ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. // See https://core.telegram.org/bots/api#replacestickerinset type ReplaceStickerInSetP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Name string `json:"name"` OldSticker string `json:"old_sticker"` Sticker InputSticker `json:"sticker"` @@ -195,7 +214,7 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) { // See https://core.telegram.org/bots/api#setstickersetthumbnail type SetStickerSetThumbnailP struct { Name string `json:"name"` - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` Thumbnail string `json:"thumbnail"` Format InputStickerFormat `json:"format"` } @@ -218,9 +237,7 @@ type SetCustomEmojiStickerSetThumbnailP struct { // SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. // Returns True on success. // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail -// -// Note: This method uses SetStickerSetThumbnailP as its parameter type, which might be inconsistent. -func (api *API) SetCustomEmojiStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) { +func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnailP) (bool, error) { req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params) return req.Do(api) } diff --git a/tgapi/stickers_types.go b/tgapi/stickers_types.go index d9056c8..883deb2 100644 --- a/tgapi/stickers_types.go +++ b/tgapi/stickers_types.go @@ -52,7 +52,7 @@ type Sticker struct { MaskPosition *MaskPosition `json:"mask_position,omitempty"` CustomEmojiID *string `json:"custom_emoji_id,omitempty"` NeedRepainting *bool `json:"need_repainting,omitempty"` - FileSize *int `json:"file_size,omitempty"` + FileSize *int64 `json:"file_size,omitempty"` } // StickerSet represents a sticker set. diff --git a/tgapi/types.go b/tgapi/types.go index 6a44faf..ed3c3d7 100644 --- a/tgapi/types.go +++ b/tgapi/types.go @@ -160,7 +160,7 @@ type PaidMediaPurchased struct { type File struct { FileId string `json:"file_id"` FileUniqueID string `json:"file_unique_id"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` FilePath string `json:"file_path,omitempty"` } @@ -175,7 +175,7 @@ type Audio struct { Title string `json:"title,omitempty"` FileName string `json:"file_name,omitempty"` MimeType string `json:"mime_type,omitempty"` - FileSize int `json:"file_size,omitempty"` + FileSize int64 `json:"file_size,omitempty"` Thumbnail *PhotoSize `json:"thumbnail,omitempty"` } @@ -234,7 +234,7 @@ type ChatMemberUpdated struct { type ChatJoinRequest struct { Chat Chat `json:"chat"` From User `json:"from"` - UserChatID int `json:"user_chat_id"` + UserChatID int64 `json:"user_chat_id"` Date int64 `json:"date"` Bio *string `json:"bio,omitempty"` InviteLink *ChatInviteLink `json:"invite_link,omitempty"` diff --git a/tgapi/uploader_api.go b/tgapi/uploader_api.go index efa0451..24cf8c5 100644 --- a/tgapi/uploader_api.go +++ b/tgapi/uploader_api.go @@ -14,13 +14,22 @@ import ( ) const ( - UploaderPhotoType UploaderFileType = "photo" - UploaderVideoType UploaderFileType = "video" - UploaderAudioType UploaderFileType = "audio" - UploaderDocumentType UploaderFileType = "document" - UploaderVoiceType UploaderFileType = "voice" + // UploaderPhotoType is the multipart field name for photo uploads. + UploaderPhotoType UploaderFileType = "photo" + // UploaderVideoType is the multipart field name for video uploads. + UploaderVideoType UploaderFileType = "video" + // UploaderAudioType is the multipart field name for audio uploads. + UploaderAudioType UploaderFileType = "audio" + // UploaderDocumentType is the multipart field name for document uploads. + UploaderDocumentType UploaderFileType = "document" + // UploaderVoiceType is the multipart field name for voice uploads. + UploaderVoiceType UploaderFileType = "voice" + // UploaderVideoNoteType is the multipart field name for video-note uploads. UploaderVideoNoteType UploaderFileType = "video_note" + // UploaderThumbnailType is the multipart field name for thumbnail uploads. UploaderThumbnailType UploaderFileType = "thumbnail" + // UploaderStickerType is the multipart field name for sticker uploads. + UploaderStickerType UploaderFileType = "sticker" ) // UploaderFileType represents the Telegram form field name for a file upload. @@ -40,24 +49,35 @@ func NewUploaderFile(name string, data []byte) UploaderFile { return UploaderFile{filename: name, data: data, field: t} } -// SetType used when auto-detect failed. -// i.e. you sending a voice message, but it detects as audio, or if you send audio with thumbnail +// SetType overrides the auto-detected upload field type. +// For example, use it when a voice file is detected as audio. func (f UploaderFile) SetType(t UploaderFileType) UploaderFile { f.field = t return f } +// Uploader is a Telegram Bot API client specialized for multipart file uploads. +// +// Use Uploader methods when you need to upload binary files directly +// (InputFile/multipart). For JSON-only calls (file_id, URL, plain params), use API. type Uploader struct { api *API logger *slog.Logger } +// NewUploader creates a multipart uploader bound to an API client. func NewUploader(api *API) *Uploader { logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") logger.AddWriter(logger.CreateJsonStdoutWriter()) return &Uploader{api, logger} } -func (u *Uploader) Close() error { return u.logger.Close() } + +// Close flushes and closes uploader logger resources. +// See https://core.telegram.org/bots/api +func (u *Uploader) Close() error { return u.logger.Close() } + +// GetLogger returns uploader logger instance. +// See https://core.telegram.org/bots/api func (u *Uploader) GetLogger() *slog.Logger { return u.logger } // UploaderRequest is a multipart file upload request to the Telegram API. @@ -90,14 +110,8 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, for { if up.api.Limiter != nil { - if up.api.dropOverflowLimit { - if !up.api.Limiter.GlobalAllow() { - return zero, utils.ErrDropOverflow - } - } else { - if err := up.api.Limiter.GlobalWait(ctx); err != nil { - return zero, err - } + if err := up.api.Limiter.Check(ctx, up.api.dropOverflowLimit, r.chatId); err != nil { + return zero, err } } diff --git a/tgapi/uploader_methods.go b/tgapi/uploader_methods.go index 892674d..0ab9547 100644 --- a/tgapi/uploader_methods.go +++ b/tgapi/uploader_methods.go @@ -24,10 +24,10 @@ type UploadPhotoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadPhoto uploads a photo and sends it as a message. +// SendPhoto uploads a photo via multipart and sends it as a message. // file is the photo file to upload. // See https://core.telegram.org/bots/api#sendphoto -func (u *Uploader) UploadPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { +func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file) return req.Do(u) } @@ -58,10 +58,10 @@ type UploadAudioP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadAudio uploads an audio file and sends it as a message. +// SendAudio uploads an audio file via multipart and sends it as a message. // files are the audio file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendaudio -func (u *Uploader) UploadAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { +func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Message, error) { req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...) return req.Do(u) } @@ -89,11 +89,11 @@ type UploadDocumentP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadDocument uploads a document and sends it as a message. +// SendDocument uploads a document via multipart and sends it as a message. // files are the document file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#senddocument -func (u *Uploader) UploadDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendDocument", params, files...) +func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...) return req.Do(u) } @@ -127,11 +127,11 @@ type UploadVideoP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVideo uploads a video and sends it as a message. +// SendVideo uploads a video via multipart and sends it as a message. // files are the video file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideo -func (u *Uploader) UploadVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVideo", params, files...) +func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...) return req.Do(u) } @@ -163,11 +163,11 @@ type UploadAnimationP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadAnimation uploads an animation (GIF or H.264/MPEG-4 AVC video without sound) and sends it as a message. +// SendAnimation uploads an animation via multipart and sends it as a message. // files are the animation file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendanimation -func (u *Uploader) UploadAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendAnimation", params, files...) +func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...) return req.Do(u) } @@ -194,11 +194,11 @@ type UploadVoiceP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVoice uploads a voice note and sends it as a message. +// SendVoice uploads a voice note via multipart and sends it as a message. // files are the voice file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvoice -func (u *Uploader) UploadVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVoice", params, files...) +func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...) return req.Do(u) } @@ -223,11 +223,11 @@ type UploadVideoNoteP struct { ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` } -// UploadVideoNote uploads a video note (rounded video) and sends it as a message. +// SendVideoNote uploads a video note via multipart and sends it as a message. // files are the video note file(s) to upload (typically one file). // See https://core.telegram.org/bots/api#sendvideonote -func (u *Uploader) UploadVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendVideoNote", params, files...) +func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) { + req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...) return req.Do(u) } @@ -237,10 +237,10 @@ type UploadChatPhotoP struct { ChatID int64 `json:"chat_id"` } -// UploadChatPhoto uploads a new chat photo. +// SetChatPhoto uploads a new chat photo. // photo is the photo file to upload. // See https://core.telegram.org/bots/api#setchatphoto -func (u *Uploader) UploadChatPhoto(params UploadChatPhotoP, photo UploaderFile) (Message, error) { - req := NewUploaderRequest[Message]("sendChatPhoto", params, photo) +func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bool, error) { + req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) return req.Do(u) } diff --git a/tgapi/users_methods.go b/tgapi/users_methods.go index 637c316..ff5ea01 100644 --- a/tgapi/users_methods.go +++ b/tgapi/users_methods.go @@ -3,9 +3,9 @@ package tgapi // GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. // See https://core.telegram.org/bots/api#getuserprofilephotos type GetUserProfilePhotosP struct { - UserID int `json:"user_id"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` + UserID int64 `json:"user_id"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` } // GetUserProfilePhotos returns a list of profile pictures for a user. @@ -18,9 +18,9 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP // GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. // See https://core.telegram.org/bots/api#getuserprofileaudios type GetUserProfileAudiosP struct { - UserID int `json:"user_id"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` + UserID int64 `json:"user_id"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` } // GetUserProfileAudios returns a list of profile audios for a user. @@ -33,7 +33,7 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA // SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. // See https://core.telegram.org/bots/api#setuseremojistatus type SetUserEmojiStatusP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` } @@ -49,7 +49,7 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) { // GetUserGiftsP holds parameters for the GetUserGifts method. // See https://core.telegram.org/bots/api#getusergifts type GetUserGiftsP struct { - UserID int `json:"user_id"` + UserID int64 `json:"user_id"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"` ExcludeLimitedNonUpgradable bool `json:"exclude_limited_non_upgradable,omitempty"` diff --git a/tgapi/users_types.go b/tgapi/users_types.go index d086ce6..94eeeb8 100644 --- a/tgapi/users_types.go +++ b/tgapi/users_types.go @@ -3,7 +3,7 @@ package tgapi // User represents a Telegram user or bot. // See https://core.telegram.org/bots/api#user type User struct { - ID int `json:"id"` + ID int64 `json:"id"` IsBot bool `json:"is_bot"` FirstName string `json:"first_name"` LastName *string `json:"last_name,omitempty"` diff --git a/utils.go b/utils.go index 96456eb..7127692 100644 --- a/utils.go +++ b/utils.go @@ -6,7 +6,10 @@ import ( "git.nix13.pw/scuroneko/laniakea/utils" ) +// Ptr returns a pointer to v. func Ptr[T any](v T) *T { return &v } + +// Val returns dereferenced pointer value or def when p is nil. func Val[T any](p *T, def T) T { if p != nil { return *p @@ -14,8 +17,8 @@ func Val[T any](p *T, def T) T { return def } -// EscapeMarkdown -// Deprecated. Use MarkdownV2 +// EscapeMarkdown escapes special characters for legacy Telegram Markdown. +// Deprecated: Use EscapeMarkdownV2. func EscapeMarkdown(s string) string { s = strings.ReplaceAll(s, "_", `\_`) s = strings.ReplaceAll(s, "*", `\*`) @@ -40,6 +43,8 @@ func EscapeMarkdownV2(s string) string { } return s } + +// EscapePunctuation escapes '.', '!' and '-' for MarkdownV2 fragments. func EscapePunctuation(s string) string { symbols := []string{".", "!", "-"} for _, symbol := range symbols { @@ -48,6 +53,7 @@ func EscapePunctuation(s string) string { return s } +// Version constants mirror values from the internal utils/version package. const ( VersionString = utils.VersionString VersionMajor = utils.VersionMajor diff --git a/utils/limiter.go b/utils/limiter.go index f15811c..67e05cd 100644 --- a/utils/limiter.go +++ b/utils/limiter.go @@ -36,6 +36,17 @@ func NewRateLimiter() *RateLimiter { } } +// SetGlobalRate overrides global request-per-second limit and burst. +// If rps <= 0, current settings are kept. +func (rl *RateLimiter) SetGlobalRate(rps int) { + if rps <= 0 { + return + } + rl.globalMu.Lock() + defer rl.globalMu.Unlock() + rl.globalLimiter = rate.NewLimiter(rate.Limit(rps), rps) +} + // SetGlobalLock sets a global cooldown period (e.g., after receiving 429 from Telegram). // If retryAfter <= 0, no lock is applied. func (rl *RateLimiter) SetGlobalLock(retryAfter int) { @@ -64,7 +75,11 @@ func (rl *RateLimiter) GlobalWait(ctx context.Context) error { if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - return rl.globalLimiter.Wait(ctx) + limiter := rl.getGlobalLimiter() + if limiter == nil { + return nil + } + return limiter.Wait(ctx) } // Wait blocks until a request for the given chat can be made. @@ -77,8 +92,21 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error { if err := rl.waitForGlobalUnlock(ctx); err != nil { return err } - limiter := rl.getChatLimiter(chatID) - return limiter.Wait(ctx) + limiter := rl.getGlobalLimiter() + if limiter != nil { + if err := limiter.Wait(ctx); err != nil { + return err + } + } + chatLimiter := rl.getChatLimiter(chatID) + return chatLimiter.Wait(ctx) +} + +// getGlobalLimiter returns the global limiter safely under read lock. +func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { + rl.globalMu.RLock() + defer rl.globalMu.RUnlock() + return rl.globalLimiter } // GlobalAllow checks if a global request can be made without blocking. @@ -91,7 +119,11 @@ func (rl *RateLimiter) GlobalAllow() bool { if !until.IsZero() && time.Now().Before(until) { return false } - return rl.globalLimiter.Allow() + limiter := rl.getGlobalLimiter() + if limiter == nil { + return true + } + return limiter.Allow() } // Allow checks if a request for the given chat can be made without blocking. @@ -115,13 +147,14 @@ func (rl *RateLimiter) Allow(chatID int64) bool { } // Check global token bucket - if !rl.globalLimiter.Allow() { + limiter := rl.getGlobalLimiter() + if limiter != nil && !limiter.Allow() { return false } // Check chat token bucket - limiter := rl.getChatLimiter(chatID) - return limiter.Allow() + chatLimiter := rl.getChatLimiter(chatID) + return chatLimiter.Allow() } // Check applies rate limiting based on configuration. diff --git a/utils/multipart.go b/utils/multipart.go index df6131f..8b2a72c 100644 --- a/utils/multipart.go +++ b/utils/multipart.go @@ -10,6 +10,7 @@ import ( "strings" ) +// Encode writes struct fields into multipart form-data using json tags as field names. func Encode[T any](w *multipart.Writer, req T) error { v := reflect.ValueOf(req) if v.Kind() == reflect.Ptr { diff --git a/utils/utils.go b/utils/utils.go index 51ce3c6..889cc1f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -6,6 +6,7 @@ import ( "git.nix13.pw/scuroneko/slog" ) +// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. func GetLoggerLevel() slog.LogLevel { level := slog.FATAL if os.Getenv("DEBUG") == "true" { diff --git a/utils/version.go b/utils/version.go index cc789d9..306615e 100644 --- a/utils/version.go +++ b/utils/version.go @@ -1,9 +1,9 @@ package utils const ( - VersionString = "1.0.0-beta.21" + VersionString = "1.0.0-beta.22" VersionMajor = 1 VersionMinor = 0 VersionPatch = 0 - VersionBeta = 21 + VersionBeta = 22 )