diff --git a/admin.go b/admin.go index 51aaf96..c4c828d 100644 --- a/admin.go +++ b/admin.go @@ -6,71 +6,6 @@ import ( "time" ) -// ChatInviteLink object represents an invite for a chat. -type ChatInviteLink struct { - // The invite link. - InviteLink string `json:"invite_link"` - - // Invite link name. - Name string `json:"name"` - - // The creator of the link. - Creator *User `json:"creator"` - - // If the link is primary. - IsPrimary bool `json:"is_primary"` - - // If the link is revoked. - IsRevoked bool `json:"is_revoked"` - - // (Optional) Point in time when the link will expire, - // use ExpireDate() to get time.Time. - ExpireUnixtime int64 `json:"expire_date,omitempty"` - - // (Optional) Maximum number of users that can be members of - // the chat simultaneously. - MemberLimit int `json:"member_limit,omitempty"` - - // (Optional) True, if users joining the chat via the link need to - // be approved by chat administrators. If True, member_limit can't be specified. - JoinRequest bool `json:"creates_join_request"` - - // (Optional) Number of pending join requests created using this link. - PendingCount int `json:"pending_join_request_count"` -} - -// ExpireDate returns the moment of the link expiration in local time. -func (c *ChatInviteLink) ExpireDate() time.Time { - return time.Unix(c.ExpireUnixtime, 0) -} - -// ChatMemberUpdate object represents changes in the status of a chat member. -type ChatMemberUpdate struct { - // Chat where the user belongs to. - Chat *Chat `json:"chat"` - - // Sender which user the action was triggered. - Sender *User `json:"from"` - - // Unixtime, use Date() to get time.Time. - Unixtime int64 `json:"date"` - - // Previous information about the chat member. - OldChatMember *ChatMember `json:"old_chat_member"` - - // New information about the chat member. - NewChatMember *ChatMember `json:"new_chat_member"` - - // (Optional) InviteLink which was used by the user to - // join the chat; for joining by invite link events only. - InviteLink *ChatInviteLink `json:"invite_link"` -} - -// Time returns the moment of the change in local time. -func (c *ChatMemberUpdate) Time() time.Time { - return time.Unix(c.Unixtime, 0) -} - // Rights is a list of privileges available to chat members. type Rights struct { // Anonymous is true, if the user's presence in the chat is hidden. @@ -345,3 +280,8 @@ func (b *Bot) SetDefaultRights(rights Rights, forChannels bool) error { _, err := b.Raw("setMyDefaultAdministratorRights", params) return err } + +func embedRights(p map[string]interface{}, rights Rights) { + data, _ := json.Marshal(rights) + _ = json.Unmarshal(data, &p) +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..5fd2412 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,39 @@ +package telebot + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEmbedRights(t *testing.T) { + rights := NoRestrictions() + params := map[string]interface{}{ + "chat_id": "1", + "user_id": "2", + } + embedRights(params, rights) + + expected := map[string]interface{}{ + "is_anonymous": false, + "chat_id": "1", + "user_id": "2", + "can_be_edited": true, + "can_send_messages": true, + "can_send_media_messages": true, + "can_send_polls": true, + "can_send_other_messages": true, + "can_add_web_page_previews": true, + "can_change_info": false, + "can_post_messages": false, + "can_edit_messages": false, + "can_delete_messages": false, + "can_invite_users": false, + "can_restrict_members": false, + "can_pin_messages": false, + "can_promote_members": false, + "can_manage_video_chats": false, + "can_manage_chat": false, + } + assert.Equal(t, expected, params) +} diff --git a/api.go b/api.go index 6a6d463..143c23a 100644 --- a/api.go +++ b/api.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/ioutil" + "log" "mime/multipart" "net/http" "os" @@ -237,3 +238,94 @@ func (b *Bot) getUpdates(offset, limit int, timeout time.Duration, allowed []str } return resp.Result, nil } + +// extractOk checks given result for error. If result is ok returns nil. +// In other cases it extracts API error. If error is not presented +// in errors.go, it will be prefixed with `unknown` keyword. +func extractOk(data []byte) error { + var e struct { + Ok bool `json:"ok"` + Code int `json:"error_code"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` + } + if json.NewDecoder(bytes.NewReader(data)).Decode(&e) != nil { + return nil // FIXME + } + if e.Ok { + return nil + } + + err := Err(e.Description) + switch err { + case nil: + case ErrGroupMigrated: + migratedTo, ok := e.Parameters["migrate_to_chat_id"] + if !ok { + return NewError(e.Code, e.Description) + } + + return GroupError{ + err: err.(*Error), + MigratedTo: int64(migratedTo.(float64)), + } + default: + return err + } + + switch e.Code { + case http.StatusTooManyRequests: + retryAfter, ok := e.Parameters["retry_after"] + if !ok { + return NewError(e.Code, e.Description) + } + + err = FloodError{ + err: NewError(e.Code, e.Description), + RetryAfter: int(retryAfter.(float64)), + } + default: + err = fmt.Errorf("telegram: %s (%d)", e.Description, e.Code) + } + + return err +} + +// extractMessage extracts common Message result from given data. +// Should be called after extractOk or b.Raw() to handle possible errors. +func extractMessage(data []byte) (*Message, error) { + var resp struct { + Result *Message + } + if err := json.Unmarshal(data, &resp); err != nil { + var resp struct { + Result bool + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, wrapError(err) + } + if resp.Result { + return nil, ErrTrueResult + } + return nil, wrapError(err) + } + return resp.Result, nil +} + +func verbose(method string, payload interface{}, data []byte) { + body, _ := json.Marshal(payload) + body = bytes.ReplaceAll(body, []byte(`\"`), []byte(`"`)) + body = bytes.ReplaceAll(body, []byte(`"{`), []byte(`{`)) + body = bytes.ReplaceAll(body, []byte(`}"`), []byte(`}`)) + + indent := func(b []byte) string { + var buf bytes.Buffer + json.Indent(&buf, b, "", " ") + return buf.String() + } + + log.Printf( + "[verbose] telebot: sent request\nMethod: %v\nParams: %v\nResponse: %v", + method, indent(body), indent(data), + ) +} diff --git a/api_test.go b/api_test.go index e9e0f1e..d935a6b 100644 --- a/api_test.go +++ b/api_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) @@ -72,3 +74,47 @@ func TestRaw(t *testing.T) { _, err = b.Raw("testUnknownError", nil) assert.EqualError(t, err, "telegram: unknown error (400)") } + +func TestExtractOk(t *testing.T) { + data := []byte(`{"ok": true, "result": {}}`) + require.NoError(t, extractOk(data)) + + data = []byte(`{ + "ok": false, + "error_code": 400, + "description": "Bad Request: reply message not found" + }`) + assert.EqualError(t, extractOk(data), ErrNotFoundToReply.Error()) + + data = []byte(`{ + "ok": false, + "error_code": 429, + "description": "Too Many Requests: retry after 8", + "parameters": {"retry_after": 8} + }`) + assert.Equal(t, FloodError{ + err: NewError(429, "Too Many Requests: retry after 8"), + RetryAfter: 8, + }, extractOk(data)) + + data = []byte(`{ + "ok": false, + "error_code": 400, + "description": "Bad Request: group chat was upgraded to a supergroup chat", + "parameters": {"migrate_to_chat_id": -100123456789} + }`) + assert.Equal(t, GroupError{ + err: ErrGroupMigrated, + MigratedTo: -100123456789, + }, extractOk(data)) +} + +func TestExtractMessage(t *testing.T) { + data := []byte(`{"ok":true,"result":true}`) + _, err := extractMessage(data) + assert.Equal(t, ErrTrueResult, err) + + data = []byte(`{"ok":true,"result":{"foo":"bar"}}`) + _, err = extractMessage(data) + require.NoError(t, err) +} diff --git a/bot.go b/bot.go index 7c9fd71..52d6bcc 100644 --- a/bot.go +++ b/bot.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "regexp" @@ -120,40 +121,24 @@ type Settings struct { Offline bool } -// Update object represents an incoming update. -type Update struct { - ID int `json:"update_id"` - - Message *Message `json:"message,omitempty"` - EditedMessage *Message `json:"edited_message,omitempty"` - ChannelPost *Message `json:"channel_post,omitempty"` - EditedChannelPost *Message `json:"edited_channel_post,omitempty"` - Callback *Callback `json:"callback_query,omitempty"` - Query *Query `json:"inline_query,omitempty"` - InlineResult *InlineResult `json:"chosen_inline_result,omitempty"` - ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` - PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` - Poll *Poll `json:"poll,omitempty"` - PollAnswer *PollAnswer `json:"poll_answer,omitempty"` - MyChatMember *ChatMemberUpdate `json:"my_chat_member,omitempty"` - ChatMember *ChatMemberUpdate `json:"chat_member,omitempty"` - ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` -} - -// Command represents a bot command. -type Command struct { - // Text is a text of the command, 1-32 characters. - // Can contain only lowercase English letters, digits and underscores. - Text string `json:"command"` - - // Description of the command, 3-256 characters. - Description string `json:"description"` +var defaultOnError = func(err error, c Context) { + if c != nil { + log.Println(c.Update().ID, err) + } else { + log.Println(err) + } } func (b *Bot) OnError(err error, c Context) { b.onError(err, c) } +func (b *Bot) debug(err error) { + if b.verbose { + b.OnError(err, nil) + } +} + // Group returns a new group. func (b *Bot) Group() *Group { return &Group{b: b} @@ -164,6 +149,11 @@ func (b *Bot) Use(middleware ...MiddlewareFunc) { b.group.Use(middleware...) } +var ( + cmdRx = regexp.MustCompile(`^(/\w+)(@(\w+))?(\s|$)(.+)?`) + cbackRx = regexp.MustCompile(`^\f([-\w]+)(\|(.+))?$`) +) + // Handle lets you set the handler for some command name or // one of the supported endpoints. It also applies middleware // if such passed to the function. @@ -201,11 +191,6 @@ func (b *Bot) Handle(endpoint interface{}, h HandlerFunc, m ...MiddlewareFunc) { } } -var ( - cmdRx = regexp.MustCompile(`^(/\w+)(@(\w+))?(\s|$)(.+)?`) - cbackRx = regexp.MustCompile(`^\f([-\w]+)(\|(.+))?$`) -) - // Start brings bot into motion by consuming incoming // updates (see Bot.Updates channel). func (b *Bot) Start() { @@ -267,307 +252,6 @@ func (b *Bot) NewContext(u Update) Context { } } -// ProcessUpdate processes a single incoming update. -// A started bot calls this function automatically. -func (b *Bot) ProcessUpdate(u Update) { - c := b.NewContext(u) - - if u.Message != nil { - m := u.Message - - if m.PinnedMessage != nil { - b.handle(OnPinned, c) - return - } - - // Commands - if m.Text != "" { - // Filtering malicious messages - if m.Text[0] == '\a' { - return - } - - match := cmdRx.FindAllStringSubmatch(m.Text, -1) - if match != nil { - // Syntax: "@ " - command, botName := match[0][1], match[0][3] - - if botName != "" && !strings.EqualFold(b.Me.Username, botName) { - return - } - - m.Payload = match[0][5] - if b.handle(command, c) { - return - } - } - - // 1:1 satisfaction - if b.handle(m.Text, c) { - return - } - - b.handle(OnText, c) - return - } - - if b.handleMedia(c) { - return - } - - if m.Contact != nil { - b.handle(OnContact, c) - return - } - if m.Location != nil { - b.handle(OnLocation, c) - return - } - if m.Venue != nil { - b.handle(OnVenue, c) - return - } - if m.Game != nil { - b.handle(OnGame, c) - return - } - if m.Dice != nil { - b.handle(OnDice, c) - return - } - if m.Invoice != nil { - b.handle(OnInvoice, c) - return - } - if m.Payment != nil { - b.handle(OnPayment, c) - return - } - - wasAdded := (m.UserJoined != nil && m.UserJoined.ID == b.Me.ID) || - (m.UsersJoined != nil && isUserInList(b.Me, m.UsersJoined)) - if m.GroupCreated || m.SuperGroupCreated || wasAdded { - b.handle(OnAddedToGroup, c) - return - } - - if m.UserJoined != nil { - b.handle(OnUserJoined, c) - return - } - - if m.UsersJoined != nil { - for _, user := range m.UsersJoined { - m.UserJoined = &user - b.handle(OnUserJoined, c) - } - return - } - - if m.UserLeft != nil { - b.handle(OnUserLeft, c) - return - } - - if m.NewGroupTitle != "" { - b.handle(OnNewGroupTitle, c) - return - } - - if m.NewGroupPhoto != nil { - b.handle(OnNewGroupPhoto, c) - return - } - - if m.GroupPhotoDeleted { - b.handle(OnGroupPhotoDeleted, c) - return - } - - if m.GroupCreated { - b.handle(OnGroupCreated, c) - return - } - - if m.SuperGroupCreated { - b.handle(OnSuperGroupCreated, c) - return - } - - if m.ChannelCreated { - b.handle(OnChannelCreated, c) - return - } - - if m.MigrateTo != 0 { - m.MigrateFrom = m.Chat.ID - b.handle(OnMigration, c) - return - } - - if m.VideoChatStarted != nil { - b.handle(OnVideoChatStarted, c) - return - } - - if m.VideoChatEnded != nil { - b.handle(OnVideoChatEnded, c) - return - } - - if m.VideoChatParticipants != nil { - b.handle(OnVideoChatParticipants, c) - return - } - - if m.VideoChatScheduled != nil { - b.handle(OnVideoChatScheduled, c) - return - } - - if m.WebAppData != nil { - b.handle(OnWebApp, c) - } - - if m.ProximityAlert != nil { - b.handle(OnProximityAlert, c) - return - } - - if m.AutoDeleteTimer != nil { - b.handle(OnAutoDeleteTimer, c) - return - } - } - - if u.EditedMessage != nil { - b.handle(OnEdited, c) - return - } - - if u.ChannelPost != nil { - m := u.ChannelPost - - if m.PinnedMessage != nil { - b.handle(OnPinned, c) - return - } - - b.handle(OnChannelPost, c) - return - } - - if u.EditedChannelPost != nil { - b.handle(OnEditedChannelPost, c) - return - } - - if u.Callback != nil { - if data := u.Callback.Data; data != "" && data[0] == '\f' { - match := cbackRx.FindAllStringSubmatch(data, -1) - if match != nil { - unique, payload := match[0][1], match[0][3] - if handler, ok := b.handlers["\f"+unique]; ok { - u.Callback.Unique = unique - u.Callback.Data = payload - b.runHandler(handler, c) - return - } - } - } - - b.handle(OnCallback, c) - return - } - - if u.Query != nil { - b.handle(OnQuery, c) - return - } - - if u.InlineResult != nil { - b.handle(OnInlineResult, c) - return - } - - if u.ShippingQuery != nil { - b.handle(OnShipping, c) - return - } - - if u.PreCheckoutQuery != nil { - b.handle(OnCheckout, c) - return - } - - if u.Poll != nil { - b.handle(OnPoll, c) - return - } - - if u.PollAnswer != nil { - b.handle(OnPollAnswer, c) - return - } - - if u.MyChatMember != nil { - b.handle(OnMyChatMember, c) - return - } - - if u.ChatMember != nil { - b.handle(OnChatMember, c) - return - } - - if u.ChatJoinRequest != nil { - b.handle(OnChatJoinRequest, c) - return - } -} - -func (b *Bot) handle(end string, c Context) bool { - if handler, ok := b.handlers[end]; ok { - b.runHandler(handler, c) - return true - } - return false -} - -func (b *Bot) handleMedia(c Context) bool { - var ( - m = c.Message() - fired = true - ) - - switch { - case m.Photo != nil: - fired = b.handle(OnPhoto, c) - case m.Voice != nil: - fired = b.handle(OnVoice, c) - case m.Audio != nil: - fired = b.handle(OnAudio, c) - case m.Animation != nil: - fired = b.handle(OnAnimation, c) - case m.Document != nil: - fired = b.handle(OnDocument, c) - case m.Sticker != nil: - fired = b.handle(OnSticker, c) - case m.Video != nil: - fired = b.handle(OnVideo, c) - case m.VideoNote != nil: - fired = b.handle(OnVideoNote, c) - default: - return false - } - - if !fired { - return b.handle(OnMedia, c) - } - - return true -} - // Send accepts 2+ arguments, starting with destination chat, followed by // some Sendable (or string!) and optional send options. // @@ -1268,115 +952,6 @@ func (b *Bot) StopPoll(msg Editable, opts ...interface{}) (*Poll, error) { return resp.Result, nil } -func (b *Bot) CreateInvoice(i Invoice) (string, error) { - data, err := b.Raw("createInvoiceLink", i.params()) - if err != nil { - return "", err - } - - var resp struct { - Result string - } - if err := json.Unmarshal(data, &resp); err != nil { - return "", wrapError(err) - } - return resp.Result, nil -} - -// InviteLink should be used to export chat's invite link. -func (b *Bot) InviteLink(chat *Chat) (string, error) { - params := map[string]string{ - "chat_id": chat.Recipient(), - } - - data, err := b.Raw("exportChatInviteLink", params) - if err != nil { - return "", err - } - - var resp struct { - Result string - } - if err := json.Unmarshal(data, &resp); err != nil { - return "", wrapError(err) - } - return resp.Result, nil -} - -// SetGroupTitle should be used to update group title. -func (b *Bot) SetGroupTitle(chat *Chat, title string) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - "title": title, - } - - _, err := b.Raw("setChatTitle", params) - return err -} - -// SetGroupDescription should be used to update group description. -func (b *Bot) SetGroupDescription(chat *Chat, description string) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - "description": description, - } - - _, err := b.Raw("setChatDescription", params) - return err -} - -// SetGroupPhoto should be used to update group photo. -func (b *Bot) SetGroupPhoto(chat *Chat, p *Photo) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - } - - _, err := b.sendFiles("setChatPhoto", map[string]File{"photo": p.File}, params) - return err -} - -// SetGroupStickerSet should be used to update group's group sticker set. -func (b *Bot) SetGroupStickerSet(chat *Chat, setName string) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - "sticker_set_name": setName, - } - - _, err := b.Raw("setChatStickerSet", params) - return err -} - -// SetGroupPermissions sets default chat permissions for all members. -func (b *Bot) SetGroupPermissions(chat *Chat, perms Rights) error { - params := map[string]interface{}{ - "chat_id": chat.Recipient(), - "permissions": perms, - } - - _, err := b.Raw("setChatPermissions", params) - return err -} - -// DeleteGroupPhoto should be used to just remove group photo. -func (b *Bot) DeleteGroupPhoto(chat *Chat) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - } - - _, err := b.Raw("deleteChatPhoto", params) - return err -} - -// DeleteGroupStickerSet should be used to just remove group sticker set. -func (b *Bot) DeleteGroupStickerSet(chat *Chat) error { - params := map[string]string{ - "chat_id": chat.Recipient(), - } - - _, err := b.Raw("deleteChatStickerSet", params) - return err -} - // Leave makes bot leave a group, supergroup or channel. func (b *Bot) Leave(chat *Chat) error { params := map[string]string{ @@ -1422,7 +997,6 @@ func (b *Bot) Unpin(chat *Chat, messageID ...int) error { } // UnpinAll unpins all messages in a supergroup or a channel. -// // It supports tb.Silent option. func (b *Bot) UnpinAll(chat *Chat) error { params := map[string]string{ @@ -1509,37 +1083,6 @@ func (b *Bot) ChatMemberOf(chat, user Recipient) (*ChatMember, error) { return resp.Result, nil } -// Commands returns the current list of the bot's commands for the given scope and user language. -func (b *Bot) Commands(opts ...interface{}) ([]Command, error) { - params := extractCommandsParams(opts...) - data, err := b.Raw("getMyCommands", params) - if err != nil { - return nil, err - } - - var resp struct { - Result []Command - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, wrapError(err) - } - return resp.Result, nil -} - -// SetCommands changes the list of the bot's commands. -func (b *Bot) SetCommands(opts ...interface{}) error { - params := extractCommandsParams(opts...) - _, err := b.Raw("setMyCommands", params) - return err -} - -// DeleteCommands deletes the list of the bot's commands for the given scope and user language. -func (b *Bot) DeleteCommands(opts ...interface{}) error { - params := extractCommandsParams(opts...) - _, err := b.Raw("deleteMyCommands", params) - return err -} - // MenuButton returns the current value of the bot's menu button in a private chat, // or the default menu button. func (b *Bot) MenuButton(chat *User) (*MenuButton, error) { diff --git a/callbacks.go b/callback.go similarity index 57% rename from callbacks.go rename to callback.go index aa3488e..4bce60a 100644 --- a/callbacks.go +++ b/callback.go @@ -1,7 +1,5 @@ package telebot -import "encoding/json" - // CallbackEndpoint is an interface any element capable // of responding to a callback `\f`. type CallbackEndpoint interface { @@ -72,51 +70,9 @@ type CallbackResponse struct { URL string `json:"url,omitempty"` } -// InlineButton represents a button displayed in the message. -type InlineButton struct { - // Unique slagish name for this kind of button, - // try to be as specific as possible. - // - // It will be used as a callback endpoint. - Unique string `json:"unique,omitempty"` - - Text string `json:"text"` - URL string `json:"url,omitempty"` - Data string `json:"callback_data,omitempty"` - InlineQuery string `json:"switch_inline_query,omitempty"` - InlineQueryChat string `json:"switch_inline_query_current_chat"` - WebApp *WebApp `json:"web_app,omitempty"` - Login *Login `json:"login_url,omitempty"` -} - -// MarshalJSON implements json.Marshaler interface. -// It needed to avoid InlineQueryChat and Login fields conflict. -// If you have Login field in your button, InlineQueryChat must be skipped. -func (t *InlineButton) MarshalJSON() ([]byte, error) { - type IB InlineButton - - if t.Login != nil { - return json.Marshal(struct { - IB - InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"` - }{ - IB: IB(*t), - }) - } - return json.Marshal(IB(*t)) -} - -// With returns a copy of the button with data. -func (t *InlineButton) With(data string) *InlineButton { - return &InlineButton{ - Unique: t.Unique, - Text: t.Text, - URL: t.URL, - InlineQuery: t.InlineQuery, - InlineQueryChat: t.InlineQueryChat, - Login: t.Login, - Data: data, - } +// CallbackUnique returns ReplyButton.Text. +func (t *ReplyButton) CallbackUnique() string { + return t.Text } // CallbackUnique returns InlineButton.Unique. @@ -124,11 +80,6 @@ func (t *InlineButton) CallbackUnique() string { return "\f" + t.Unique } -// CallbackUnique returns KeyboardButton.Text. -func (t *ReplyButton) CallbackUnique() string { - return t.Text -} - // CallbackUnique implements CallbackEndpoint. func (t *Btn) CallbackUnique() string { if t.Unique != "" { @@ -136,13 +87,3 @@ func (t *Btn) CallbackUnique() string { } return t.Text } - -// Login represents a parameter of the inline keyboard button -// used to automatically authorize a user. Serves as a great replacement -// for the Telegram Login Widget when the user is coming from Telegram. -type Login struct { - URL string `json:"url"` - Text string `json:"forward_text,omitempty"` - Username string `json:"bot_username,omitempty"` - WriteAccess bool `json:"request_write_access,omitempty"` -} diff --git a/chat.go b/chat.go index cfb4da8..ab76746 100644 --- a/chat.go +++ b/chat.go @@ -60,6 +60,23 @@ type Chat struct { NoVoiceAndVideo bool `json:"has_restricted_voice_and_video_messages"` } +// Recipient returns chat ID (see Recipient interface). +func (c *Chat) Recipient() string { + return strconv.FormatInt(c.ID, 10) +} + +// ChatType represents one of the possible chat types. +type ChatType string + +const ( + ChatPrivate ChatType = "private" + ChatGroup ChatType = "group" + ChatSuperGroup ChatType = "supergroup" + ChatChannel ChatType = "channel" + ChatChannelPrivate ChatType = "privatechannel" +) + +// ChatLocation represents a location to which a chat is connected. type ChatLocation struct { Location Location `json:"location,omitempty"` Address string `json:"address,omitempty"` @@ -76,11 +93,6 @@ type ChatPhoto struct { BigUniqueID string `json:"big_file_unique_id"` } -// Recipient returns chat ID (see Recipient interface). -func (c *Chat) Recipient() string { - return strconv.FormatInt(c.ID, 10) -} - // ChatMember object represents information about a single chat member. type ChatMember struct { Rights @@ -104,6 +116,45 @@ type ChatMember struct { JoinByRequest string `json:"join_by_request"` } +// MemberStatus is one's chat status. +type MemberStatus string + +const ( + Creator MemberStatus = "creator" + Administrator MemberStatus = "administrator" + Member MemberStatus = "member" + Restricted MemberStatus = "restricted" + Left MemberStatus = "left" + Kicked MemberStatus = "kicked" +) + +// ChatMemberUpdate object represents changes in the status of a chat member. +type ChatMemberUpdate struct { + // Chat where the user belongs to. + Chat *Chat `json:"chat"` + + // Sender which user the action was triggered. + Sender *User `json:"from"` + + // Unixtime, use Date() to get time.Time. + Unixtime int64 `json:"date"` + + // Previous information about the chat member. + OldChatMember *ChatMember `json:"old_chat_member"` + + // New information about the chat member. + NewChatMember *ChatMember `json:"new_chat_member"` + + // (Optional) InviteLink which was used by the user to + // join the chat; for joining by invite link events only. + InviteLink *ChatInviteLink `json:"invite_link"` +} + +// Time returns the moment of the change in local time. +func (c *ChatMemberUpdate) Time() time.Time { + return time.Unix(c.Unixtime, 0) +} + // ChatID represents a chat or an user integer ID, which can be used // as recipient in bot methods. It is very useful in cases where // you have special group IDs, for example in your config, and don't @@ -145,11 +196,69 @@ type ChatJoinRequest struct { InviteLink *ChatInviteLink `json:"invite_link"` } +// ChatInviteLink object represents an invite for a chat. +type ChatInviteLink struct { + // The invite link. + InviteLink string `json:"invite_link"` + + // Invite link name. + Name string `json:"name"` + + // The creator of the link. + Creator *User `json:"creator"` + + // If the link is primary. + IsPrimary bool `json:"is_primary"` + + // If the link is revoked. + IsRevoked bool `json:"is_revoked"` + + // (Optional) Point in time when the link will expire, + // use ExpireDate() to get time.Time. + ExpireUnixtime int64 `json:"expire_date,omitempty"` + + // (Optional) Maximum number of users that can be members of + // the chat simultaneously. + MemberLimit int `json:"member_limit,omitempty"` + + // (Optional) True, if users joining the chat via the link need to + // be approved by chat administrators. If True, member_limit can't be specified. + JoinRequest bool `json:"creates_join_request"` + + // (Optional) Number of pending join requests created using this link. + PendingCount int `json:"pending_join_request_count"` +} + +// ExpireDate returns the moment of the link expiration in local time. +func (c *ChatInviteLink) ExpireDate() time.Time { + return time.Unix(c.ExpireUnixtime, 0) +} + // Time returns the moment of chat join request sending in local time. func (r ChatJoinRequest) Time() time.Time { return time.Unix(r.Unixtime, 0) } +// InviteLink should be used to export chat's invite link. +func (b *Bot) InviteLink(chat *Chat) (string, error) { + params := map[string]string{ + "chat_id": chat.Recipient(), + } + + data, err := b.Raw("exportChatInviteLink", params) + if err != nil { + return "", err + } + + var resp struct { + Result string + } + if err := json.Unmarshal(data, &resp); err != nil { + return "", wrapError(err) + } + return resp.Result, nil +} + // CreateInviteLink creates an additional invite link for a chat. func (b *Bot) CreateInviteLink(chat Recipient, link *ChatInviteLink) (*ChatInviteLink, error) { params := map[string]string{ @@ -239,8 +348,8 @@ func (b *Bot) RevokeInviteLink(chat Recipient, link string) (*ChatInviteLink, er return &resp.Result, nil } -// ApproveChatJoinRequest approves a chat join request. -func (b *Bot) ApproveChatJoinRequest(chat Recipient, user *User) error { +// ApproveJoinRequest approves a chat join request. +func (b *Bot) ApproveJoinRequest(chat Recipient, user *User) error { params := map[string]string{ "chat_id": chat.Recipient(), "user_id": user.Recipient(), @@ -254,8 +363,8 @@ func (b *Bot) ApproveChatJoinRequest(chat Recipient, user *User) error { return extractOk(data) } -// DeclineChatJoinRequest declines a chat join request. -func (b *Bot) DeclineChatJoinRequest(chat Recipient, user *User) error { +// DeclineJoinRequest declines a chat join request. +func (b *Bot) DeclineJoinRequest(chat Recipient, user *User) error { params := map[string]string{ "chat_id": chat.Recipient(), "user_id": user.Recipient(), @@ -268,3 +377,77 @@ func (b *Bot) DeclineChatJoinRequest(chat Recipient, user *User) error { return extractOk(data) } + +// SetGroupTitle should be used to update group title. +func (b *Bot) SetGroupTitle(chat *Chat, title string) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + "title": title, + } + + _, err := b.Raw("setChatTitle", params) + return err +} + +// SetGroupDescription should be used to update group description. +func (b *Bot) SetGroupDescription(chat *Chat, description string) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + "description": description, + } + + _, err := b.Raw("setChatDescription", params) + return err +} + +// SetGroupPhoto should be used to update group photo. +func (b *Bot) SetGroupPhoto(chat *Chat, p *Photo) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + } + + _, err := b.sendFiles("setChatPhoto", map[string]File{"photo": p.File}, params) + return err +} + +// SetGroupStickerSet should be used to update group's group sticker set. +func (b *Bot) SetGroupStickerSet(chat *Chat, setName string) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + "sticker_set_name": setName, + } + + _, err := b.Raw("setChatStickerSet", params) + return err +} + +// SetGroupPermissions sets default chat permissions for all members. +func (b *Bot) SetGroupPermissions(chat *Chat, perms Rights) error { + params := map[string]interface{}{ + "chat_id": chat.Recipient(), + "permissions": perms, + } + + _, err := b.Raw("setChatPermissions", params) + return err +} + +// DeleteGroupPhoto should be used to just remove group photo. +func (b *Bot) DeleteGroupPhoto(chat *Chat) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + } + + _, err := b.Raw("deleteChatPhoto", params) + return err +} + +// DeleteGroupStickerSet should be used to just remove group sticker set. +func (b *Bot) DeleteGroupStickerSet(chat *Chat) error { + params := map[string]string{ + "chat_id": chat.Recipient(), + } + + _, err := b.Raw("deleteChatStickerSet", params) + return err +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..36a515a --- /dev/null +++ b/commands.go @@ -0,0 +1,85 @@ +package telebot + +import "encoding/json" + +// Command represents a bot command. +type Command struct { + // Text is a text of the command, 1-32 characters. + // Can contain only lowercase English letters, digits and underscores. + Text string `json:"command"` + + // Description of the command, 3-256 characters. + Description string `json:"description"` +} + +// CommandParams controls parameters for commands-related methods (setMyCommands, deleteMyCommands and getMyCommands). +type CommandParams struct { + Commands []Command `json:"commands,omitempty"` + Scope *CommandScope `json:"scope,omitempty"` + LanguageCode string `json:"language_code,omitempty"` +} + +type CommandScopeType = string + +const ( + CommandScopeDefault CommandScopeType = "default" + CommandScopeAllPrivateChats CommandScopeType = "all_private_chats" + CommandScopeAllGroupChats CommandScopeType = "all_group_chats" + CommandScopeAllChatAdmin CommandScopeType = "all_chat_administrators" + CommandScopeChat CommandScopeType = "chat" + CommandScopeChatAdmin CommandScopeType = "chat_administrators" + CommandScopeChatMember CommandScopeType = "chat_member" +) + +// CommandScope object represents a scope to which bot commands are applied. +type CommandScope struct { + Type CommandScopeType `json:"type"` + ChatID int64 `json:"chat_id,omitempty"` + UserID int64 `json:"user_id,omitempty"` +} + +// Commands returns the current list of the bot's commands for the given scope and user language. +func (b *Bot) Commands(opts ...interface{}) ([]Command, error) { + params := extractCommandsParams(opts...) + data, err := b.Raw("getMyCommands", params) + if err != nil { + return nil, err + } + + var resp struct { + Result []Command + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, wrapError(err) + } + return resp.Result, nil +} + +// SetCommands changes the list of the bot's commands. +func (b *Bot) SetCommands(opts ...interface{}) error { + params := extractCommandsParams(opts...) + _, err := b.Raw("setMyCommands", params) + return err +} + +// DeleteCommands deletes the list of the bot's commands for the given scope and user language. +func (b *Bot) DeleteCommands(opts ...interface{}) error { + params := extractCommandsParams(opts...) + _, err := b.Raw("deleteMyCommands", params) + return err +} + +// extractCommandsParams extracts parameters for commands-related methods from the given options. +func extractCommandsParams(opts ...interface{}) (params CommandParams) { + for _, opt := range opts { + switch value := opt.(type) { + case []Command: + params.Commands = value + case string: + params.LanguageCode = value + case CommandScope: + params.Scope = &value + } + } + return +} diff --git a/errors.go b/errors.go index fdb62fe..73d6d74 100644 --- a/errors.go +++ b/errors.go @@ -235,3 +235,8 @@ func Err(s string) error { return nil } } + +// wrapError returns new wrapped telebot-related error. +func wrapError(err error) error { + return fmt.Errorf("telebot: %w", err) +} diff --git a/games.go b/game.go similarity index 100% rename from games.go rename to game.go diff --git a/markup.go b/markup.go new file mode 100644 index 0000000..8b71596 --- /dev/null +++ b/markup.go @@ -0,0 +1,315 @@ +package telebot + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ReplyMarkup controls two convenient options for bot-user communications +// such as reply keyboard and inline "keyboard" (a grid of buttons as a part +// of the message). +type ReplyMarkup struct { + // InlineKeyboard is a grid of InlineButtons displayed in the message. + // + // Note: DO NOT confuse with ReplyKeyboard and other keyboard properties! + InlineKeyboard [][]InlineButton `json:"inline_keyboard,omitempty"` + + // ReplyKeyboard is a grid, consisting of keyboard buttons. + // + // Note: you don't need to set HideCustomKeyboard field to show custom keyboard. + ReplyKeyboard [][]ReplyButton `json:"keyboard,omitempty"` + + // ForceReply forces Telegram clients to display + // a reply interface to the user (act as if the user + // has selected the bot‘s message and tapped "Reply"). + ForceReply bool `json:"force_reply,omitempty"` + + // Requests clients to resize the keyboard vertically for optimal fit + // (e.g. make the keyboard smaller if there are just two rows of buttons). + // + // Defaults to false, in which case the custom keyboard is always of the + // same height as the app's standard keyboard. + ResizeKeyboard bool `json:"resize_keyboard,omitempty"` + + // Requests clients to hide the reply keyboard as soon as it's been used. + // + // Defaults to false. + OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` + + // Requests clients to remove the reply keyboard. + // + // Defaults to false. + RemoveKeyboard bool `json:"remove_keyboard,omitempty"` + + // Use this param if you want to force reply from + // specific users only. + // + // Targets: + // 1) Users that are @mentioned in the text of the Message object; + // 2) If the bot's message is a reply (has SendOptions.ReplyTo), + // sender of the original message. + Selective bool `json:"selective,omitempty"` + + // Placeholder will be shown in the input field when the reply is active. + Placeholder string `json:"input_field_placeholder,omitempty"` +} + +func (r *ReplyMarkup) copy() *ReplyMarkup { + cp := *r + + if len(r.ReplyKeyboard) > 0 { + cp.ReplyKeyboard = make([][]ReplyButton, len(r.ReplyKeyboard)) + for i, row := range r.ReplyKeyboard { + cp.ReplyKeyboard[i] = make([]ReplyButton, len(row)) + copy(cp.ReplyKeyboard[i], row) + } + } + + if len(r.InlineKeyboard) > 0 { + cp.InlineKeyboard = make([][]InlineButton, len(r.InlineKeyboard)) + for i, row := range r.InlineKeyboard { + cp.InlineKeyboard[i] = make([]InlineButton, len(row)) + copy(cp.InlineKeyboard[i], row) + } + } + + return &cp +} + +// Btn is a constructor button, which will later become either a reply, or an inline button. +type Btn struct { + Unique string `json:"unique,omitempty"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + Data string `json:"callback_data,omitempty"` + InlineQuery string `json:"switch_inline_query,omitempty"` + InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"` + Contact bool `json:"request_contact,omitempty"` + Location bool `json:"request_location,omitempty"` + Poll PollType `json:"request_poll,omitempty"` + Login *Login `json:"login_url,omitempty"` +} + +// Row represents an array of buttons, a row. +type Row []Btn + +// Row creates a row of buttons. +func (r *ReplyMarkup) Row(many ...Btn) Row { + return many +} + +// Split splits the keyboard into the rows with N maximum number of buttons. +// For example, if you pass six buttons and 3 as the max, you get two rows with +// three buttons in each. +// +// `Split(3, []Btn{six buttons...}) -> [[1, 2, 3], [4, 5, 6]]` +// `Split(2, []Btn{six buttons...}) -> [[1, 2],[3, 4],[5, 6]]` +// +func (r *ReplyMarkup) Split(max int, btns []Btn) []Row { + rows := make([]Row, (max-1+len(btns))/max) + for i, b := range btns { + i /= max + rows[i] = append(rows[i], b) + } + return rows +} + +func (r *ReplyMarkup) Inline(rows ...Row) { + inlineKeys := make([][]InlineButton, 0, len(rows)) + for i, row := range rows { + keys := make([]InlineButton, 0, len(row)) + for j, btn := range row { + btn := btn.Inline() + if btn == nil { + panic(fmt.Sprintf( + "telebot: button row %d column %d is not an inline button", + i, j)) + } + keys = append(keys, *btn) + } + inlineKeys = append(inlineKeys, keys) + } + + r.InlineKeyboard = inlineKeys +} + +func (r *ReplyMarkup) Reply(rows ...Row) { + replyKeys := make([][]ReplyButton, 0, len(rows)) + for i, row := range rows { + keys := make([]ReplyButton, 0, len(row)) + for j, btn := range row { + btn := btn.Reply() + if btn == nil { + panic(fmt.Sprintf( + "telebot: button row %d column %d is not a reply button", + i, j)) + } + keys = append(keys, *btn) + } + replyKeys = append(replyKeys, keys) + } + + r.ReplyKeyboard = replyKeys +} + +func (r *ReplyMarkup) Text(text string) Btn { + return Btn{Text: text} +} + +func (r *ReplyMarkup) Contact(text string) Btn { + return Btn{Contact: true, Text: text} +} + +func (r *ReplyMarkup) Location(text string) Btn { + return Btn{Location: true, Text: text} +} + +func (r *ReplyMarkup) Poll(text string, poll PollType) Btn { + return Btn{Poll: poll, Text: text} +} + +func (r *ReplyMarkup) Data(text, unique string, data ...string) Btn { + return Btn{ + Unique: unique, + Text: text, + Data: strings.Join(data, "|"), + } +} + +func (r *ReplyMarkup) URL(text, url string) Btn { + return Btn{Text: text, URL: url} +} + +func (r *ReplyMarkup) Query(text, query string) Btn { + return Btn{Text: text, InlineQuery: query} +} + +func (r *ReplyMarkup) QueryChat(text, query string) Btn { + return Btn{Text: text, InlineQueryChat: query} +} + +func (r *ReplyMarkup) Login(text string, login *Login) Btn { + return Btn{Login: login, Text: text} +} + +// ReplyButton represents a button displayed in reply-keyboard. +// +// Set either Contact or Location to true in order to request +// sensitive info, such as user's phone number or current location. +// +type ReplyButton struct { + Text string `json:"text"` + + Contact bool `json:"request_contact,omitempty"` + Location bool `json:"request_location,omitempty"` + Poll PollType `json:"request_poll,omitempty"` + WebApp *WebApp `json:"web_app,omitempty"` +} + +// MarshalJSON implements json.Marshaler. It allows passing PollType as a +// keyboard's poll type instead of KeyboardButtonPollType object. +func (pt PollType) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Type string `json:"type"` + }{ + Type: string(pt), + }) +} + +// InlineButton represents a button displayed in the message. +type InlineButton struct { + // Unique slagish name for this kind of button, + // try to be as specific as possible. + // + // It will be used as a callback endpoint. + Unique string `json:"unique,omitempty"` + + Text string `json:"text"` + URL string `json:"url,omitempty"` + Data string `json:"callback_data,omitempty"` + InlineQuery string `json:"switch_inline_query,omitempty"` + InlineQueryChat string `json:"switch_inline_query_current_chat"` + WebApp *WebApp `json:"web_app,omitempty"` + Login *Login `json:"login_url,omitempty"` +} + +// MarshalJSON implements json.Marshaler interface. +// It needed to avoid InlineQueryChat and Login fields conflict. +// If you have Login field in your button, InlineQueryChat must be skipped. +func (t *InlineButton) MarshalJSON() ([]byte, error) { + type IB InlineButton + + if t.Login != nil { + return json.Marshal(struct { + IB + InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"` + }{ + IB: IB(*t), + }) + } + return json.Marshal(IB(*t)) +} + +// With returns a copy of the button with data. +func (t *InlineButton) With(data string) *InlineButton { + return &InlineButton{ + Unique: t.Unique, + Text: t.Text, + URL: t.URL, + InlineQuery: t.InlineQuery, + InlineQueryChat: t.InlineQueryChat, + Login: t.Login, + Data: data, + } +} + +func (b Btn) Reply() *ReplyButton { + if b.Unique != "" { + return nil + } + + return &ReplyButton{ + Text: b.Text, + Contact: b.Contact, + Location: b.Location, + Poll: b.Poll, + } +} + +func (b Btn) Inline() *InlineButton { + return &InlineButton{ + Unique: b.Unique, + Text: b.Text, + URL: b.URL, + Data: b.Data, + InlineQuery: b.InlineQuery, + InlineQueryChat: b.InlineQueryChat, + Login: b.Login, + } +} + +// Login represents a parameter of the inline keyboard button +// used to automatically authorize a user. Serves as a great replacement +// for the Telegram Login Widget when the user is coming from Telegram. +type Login struct { + URL string `json:"url"` + Text string `json:"forward_text,omitempty"` + Username string `json:"bot_username,omitempty"` + WriteAccess bool `json:"request_write_access,omitempty"` +} + +// MenuButton describes the bot's menu button in a private chat. +type MenuButton struct { + Type MenuButtonType `json:"type"` + Text string `json:"text,omitempty"` + WebApp *WebApp `json:"web_app,omitempty"` +} + +type MenuButtonType = string + +const ( + MenuButtonDefault MenuButtonType = "default" + MenuButtonCommands MenuButtonType = "commands" + MenuButtonWebApp MenuButtonType = "web_app" +) diff --git a/media.go b/media.go index ff04109..7dcabce 100644 --- a/media.go +++ b/media.go @@ -341,3 +341,15 @@ type Dice struct { Type DiceType `json:"emoji"` Value int `json:"value"` } + +// DiceType defines dice types. +type DiceType string + +var ( + Cube = &Dice{Type: "🎲"} + Dart = &Dice{Type: "🎯"} + Ball = &Dice{Type: "🏀"} + Goal = &Dice{Type: "⚽"} + Slot = &Dice{Type: "🎰"} + Bowl = &Dice{Type: "🎳"} +) diff --git a/message.go b/message.go index f29be5e..47c0721 100644 --- a/message.go +++ b/message.go @@ -279,6 +279,29 @@ type MessageEntity struct { CustomEmoji string `json:"custom_emoji_id"` } +// EntityType is a MessageEntity type. +type EntityType string + +const ( + EntityMention EntityType = "mention" + EntityTMention EntityType = "text_mention" + EntityHashtag EntityType = "hashtag" + EntityCashtag EntityType = "cashtag" + EntityCommand EntityType = "bot_command" + EntityURL EntityType = "url" + EntityEmail EntityType = "email" + EntityPhone EntityType = "phone_number" + EntityBold EntityType = "bold" + EntityItalic EntityType = "italic" + EntityUnderline EntityType = "underline" + EntityStrikethrough EntityType = "strikethrough" + EntityCode EntityType = "code" + EntityCodeBlock EntityType = "pre" + EntityTextLink EntityType = "text_link" + EntitySpoiler EntityType = "spoiler" + EntityCustomEmoji EntityType = "custom_emoji" +) + // Entities is used to set message's text entities as a send option. type Entities []MessageEntity diff --git a/middleware.go b/middleware.go index fec640f..aa21ca2 100644 --- a/middleware.go +++ b/middleware.go @@ -4,6 +4,13 @@ package telebot // which get called before the endpoint group or specific handler. type MiddlewareFunc func(HandlerFunc) HandlerFunc +func applyMiddleware(h HandlerFunc, m ...MiddlewareFunc) HandlerFunc { + for i := len(m) - 1; i >= 0; i-- { + h = m[i](h) + } + return h +} + // Group is a separated group of handlers, united by the general middleware. type Group struct { b *Bot diff --git a/options.go b/options.go index 43aab90..2aa2805 100644 --- a/options.go +++ b/options.go @@ -2,8 +2,7 @@ package telebot import ( "encoding/json" - "fmt" - "strings" + "strconv" ) // Option is a shortcut flag type for certain message features @@ -90,282 +89,125 @@ func (og *SendOptions) copy() *SendOptions { return &cp } -// ReplyMarkup controls two convenient options for bot-user communications -// such as reply keyboard and inline "keyboard" (a grid of buttons as a part -// of the message). -type ReplyMarkup struct { - // InlineKeyboard is a grid of InlineButtons displayed in the message. - // - // Note: DO NOT confuse with ReplyKeyboard and other keyboard properties! - InlineKeyboard [][]InlineButton `json:"inline_keyboard,omitempty"` - - // ReplyKeyboard is a grid, consisting of keyboard buttons. - // - // Note: you don't need to set HideCustomKeyboard field to show custom keyboard. - ReplyKeyboard [][]ReplyButton `json:"keyboard,omitempty"` - - // ForceReply forces Telegram clients to display - // a reply interface to the user (act as if the user - // has selected the bot‘s message and tapped "Reply"). - ForceReply bool `json:"force_reply,omitempty"` - - // Requests clients to resize the keyboard vertically for optimal fit - // (e.g. make the keyboard smaller if there are just two rows of buttons). - // - // Defaults to false, in which case the custom keyboard is always of the - // same height as the app's standard keyboard. - ResizeKeyboard bool `json:"resize_keyboard,omitempty"` - - // Requests clients to hide the reply keyboard as soon as it's been used. - // - // Defaults to false. - OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` - - // Requests clients to remove the reply keyboard. - // - // Defaults to false. - RemoveKeyboard bool `json:"remove_keyboard,omitempty"` - - // Use this param if you want to force reply from - // specific users only. - // - // Targets: - // 1) Users that are @mentioned in the text of the Message object; - // 2) If the bot's message is a reply (has SendOptions.ReplyTo), - // sender of the original message. - Selective bool `json:"selective,omitempty"` - - // Placeholder will be shown in the input field when the reply is active. - Placeholder string `json:"input_field_placeholder,omitempty"` -} - -func (r *ReplyMarkup) copy() *ReplyMarkup { - cp := *r - - if len(r.ReplyKeyboard) > 0 { - cp.ReplyKeyboard = make([][]ReplyButton, len(r.ReplyKeyboard)) - for i, row := range r.ReplyKeyboard { - cp.ReplyKeyboard[i] = make([]ReplyButton, len(row)) - copy(cp.ReplyKeyboard[i], row) - } - } +func extractOptions(how []interface{}) *SendOptions { + opts := &SendOptions{} - if len(r.InlineKeyboard) > 0 { - cp.InlineKeyboard = make([][]InlineButton, len(r.InlineKeyboard)) - for i, row := range r.InlineKeyboard { - cp.InlineKeyboard[i] = make([]InlineButton, len(row)) - copy(cp.InlineKeyboard[i], row) + for _, prop := range how { + switch opt := prop.(type) { + case *SendOptions: + opts = opt.copy() + case *ReplyMarkup: + if opt != nil { + opts.ReplyMarkup = opt.copy() + } + case Option: + switch opt { + case NoPreview: + opts.DisableWebPagePreview = true + case Silent: + opts.DisableNotification = true + case AllowWithoutReply: + opts.AllowWithoutReply = true + case ForceReply: + if opts.ReplyMarkup == nil { + opts.ReplyMarkup = &ReplyMarkup{} + } + opts.ReplyMarkup.ForceReply = true + case OneTimeKeyboard: + if opts.ReplyMarkup == nil { + opts.ReplyMarkup = &ReplyMarkup{} + } + opts.ReplyMarkup.OneTimeKeyboard = true + case RemoveKeyboard: + if opts.ReplyMarkup == nil { + opts.ReplyMarkup = &ReplyMarkup{} + } + opts.ReplyMarkup.RemoveKeyboard = true + case Protected: + opts.Protected = true + default: + panic("telebot: unsupported flag-option") + } + case ParseMode: + opts.ParseMode = opt + case Entities: + opts.Entities = opt + default: + panic("telebot: unsupported send-option") } } - return &cp + return opts } -// ReplyButton represents a button displayed in reply-keyboard. -// -// Set either Contact or Location to true in order to request -// sensitive info, such as user's phone number or current location. -// -type ReplyButton struct { - Text string `json:"text"` - - Contact bool `json:"request_contact,omitempty"` - Location bool `json:"request_location,omitempty"` - Poll PollType `json:"request_poll,omitempty"` - WebApp *WebApp `json:"web_app,omitempty"` -} +func (b *Bot) embedSendOptions(params map[string]string, opt *SendOptions) { + if b.parseMode != ModeDefault { + params["parse_mode"] = b.parseMode + } -// MarshalJSON implements json.Marshaler. It allows to pass -// PollType as keyboard's poll type instead of KeyboardButtonPollType object. -func (pt PollType) MarshalJSON() ([]byte, error) { - var aux = struct { - Type string `json:"type"` - }{ - Type: string(pt), + if opt == nil { + return } - return json.Marshal(&aux) -} -// Row represents an array of buttons, a row. -type Row []Btn + if opt.ReplyTo != nil && opt.ReplyTo.ID != 0 { + params["reply_to_message_id"] = strconv.Itoa(opt.ReplyTo.ID) + } -// Row creates a row of buttons. -func (r *ReplyMarkup) Row(many ...Btn) Row { - return many -} + if opt.DisableWebPagePreview { + params["disable_web_page_preview"] = "true" + } -// Split splits the keyboard into the rows with N maximum number of buttons. -// For example, if you pass six buttons and 3 as the max, you get two rows with -// three buttons in each. -// -// `Split(3, []Btn{six buttons...}) -> [[1, 2, 3], [4, 5, 6]]` -// `Split(2, []Btn{six buttons...}) -> [[1, 2],[3, 4],[5, 6]]` -// -func (r *ReplyMarkup) Split(max int, btns []Btn) []Row { - rows := make([]Row, (max-1+len(btns))/max) - for i, b := range btns { - i /= max - rows[i] = append(rows[i], b) + if opt.DisableNotification { + params["disable_notification"] = "true" } - return rows -} -func (r *ReplyMarkup) Inline(rows ...Row) { - inlineKeys := make([][]InlineButton, 0, len(rows)) - for i, row := range rows { - keys := make([]InlineButton, 0, len(row)) - for j, btn := range row { - btn := btn.Inline() - if btn == nil { - panic(fmt.Sprintf( - "telebot: button row %d column %d is not an inline button", - i, j)) - } - keys = append(keys, *btn) - } - inlineKeys = append(inlineKeys, keys) + if opt.ParseMode != ModeDefault { + params["parse_mode"] = opt.ParseMode } - r.InlineKeyboard = inlineKeys -} + if len(opt.Entities) > 0 { + delete(params, "parse_mode") + entities, _ := json.Marshal(opt.Entities) -func (r *ReplyMarkup) Reply(rows ...Row) { - replyKeys := make([][]ReplyButton, 0, len(rows)) - for i, row := range rows { - keys := make([]ReplyButton, 0, len(row)) - for j, btn := range row { - btn := btn.Reply() - if btn == nil { - panic(fmt.Sprintf( - "telebot: button row %d column %d is not a reply button", - i, j)) - } - keys = append(keys, *btn) + if params["caption"] != "" { + params["caption_entities"] = string(entities) + } else { + params["entities"] = string(entities) } - replyKeys = append(replyKeys, keys) } - r.ReplyKeyboard = replyKeys -} - -func (r *ReplyMarkup) Text(text string) Btn { - return Btn{Text: text} -} - -func (r *ReplyMarkup) Contact(text string) Btn { - return Btn{Contact: true, Text: text} -} - -func (r *ReplyMarkup) Location(text string) Btn { - return Btn{Location: true, Text: text} -} - -func (r *ReplyMarkup) Poll(text string, poll PollType) Btn { - return Btn{Poll: poll, Text: text} -} - -func (r *ReplyMarkup) Data(text, unique string, data ...string) Btn { - return Btn{ - Unique: unique, - Text: text, - Data: strings.Join(data, "|"), + if opt.AllowWithoutReply { + params["allow_sending_without_reply"] = "true" } -} - -func (r *ReplyMarkup) URL(text, url string) Btn { - return Btn{Text: text, URL: url} -} - -func (r *ReplyMarkup) Query(text, query string) Btn { - return Btn{Text: text, InlineQuery: query} -} -func (r *ReplyMarkup) QueryChat(text, query string) Btn { - return Btn{Text: text, InlineQueryChat: query} -} - -func (r *ReplyMarkup) Login(text string, login *Login) Btn { - return Btn{Login: login, Text: text} -} - -// Btn is a constructor button, which will later become either a reply, or an inline button. -type Btn struct { - Unique string `json:"unique,omitempty"` - Text string `json:"text,omitempty"` - URL string `json:"url,omitempty"` - Data string `json:"callback_data,omitempty"` - InlineQuery string `json:"switch_inline_query,omitempty"` - InlineQueryChat string `json:"switch_inline_query_current_chat,omitempty"` - Contact bool `json:"request_contact,omitempty"` - Location bool `json:"request_location,omitempty"` - Poll PollType `json:"request_poll,omitempty"` - Login *Login `json:"login_url,omitempty"` -} + if opt.ReplyMarkup != nil { + processButtons(opt.ReplyMarkup.InlineKeyboard) + replyMarkup, _ := json.Marshal(opt.ReplyMarkup) + params["reply_markup"] = string(replyMarkup) + } -func (b Btn) Inline() *InlineButton { - return &InlineButton{ - Unique: b.Unique, - Text: b.Text, - URL: b.URL, - Data: b.Data, - InlineQuery: b.InlineQuery, - InlineQueryChat: b.InlineQueryChat, - Login: b.Login, + if opt.Protected { + params["protect_content"] = "true" } } -func (b Btn) Reply() *ReplyButton { - if b.Unique != "" { - return nil +func processButtons(keys [][]InlineButton) { + if keys == nil || len(keys) < 1 || len(keys[0]) < 1 { + return } - return &ReplyButton{ - Text: b.Text, - Contact: b.Contact, - Location: b.Location, - Poll: b.Poll, + for i := range keys { + for j := range keys[i] { + key := &keys[i][j] + if key.Unique != "" { + // Format: "\f|" + data := key.Data + if data == "" { + key.Data = "\f" + key.Unique + } else { + key.Data = "\f" + key.Unique + "|" + data + } + } + } } } - -// CommandParams controls parameters for commands-related methods (setMyCommands, deleteMyCommands and getMyCommands). -type CommandParams struct { - Commands []Command `json:"commands,omitempty"` - Scope *CommandScope `json:"scope,omitempty"` - LanguageCode string `json:"language_code,omitempty"` -} - -// CommandScope object represents a scope to which bot commands are applied. -type CommandScope struct { - Type CommandScopeType `json:"type"` - ChatID int64 `json:"chat_id,omitempty"` - UserID int64 `json:"user_id,omitempty"` -} - -type CommandScopeType = string - -// CommandScope types. -const ( - CommandScopeDefault CommandScopeType = "default" - CommandScopeAllPrivateChats CommandScopeType = "all_private_chats" - CommandScopeAllGroupChats CommandScopeType = "all_group_chats" - CommandScopeAllChatAdmin CommandScopeType = "all_chat_administrators" - CommandScopeChat CommandScopeType = "chat" - CommandScopeChatAdmin CommandScopeType = "chat_administrators" - CommandScopeChatMember CommandScopeType = "chat_member" -) - -// MenuButton describes the bot's menu button in a private chat. -type MenuButton struct { - Type MenuButtonType `json:"type"` - Text string `json:"text,omitempty"` - WebApp *WebApp `json:"web_app,omitempty"` -} - -type MenuButtonType = string - -// MenuButton types. -const ( - MenuButtonDefault MenuButtonType = "default" - MenuButtonCommands MenuButtonType = "commands" - MenuButtonWebApp MenuButtonType = "web_app" -) diff --git a/payments.go b/payments.go index c5670d6..c32f8a1 100644 --- a/payments.go +++ b/payments.go @@ -4,7 +4,6 @@ import ( "encoding/json" "math" "strconv" - "strings" ) // ShippingQuery contains information about an incoming shipping query. @@ -132,7 +131,13 @@ func (i Invoice) params() map[string]string { params["prices"] = string(data) } if len(i.SuggestedTipAmounts) > 0 { - params["suggested_tip_amounts"] = "[" + strings.Join(intsToStrs(i.SuggestedTipAmounts), ",") + "]" + var amounts []string + for _, n := range i.SuggestedTipAmounts { + amounts = append(amounts, strconv.Itoa(n)) + } + + data, _ := json.Marshal(amounts) + params["suggested_tip_amounts"] = string(data) } return params } @@ -166,11 +171,18 @@ func (c Currency) ToTotal(total float64) int { return int(total) * int(math.Pow(10, float64(c.Exp))) } -var SupportedCurrencies = make(map[string]Currency) - -func init() { - err := json.Unmarshal([]byte(dataCurrencies), &SupportedCurrencies) +// CreateInvoiceLink creates a link for a payment invoice. +func (b *Bot) CreateInvoiceLink(i Invoice) (string, error) { + data, err := b.Raw("createInvoiceLink", i.params()) if err != nil { - panic(err) + return "", err + } + + var resp struct { + Result string + } + if err := json.Unmarshal(data, &resp); err != nil { + return "", wrapError(err) } + return resp.Result, nil } diff --git a/payments_data.go b/payments_data.go index 99efa76..a325c5b 100644 --- a/payments_data.go +++ b/payments_data.go @@ -1,3 +1,14 @@ package telebot +import "encoding/json" + const dataCurrencies = `{"AED":{"code":"AED","title":"United Arab Emirates Dirham","symbol":"AED","native":"\u062f.\u0625.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"367","max_amount":"3673200"},"AFN":{"code":"AFN","title":"Afghan Afghani","symbol":"AFN","native":"\u060b","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"7554","max_amount":"75540495"},"ALL":{"code":"ALL","title":"Albanian Lek","symbol":"ALL","native":"Lek","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"10908","max_amount":"109085036"},"AMD":{"code":"AMD","title":"Armenian Dram","symbol":"AMD","native":"\u0564\u0580.","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"48398","max_amount":"483984962"},"ARS":{"code":"ARS","title":"Argentine Peso","symbol":"ARS","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3720","max_amount":"37202998"},"AUD":{"code":"AUD","title":"Australian Dollar","symbol":"AU$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"139","max_amount":"1392750"},"AZN":{"code":"AZN","title":"Azerbaijani Manat","symbol":"AZN","native":"\u043c\u0430\u043d.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"170","max_amount":"1702500"},"BAM":{"code":"BAM","title":"Bosnia & Herzegovina Convertible Mark","symbol":"BAM","native":"KM","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"171","max_amount":"1715550"},"BDT":{"code":"BDT","title":"Bangladeshi Taka","symbol":"BDT","native":"\u09f3","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"8336","max_amount":"83367500"},"BGN":{"code":"BGN","title":"Bulgarian Lev","symbol":"BGN","native":"\u043b\u0432.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"171","max_amount":"1716850"},"BND":{"code":"BND","title":"Brunei Dollar","symbol":"BND","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"134","max_amount":"1349850"},"BOB":{"code":"BOB","title":"Bolivian Boliviano","symbol":"BOB","native":"Bs","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"687","max_amount":"6877150"},"BRL":{"code":"BRL","title":"Brazilian Real","symbol":"R$","native":"R$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"377","max_amount":"3775397"},"CAD":{"code":"CAD","title":"Canadian Dollar","symbol":"CA$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"132","max_amount":"1321950"},"CHF":{"code":"CHF","title":"Swiss Franc","symbol":"CHF","native":"CHF","thousands_sep":"'","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"99","max_amount":"993220"},"CLP":{"code":"CLP","title":"Chilean Peso","symbol":"CLP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"666","max_amount":"6665199"},"CNY":{"code":"CNY","title":"Chinese Renminbi Yuan","symbol":"CN\u00a5","native":"CN\u00a5","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"674","max_amount":"6747298"},"COP":{"code":"COP","title":"Colombian Peso","symbol":"COP","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"315595","max_amount":"3155950000"},"CRC":{"code":"CRC","title":"Costa Rican Col\u00f3n","symbol":"CRC","native":"\u20a1","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"60113","max_amount":"601130282"},"CZK":{"code":"CZK","title":"Czech Koruna","symbol":"CZK","native":"K\u010d","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"2251","max_amount":"22510978"},"DKK":{"code":"DKK","title":"Danish Krone","symbol":"DKK","native":"kr","thousands_sep":"","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"654","max_amount":"6545403"},"DOP":{"code":"DOP","title":"Dominican Peso","symbol":"DOP","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5032","max_amount":"50329504"},"DZD":{"code":"DZD","title":"Algerian Dinar","symbol":"DZD","native":"\u062f.\u062c.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"11872","max_amount":"118729869"},"EGP":{"code":"EGP","title":"Egyptian Pound","symbol":"EGP","native":"\u062c.\u0645.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1791","max_amount":"17912012"},"EUR":{"code":"EUR","title":"Euro","symbol":"\u20ac","native":"\u20ac","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"87","max_amount":"877155"},"GBP":{"code":"GBP","title":"British Pound","symbol":"\u00a3","native":"\u00a3","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"75","max_amount":"757605"},"GEL":{"code":"GEL","title":"Georgian Lari","symbol":"GEL","native":"GEL","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"266","max_amount":"2663750"},"GTQ":{"code":"GTQ","title":"Guatemalan Quetzal","symbol":"GTQ","native":"Q","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"768","max_amount":"7689850"},"HKD":{"code":"HKD","title":"Hong Kong Dollar","symbol":"HK$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"784","max_amount":"7845505"},"HNL":{"code":"HNL","title":"Honduran Lempira","symbol":"HNL","native":"L","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"2427","max_amount":"24277502"},"HRK":{"code":"HRK","title":"Croatian Kuna","symbol":"HRK","native":"kn","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"650","max_amount":"6506302"},"HUF":{"code":"HUF","title":"Hungarian Forint","symbol":"HUF","native":"Ft","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"27844","max_amount":"278440341"},"IDR":{"code":"IDR","title":"Indonesian Rupiah","symbol":"IDR","native":"Rp","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"1406555","max_amount":"14065550000"},"ILS":{"code":"ILS","title":"Israeli New Sheqel","symbol":"\u20aa","native":"\u20aa","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"366","max_amount":"3668230"},"INR":{"code":"INR","title":"Indian Rupee","symbol":"\u20b9","native":"\u20b9","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"7090","max_amount":"70900503"},"ISK":{"code":"ISK","title":"Icelandic Kr\u00f3na","symbol":"ISK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"119","max_amount":"1195599"},"JMD":{"code":"JMD","title":"Jamaican Dollar","symbol":"JMD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"13153","max_amount":"131539958"},"JPY":{"code":"JPY","title":"Japanese Yen","symbol":"\u00a5","native":"\uffe5","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"109","max_amount":"1095549"},"KES":{"code":"KES","title":"Kenyan Shilling","symbol":"KES","native":"Ksh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"10032","max_amount":"100322011"},"KGS":{"code":"KGS","title":"Kyrgyzstani Som","symbol":"KGS","native":"KGS","thousands_sep":"\u00a0","decimal_sep":"-","symbol_left":false,"space_between":true,"exp":2,"min_amount":"6982","max_amount":"69820300"},"KRW":{"code":"KRW","title":"South Korean Won","symbol":"\u20a9","native":"\u20a9","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"1119","max_amount":"11190001"},"KZT":{"code":"KZT","title":"Kazakhstani Tenge","symbol":"KZT","native":"\u20b8","thousands_sep":"\u00a0","decimal_sep":"-","symbol_left":true,"space_between":false,"exp":2,"min_amount":"37767","max_amount":"377674954"},"LBP":{"code":"LBP","title":"Lebanese Pound","symbol":"LBP","native":"\u0644.\u0644.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"150080","max_amount":"1500802255"},"LKR":{"code":"LKR","title":"Sri Lankan Rupee","symbol":"LKR","native":"\u0dbb\u0dd4.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"18078","max_amount":"180789638"},"MAD":{"code":"MAD","title":"Moroccan Dirham","symbol":"MAD","native":"\u062f.\u0645.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"955","max_amount":"9554850"},"MDL":{"code":"MDL","title":"Moldovan Leu","symbol":"MDL","native":"MDL","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1703","max_amount":"17038967"},"MNT":{"code":"MNT","title":"Mongolian T\u00f6gr\u00f6g","symbol":"MNT","native":"MNT","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":true,"space_between":false,"exp":2,"min_amount":"261750","max_amount":"2617500000"},"MUR":{"code":"MUR","title":"Mauritian Rupee","symbol":"MUR","native":"MUR","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3438","max_amount":"34384499"},"MVR":{"code":"MVR","title":"Maldivian Rufiyaa","symbol":"MVR","native":"MVR","thousands_sep":",","decimal_sep":".","symbol_left":false,"space_between":true,"exp":2,"min_amount":"1550","max_amount":"15501063"},"MXN":{"code":"MXN","title":"Mexican Peso","symbol":"MX$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"1898","max_amount":"18988704"},"MYR":{"code":"MYR","title":"Malaysian Ringgit","symbol":"MYR","native":"RM","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"412","max_amount":"4124501"},"MZN":{"code":"MZN","title":"Mozambican Metical","symbol":"MZN","native":"MTn","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"6188","max_amount":"61889913"},"NGN":{"code":"NGN","title":"Nigerian Naira","symbol":"NGN","native":"\u20a6","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"36174","max_amount":"361749532"},"NIO":{"code":"NIO","title":"Nicaraguan C\u00f3rdoba","symbol":"NIO","native":"C$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3241","max_amount":"32415503"},"NOK":{"code":"NOK","title":"Norwegian Krone","symbol":"NOK","native":"kr","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"851","max_amount":"8510100"},"NPR":{"code":"NPR","title":"Nepalese Rupee","symbol":"NPR","native":"\u0928\u0947\u0930\u0942","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"11299","max_amount":"112995016"},"NZD":{"code":"NZD","title":"New Zealand Dollar","symbol":"NZ$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"146","max_amount":"1461850"},"PAB":{"code":"PAB","title":"Panamanian Balboa","symbol":"PAB","native":"B\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"99","max_amount":"995290"},"PEN":{"code":"PEN","title":"Peruvian Nuevo Sol","symbol":"PEN","native":"S\/.","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"333","max_amount":"3331250"},"PHP":{"code":"PHP","title":"Philippine Peso","symbol":"PHP","native":"\u20b1","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"5260","max_amount":"52602981"},"PKR":{"code":"PKR","title":"Pakistani Rupee","symbol":"PKR","native":"\u20a8","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"13921","max_amount":"139214990"},"PLN":{"code":"PLN","title":"Polish Z\u0142oty","symbol":"PLN","native":"z\u0142","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"376","max_amount":"3764026"},"PYG":{"code":"PYG","title":"Paraguayan Guaran\u00ed","symbol":"PYG","native":"\u20b2","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":0,"min_amount":"6013","max_amount":"60134502"},"QAR":{"code":"QAR","title":"Qatari Riyal","symbol":"QAR","native":"\u0631.\u0642.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"364","max_amount":"3641101"},"RON":{"code":"RON","title":"Romanian Leu","symbol":"RON","native":"RON","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"417","max_amount":"4172003"},"RSD":{"code":"RSD","title":"Serbian Dinar","symbol":"RSD","native":"\u0434\u0438\u043d.","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"10391","max_amount":"103910127"},"RUB":{"code":"RUB","title":"Russian Ruble","symbol":"RUB","native":"\u0440\u0443\u0431.","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"6598","max_amount":"65986027"},"SAR":{"code":"SAR","title":"Saudi Riyal","symbol":"SAR","native":"\u0631.\u0633.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"373","max_amount":"3732650"},"SEK":{"code":"SEK","title":"Swedish Krona","symbol":"SEK","native":"kr","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"904","max_amount":"9047896"},"SGD":{"code":"SGD","title":"Singapore Dollar","symbol":"SGD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"135","max_amount":"1353897"},"THB":{"code":"THB","title":"Thai Baht","symbol":"\u0e3f","native":"\u0e3f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3156","max_amount":"31563499"},"TJS":{"code":"TJS","title":"Tajikistani Somoni","symbol":"TJS","native":"TJS","thousands_sep":"\u00a0","decimal_sep":";","symbol_left":false,"space_between":true,"exp":2,"min_amount":"938","max_amount":"9389950"},"TRY":{"code":"TRY","title":"Turkish Lira","symbol":"TRY","native":"TL","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"526","max_amount":"5267200"},"TTD":{"code":"TTD","title":"Trinidad and Tobago Dollar","symbol":"TTD","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"675","max_amount":"6757850"},"TWD":{"code":"TWD","title":"New Taiwan Dollar","symbol":"NT$","native":"NT$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"3072","max_amount":"30722993"},"TZS":{"code":"TZS","title":"Tanzanian Shilling","symbol":"TZS","native":"TSh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"230200","max_amount":"2302000188"},"UAH":{"code":"UAH","title":"Ukrainian Hryvnia","symbol":"UAH","native":"\u20b4","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":false,"exp":2,"min_amount":"2764","max_amount":"27648991"},"UGX":{"code":"UGX","title":"Ugandan Shilling","symbol":"UGX","native":"USh","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":0,"min_amount":"3657","max_amount":"36575502"},"USD":{"code":"USD","title":"United States Dollar","symbol":"$","native":"$","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":false,"exp":2,"min_amount":"100","max_amount":1000000},"UYU":{"code":"UYU","title":"Uruguayan Peso","symbol":"UYU","native":"$","thousands_sep":".","decimal_sep":",","symbol_left":true,"space_between":true,"exp":2,"min_amount":"3246","max_amount":"32469503"},"UZS":{"code":"UZS","title":"Uzbekistani Som","symbol":"UZS","native":"UZS","thousands_sep":"\u00a0","decimal_sep":",","symbol_left":false,"space_between":true,"exp":2,"min_amount":"832759","max_amount":"8327599915"},"VND":{"code":"VND","title":"Vietnamese \u0110\u1ed3ng","symbol":"\u20ab","native":"\u20ab","thousands_sep":".","decimal_sep":",","symbol_left":false,"space_between":true,"exp":0,"min_amount":"23084","max_amount":"230840500"},"YER":{"code":"YER","title":"Yemeni Rial","symbol":"YER","native":"\u0631.\u064a.\u200f","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"25030","max_amount":"250301249"},"ZAR":{"code":"ZAR","title":"South African Rand","symbol":"ZAR","native":"R","thousands_sep":",","decimal_sep":".","symbol_left":true,"space_between":true,"exp":2,"min_amount":"1362","max_amount":"13620106"}}` + +var SupportedCurrencies = make(map[string]Currency) + +func init() { + err := json.Unmarshal([]byte(dataCurrencies), &SupportedCurrencies) + if err != nil { + panic(err) + } +} diff --git a/polls.go b/poll.go similarity index 86% rename from polls.go rename to poll.go index d616f26..8e2e509 100644 --- a/polls.go +++ b/poll.go @@ -2,6 +2,19 @@ package telebot import "time" +// PollType defines poll types. +type PollType string + +const ( + // NOTE: + // Despite "any" type isn't described in documentation, + // it needed for proper KeyboardButtonPollType marshaling. + PollAny PollType = "any" + + PollQuiz PollType = "quiz" + PollRegular PollType = "regular" +) + // Poll contains information about a poll. type Poll struct { ID string `json:"id"` diff --git a/polls_test.go b/poll_test.go similarity index 100% rename from polls_test.go rename to poll_test.go diff --git a/poller.go b/poller.go index ec696ba..d45f2a5 100644 --- a/poller.go +++ b/poller.go @@ -1,8 +1,6 @@ package telebot -import ( - "time" -) +import "time" // Poller is a provider of Updates. // @@ -20,6 +18,53 @@ type Poller interface { Poll(b *Bot, updates chan Update, stop chan struct{}) } +// LongPoller is a classic LongPoller with timeout. +type LongPoller struct { + Limit int + Timeout time.Duration + LastUpdateID int + + // AllowedUpdates contains the update types + // you want your bot to receive. + // + // Possible values: + // message + // edited_message + // channel_post + // edited_channel_post + // inline_query + // chosen_inline_result + // callback_query + // shipping_query + // pre_checkout_query + // poll + // poll_answer + // + AllowedUpdates []string `yaml:"allowed_updates"` +} + +// Poll does long polling. +func (p *LongPoller) Poll(b *Bot, dest chan Update, stop chan struct{}) { + for { + select { + case <-stop: + return + default: + } + + updates, err := b.getUpdates(p.LastUpdateID+1, p.Limit, p.Timeout, p.AllowedUpdates) + if err != nil { + b.debug(err) + continue + } + + for _, update := range updates { + p.LastUpdateID = update.ID + dest <- update + } + } +} + // MiddlewarePoller is a special kind of poller that acts // like a filter for updates. It could be used for spam // handling, banning or whatever. @@ -68,50 +113,3 @@ func (p *MiddlewarePoller) Poll(b *Bot, dest chan Update, stop chan struct{}) { } } } - -// LongPoller is a classic LongPoller with timeout. -type LongPoller struct { - Limit int - Timeout time.Duration - LastUpdateID int - - // AllowedUpdates contains the update types - // you want your bot to receive. - // - // Possible values: - // message - // edited_message - // channel_post - // edited_channel_post - // inline_query - // chosen_inline_result - // callback_query - // shipping_query - // pre_checkout_query - // poll - // poll_answer - // - AllowedUpdates []string `yaml:"allowed_updates"` -} - -// Poll does long polling. -func (p *LongPoller) Poll(b *Bot, dest chan Update, stop chan struct{}) { - for { - select { - case <-stop: - return - default: - } - - updates, err := b.getUpdates(p.LastUpdateID+1, p.Limit, p.Timeout, p.AllowedUpdates) - if err != nil { - b.debug(err) - continue - } - - for _, update := range updates { - p.LastUpdateID = update.ID - dest <- update - } - } -} diff --git a/sendable.go b/sendable.go index 38d8b48..2e782e3 100644 --- a/sendable.go +++ b/sendable.go @@ -399,3 +399,10 @@ func (g *Game) Send(b *Bot, to Recipient, opt *SendOptions) (*Message, error) { return extractMessage(data) } + +func thumbnailToFilemap(thumb *Photo) map[string]File { + if thumb != nil { + return map[string]File{"thumb": thumb.File} + } + return nil +} diff --git a/stickers.go b/stickers.go index c5b8496..3e0a626 100644 --- a/stickers.go +++ b/stickers.go @@ -7,7 +7,6 @@ import ( type StickerSetType = string -// StickerSet types. const ( StickerRegular = "regular" StickerMask = "mask" @@ -40,6 +39,16 @@ type MaskPosition struct { Scale float32 `json:"scale"` } +// MaskFeature defines sticker mask position. +type MaskFeature string + +const ( + FeatureForehead MaskFeature = "forehead" + FeatureEyes MaskFeature = "eyes" + FeatureMouth MaskFeature = "mouth" + FeatureChin MaskFeature = "chin" +) + // UploadSticker uploads a PNG file with a sticker for later use. func (b *Bot) UploadSticker(to Recipient, png *File) (*File, error) { files := map[string]File{ diff --git a/telebot.go b/telebot.go index 35ee17c..2aa1cd9 100644 --- a/telebot.go +++ b/telebot.go @@ -46,35 +46,30 @@ const DefaultApiURL = "https://api.telegram.org" // const ( // Basic message handlers. - OnText = "\atext" - OnEdited = "\aedited" - OnPhoto = "\aphoto" - OnAudio = "\aaudio" - OnAnimation = "\aanimation" - OnDocument = "\adocument" - OnSticker = "\asticker" - OnVideo = "\avideo" - OnVoice = "\avoice" - OnVideoNote = "\avideo_note" - OnContact = "\acontact" - OnLocation = "\alocation" - OnVenue = "\avenue" - OnDice = "\adice" - OnInvoice = "\ainvoice" - OnPayment = "\apayment" - OnGame = "\agame" - OnPoll = "\apoll" - OnPollAnswer = "\apoll_answer" - OnPinned = "\apinned" - - // Will fire on channel posts. + OnText = "\atext" + OnEdited = "\aedited" + OnPhoto = "\aphoto" + OnAudio = "\aaudio" + OnAnimation = "\aanimation" + OnDocument = "\adocument" + OnSticker = "\asticker" + OnVideo = "\avideo" + OnVoice = "\avoice" + OnVideoNote = "\avideo_note" + OnContact = "\acontact" + OnLocation = "\alocation" + OnVenue = "\avenue" + OnDice = "\adice" + OnInvoice = "\ainvoice" + OnPayment = "\apayment" + OnGame = "\agame" + OnPoll = "\apoll" + OnPollAnswer = "\apoll_answer" + OnPinned = "\apinned" OnChannelPost = "\achannel_post" OnEditedChannelPost = "\aedited_channel_post" - // Will fire when bot is added to a group. - OnAddedToGroup = "\aadded_to_group" - - // Service events: + OnAddedToGroup = "\aadded_to_group" OnUserJoined = "\auser_joined" OnUserLeft = "\auser_left" OnNewGroupTitle = "\anew_chat_title" @@ -84,59 +79,29 @@ const ( OnSuperGroupCreated = "\asupergroup_created" OnChannelCreated = "\achannel_created" - // Migration happens when group switches to + // OnMigration happens when group switches to // a supergroup. You might want to update // your internal references to this chat // upon switching as its ID will change. OnMigration = "\amigration" - // Will fire on any unhandled media. - OnMedia = "\amedia" - - // Will fire on callback requests. - OnCallback = "\acallback" - - // Will fire on incoming inline queries. - OnQuery = "\aquery" - - // Will fire on chosen inline results. - OnInlineResult = "\ainline_result" - - // Will fire on a shipping query. - OnShipping = "\ashipping_query" - - // Will fire on pre checkout query. - OnCheckout = "\apre_checkout_query" - - // Will fire on bot's chat member changes. - OnMyChatMember = "\amy_chat_member" - - // Will fire on chat member's changes. - OnChatMember = "\achat_member" - - // Will fire on chat join request. + OnMedia = "\amedia" + OnCallback = "\acallback" + OnQuery = "\aquery" + OnInlineResult = "\ainline_result" + OnShipping = "\ashipping_query" + OnCheckout = "\apre_checkout_query" + OnMyChatMember = "\amy_chat_member" + OnChatMember = "\achat_member" OnChatJoinRequest = "\achat_join_request" - - // Will fire on the start of a video chat. - OnVideoChatStarted = "\avideo_chat_started" - - // Will fire on the end of a video chat. - OnVideoChatEnded = "\avideo_chat_ended" - - // Will fire on invited participants to the video chat. - OnVideoChatParticipants = "\avideo_chat_participants_invited" - - // Will fire on scheduling a video chat. - OnVideoChatScheduled = "\avideo_chat_scheduled" - - // Will fire on a proximity alert. - OnProximityAlert = "\aproximity_alert_triggered" - - // Will fire on auto delete timer set. + OnProximityAlert = "\aproximity_alert_triggered" OnAutoDeleteTimer = "\amessage_auto_delete_timer_changed" + OnWebApp = "\aweb_app" - // Will fire on the web app data. - OnWebApp = "\aweb_app" + OnVideoChatStarted = "\avideo_chat_started" + OnVideoChatEnded = "\avideo_chat_ended" + OnVideoChatParticipants = "\avideo_chat_participants_invited" + OnVideoChatScheduled = "\avideo_chat_scheduled" ) // ChatAction is a client-side status indicating bot activity. @@ -166,85 +131,6 @@ const ( ModeHTML ParseMode = "HTML" ) -// EntityType is a MessageEntity type. -type EntityType string - -const ( - EntityMention EntityType = "mention" - EntityTMention EntityType = "text_mention" - EntityHashtag EntityType = "hashtag" - EntityCashtag EntityType = "cashtag" - EntityCommand EntityType = "bot_command" - EntityURL EntityType = "url" - EntityEmail EntityType = "email" - EntityPhone EntityType = "phone_number" - EntityBold EntityType = "bold" - EntityItalic EntityType = "italic" - EntityUnderline EntityType = "underline" - EntityStrikethrough EntityType = "strikethrough" - EntityCode EntityType = "code" - EntityCodeBlock EntityType = "pre" - EntityTextLink EntityType = "text_link" - EntitySpoiler EntityType = "spoiler" - EntityCustomEmoji EntityType = "custom_emoji" -) - -// ChatType represents one of the possible chat types. -type ChatType string - -const ( - ChatPrivate ChatType = "private" - ChatGroup ChatType = "group" - ChatSuperGroup ChatType = "supergroup" - ChatChannel ChatType = "channel" - ChatChannelPrivate ChatType = "privatechannel" -) - -// MemberStatus is one's chat status. -type MemberStatus string - -const ( - Creator MemberStatus = "creator" - Administrator MemberStatus = "administrator" - Member MemberStatus = "member" - Restricted MemberStatus = "restricted" - Left MemberStatus = "left" - Kicked MemberStatus = "kicked" -) - -// MaskFeature defines sticker mask position. -type MaskFeature string - -const ( - FeatureForehead MaskFeature = "forehead" - FeatureEyes MaskFeature = "eyes" - FeatureMouth MaskFeature = "mouth" - FeatureChin MaskFeature = "chin" -) - -// PollType defines poll types. -type PollType string - -const ( - // Despite "any" type isn't described in documentation, - // it needed for proper KeyboardButtonPollType marshaling. - PollAny PollType = "any" - - PollQuiz PollType = "quiz" - PollRegular PollType = "regular" -) - -type DiceType string - -var ( - Cube = &Dice{Type: "🎲"} - Dart = &Dice{Type: "🎯"} - Ball = &Dice{Type: "🏀"} - Goal = &Dice{Type: "⚽"} - Slot = &Dice{Type: "🎰"} - Bowl = &Dice{Type: "🎳"} -) - // M is a shortcut for map[string]interface{}. Use it for passing // arguments to the layout functions. type M = map[string]interface{} diff --git a/update.go b/update.go new file mode 100644 index 0000000..76da7cd --- /dev/null +++ b/update.go @@ -0,0 +1,346 @@ +package telebot + +import "strings" + +// Update object represents an incoming update. +type Update struct { + ID int `json:"update_id"` + + Message *Message `json:"message,omitempty"` + EditedMessage *Message `json:"edited_message,omitempty"` + ChannelPost *Message `json:"channel_post,omitempty"` + EditedChannelPost *Message `json:"edited_channel_post,omitempty"` + Callback *Callback `json:"callback_query,omitempty"` + Query *Query `json:"inline_query,omitempty"` + InlineResult *InlineResult `json:"chosen_inline_result,omitempty"` + ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` + PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` + Poll *Poll `json:"poll,omitempty"` + PollAnswer *PollAnswer `json:"poll_answer,omitempty"` + MyChatMember *ChatMemberUpdate `json:"my_chat_member,omitempty"` + ChatMember *ChatMemberUpdate `json:"chat_member,omitempty"` + ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` +} + +// ProcessUpdate processes a single incoming update. +// A started bot calls this function automatically. +func (b *Bot) ProcessUpdate(u Update) { + c := b.NewContext(u) + + if u.Message != nil { + m := u.Message + + if m.PinnedMessage != nil { + b.handle(OnPinned, c) + return + } + + // Commands + if m.Text != "" { + // Filtering malicious messages + if m.Text[0] == '\a' { + return + } + + match := cmdRx.FindAllStringSubmatch(m.Text, -1) + if match != nil { + // Syntax: "@ " + command, botName := match[0][1], match[0][3] + + if botName != "" && !strings.EqualFold(b.Me.Username, botName) { + return + } + + m.Payload = match[0][5] + if b.handle(command, c) { + return + } + } + + // 1:1 satisfaction + if b.handle(m.Text, c) { + return + } + + b.handle(OnText, c) + return + } + + if b.handleMedia(c) { + return + } + + if m.Contact != nil { + b.handle(OnContact, c) + return + } + if m.Location != nil { + b.handle(OnLocation, c) + return + } + if m.Venue != nil { + b.handle(OnVenue, c) + return + } + if m.Game != nil { + b.handle(OnGame, c) + return + } + if m.Dice != nil { + b.handle(OnDice, c) + return + } + if m.Invoice != nil { + b.handle(OnInvoice, c) + return + } + if m.Payment != nil { + b.handle(OnPayment, c) + return + } + + wasAdded := (m.UserJoined != nil && m.UserJoined.ID == b.Me.ID) || + (m.UsersJoined != nil && isUserInList(b.Me, m.UsersJoined)) + if m.GroupCreated || m.SuperGroupCreated || wasAdded { + b.handle(OnAddedToGroup, c) + return + } + + if m.UserJoined != nil { + b.handle(OnUserJoined, c) + return + } + + if m.UsersJoined != nil { + for _, user := range m.UsersJoined { + m.UserJoined = &user + b.handle(OnUserJoined, c) + } + return + } + + if m.UserLeft != nil { + b.handle(OnUserLeft, c) + return + } + + if m.NewGroupTitle != "" { + b.handle(OnNewGroupTitle, c) + return + } + + if m.NewGroupPhoto != nil { + b.handle(OnNewGroupPhoto, c) + return + } + + if m.GroupPhotoDeleted { + b.handle(OnGroupPhotoDeleted, c) + return + } + + if m.GroupCreated { + b.handle(OnGroupCreated, c) + return + } + + if m.SuperGroupCreated { + b.handle(OnSuperGroupCreated, c) + return + } + + if m.ChannelCreated { + b.handle(OnChannelCreated, c) + return + } + + if m.MigrateTo != 0 { + m.MigrateFrom = m.Chat.ID + b.handle(OnMigration, c) + return + } + + if m.VideoChatStarted != nil { + b.handle(OnVideoChatStarted, c) + return + } + + if m.VideoChatEnded != nil { + b.handle(OnVideoChatEnded, c) + return + } + + if m.VideoChatParticipants != nil { + b.handle(OnVideoChatParticipants, c) + return + } + + if m.VideoChatScheduled != nil { + b.handle(OnVideoChatScheduled, c) + return + } + + if m.WebAppData != nil { + b.handle(OnWebApp, c) + } + + if m.ProximityAlert != nil { + b.handle(OnProximityAlert, c) + return + } + + if m.AutoDeleteTimer != nil { + b.handle(OnAutoDeleteTimer, c) + return + } + } + + if u.EditedMessage != nil { + b.handle(OnEdited, c) + return + } + + if u.ChannelPost != nil { + m := u.ChannelPost + + if m.PinnedMessage != nil { + b.handle(OnPinned, c) + return + } + + b.handle(OnChannelPost, c) + return + } + + if u.EditedChannelPost != nil { + b.handle(OnEditedChannelPost, c) + return + } + + if u.Callback != nil { + if data := u.Callback.Data; data != "" && data[0] == '\f' { + match := cbackRx.FindAllStringSubmatch(data, -1) + if match != nil { + unique, payload := match[0][1], match[0][3] + if handler, ok := b.handlers["\f"+unique]; ok { + u.Callback.Unique = unique + u.Callback.Data = payload + b.runHandler(handler, c) + return + } + } + } + + b.handle(OnCallback, c) + return + } + + if u.Query != nil { + b.handle(OnQuery, c) + return + } + + if u.InlineResult != nil { + b.handle(OnInlineResult, c) + return + } + + if u.ShippingQuery != nil { + b.handle(OnShipping, c) + return + } + + if u.PreCheckoutQuery != nil { + b.handle(OnCheckout, c) + return + } + + if u.Poll != nil { + b.handle(OnPoll, c) + return + } + + if u.PollAnswer != nil { + b.handle(OnPollAnswer, c) + return + } + + if u.MyChatMember != nil { + b.handle(OnMyChatMember, c) + return + } + + if u.ChatMember != nil { + b.handle(OnChatMember, c) + return + } + + if u.ChatJoinRequest != nil { + b.handle(OnChatJoinRequest, c) + return + } +} + +func (b *Bot) handle(end string, c Context) bool { + if handler, ok := b.handlers[end]; ok { + b.runHandler(handler, c) + return true + } + return false +} + +func (b *Bot) handleMedia(c Context) bool { + var ( + m = c.Message() + fired = true + ) + + switch { + case m.Photo != nil: + fired = b.handle(OnPhoto, c) + case m.Voice != nil: + fired = b.handle(OnVoice, c) + case m.Audio != nil: + fired = b.handle(OnAudio, c) + case m.Animation != nil: + fired = b.handle(OnAnimation, c) + case m.Document != nil: + fired = b.handle(OnDocument, c) + case m.Sticker != nil: + fired = b.handle(OnSticker, c) + case m.Video != nil: + fired = b.handle(OnVideo, c) + case m.VideoNote != nil: + fired = b.handle(OnVideoNote, c) + default: + return false + } + + if !fired { + return b.handle(OnMedia, c) + } + + return true +} + +func (b *Bot) runHandler(h HandlerFunc, c Context) { + f := func() { + if err := h(c); err != nil { + b.OnError(err, c) + } + } + if b.synchronous { + f() + } else { + go f() + } +} + +func isUserInList(user *User, list []User) bool { + for _, user2 := range list { + if user.ID == user2.ID { + return true + } + } + return false +} diff --git a/util.go b/util.go deleted file mode 100644 index 86f3f00..0000000 --- a/util.go +++ /dev/null @@ -1,306 +0,0 @@ -package telebot - -import ( - "bytes" - "encoding/json" - "fmt" - "log" - "net/http" - "strconv" -) - -var defaultOnError = func(err error, c Context) { - if c != nil { - log.Println(c.Update().ID, err) - } else { - log.Println(err) - } -} - -func (b *Bot) debug(err error) { - if b.verbose { - b.OnError(err, nil) - } -} - -func verbose(method string, payload interface{}, data []byte) { - body, _ := json.Marshal(payload) - body = bytes.ReplaceAll(body, []byte(`\"`), []byte(`"`)) - body = bytes.ReplaceAll(body, []byte(`"{`), []byte(`{`)) - body = bytes.ReplaceAll(body, []byte(`}"`), []byte(`}`)) - - indent := func(b []byte) string { - var buf bytes.Buffer - json.Indent(&buf, b, "", " ") - return buf.String() - } - - log.Printf( - "[verbose] telebot: sent request\nMethod: %v\nParams: %v\nResponse: %v", - method, indent(body), indent(data), - ) -} - -func (b *Bot) runHandler(h HandlerFunc, c Context) { - f := func() { - if err := h(c); err != nil { - b.OnError(err, c) - } - } - if b.synchronous { - f() - } else { - go f() - } -} - -func applyMiddleware(h HandlerFunc, m ...MiddlewareFunc) HandlerFunc { - for i := len(m) - 1; i >= 0; i-- { - h = m[i](h) - } - return h -} - -// wrapError returns new wrapped telebot-related error. -func wrapError(err error) error { - return fmt.Errorf("telebot: %w", err) -} - -// extractOk checks given result for error. If result is ok returns nil. -// In other cases it extracts API error. If error is not presented -// in errors.go, it will be prefixed with `unknown` keyword. -func extractOk(data []byte) error { - var e struct { - Ok bool `json:"ok"` - Code int `json:"error_code"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` - } - if json.NewDecoder(bytes.NewReader(data)).Decode(&e) != nil { - return nil // FIXME - } - if e.Ok { - return nil - } - - err := Err(e.Description) - switch err { - case nil: - case ErrGroupMigrated: - migratedTo, ok := e.Parameters["migrate_to_chat_id"] - if !ok { - return NewError(e.Code, e.Description) - } - - return GroupError{ - err: err.(*Error), - MigratedTo: int64(migratedTo.(float64)), - } - default: - return err - } - - switch e.Code { - case http.StatusTooManyRequests: - retryAfter, ok := e.Parameters["retry_after"] - if !ok { - return NewError(e.Code, e.Description) - } - - err = FloodError{ - err: NewError(e.Code, e.Description), - RetryAfter: int(retryAfter.(float64)), - } - default: - err = fmt.Errorf("telegram: %s (%d)", e.Description, e.Code) - } - - return err -} - -// extractMessage extracts common Message result from given data. -// Should be called after extractOk or b.Raw() to handle possible errors. -func extractMessage(data []byte) (*Message, error) { - var resp struct { - Result *Message - } - if err := json.Unmarshal(data, &resp); err != nil { - var resp struct { - Result bool - } - if err := json.Unmarshal(data, &resp); err != nil { - return nil, wrapError(err) - } - if resp.Result { - return nil, ErrTrueResult - } - return nil, wrapError(err) - } - return resp.Result, nil -} - -func extractOptions(how []interface{}) *SendOptions { - opts := &SendOptions{} - - for _, prop := range how { - switch opt := prop.(type) { - case *SendOptions: - opts = opt.copy() - case *ReplyMarkup: - if opt != nil { - opts.ReplyMarkup = opt.copy() - } - case Option: - switch opt { - case NoPreview: - opts.DisableWebPagePreview = true - case Silent: - opts.DisableNotification = true - case AllowWithoutReply: - opts.AllowWithoutReply = true - case ForceReply: - if opts.ReplyMarkup == nil { - opts.ReplyMarkup = &ReplyMarkup{} - } - opts.ReplyMarkup.ForceReply = true - case OneTimeKeyboard: - if opts.ReplyMarkup == nil { - opts.ReplyMarkup = &ReplyMarkup{} - } - opts.ReplyMarkup.OneTimeKeyboard = true - case RemoveKeyboard: - if opts.ReplyMarkup == nil { - opts.ReplyMarkup = &ReplyMarkup{} - } - opts.ReplyMarkup.RemoveKeyboard = true - case Protected: - opts.Protected = true - default: - panic("telebot: unsupported flag-option") - } - case ParseMode: - opts.ParseMode = opt - case Entities: - opts.Entities = opt - default: - panic("telebot: unsupported send-option") - } - } - - return opts -} - -// extractCommandsParams extracts parameters for commands-related methods from the given options. -func extractCommandsParams(opts ...interface{}) (params CommandParams) { - for _, opt := range opts { - switch value := opt.(type) { - case []Command: - params.Commands = value - case string: - params.LanguageCode = value - case CommandScope: - params.Scope = &value - } - } - return -} - -func (b *Bot) embedSendOptions(params map[string]string, opt *SendOptions) { - if b.parseMode != ModeDefault { - params["parse_mode"] = b.parseMode - } - - if opt == nil { - return - } - - if opt.ReplyTo != nil && opt.ReplyTo.ID != 0 { - params["reply_to_message_id"] = strconv.Itoa(opt.ReplyTo.ID) - } - - if opt.DisableWebPagePreview { - params["disable_web_page_preview"] = "true" - } - - if opt.DisableNotification { - params["disable_notification"] = "true" - } - - if opt.ParseMode != ModeDefault { - params["parse_mode"] = opt.ParseMode - } - - if len(opt.Entities) > 0 { - delete(params, "parse_mode") - entities, _ := json.Marshal(opt.Entities) - - if params["caption"] != "" { - params["caption_entities"] = string(entities) - } else { - params["entities"] = string(entities) - } - } - - if opt.AllowWithoutReply { - params["allow_sending_without_reply"] = "true" - } - - if opt.ReplyMarkup != nil { - processButtons(opt.ReplyMarkup.InlineKeyboard) - replyMarkup, _ := json.Marshal(opt.ReplyMarkup) - params["reply_markup"] = string(replyMarkup) - } - - if opt.Protected { - params["protect_content"] = "true" - } -} - -func processButtons(keys [][]InlineButton) { - if keys == nil || len(keys) < 1 || len(keys[0]) < 1 { - return - } - - for i := range keys { - for j := range keys[i] { - key := &keys[i][j] - if key.Unique != "" { - // Format: "\f|" - data := key.Data - if data == "" { - key.Data = "\f" + key.Unique - } else { - key.Data = "\f" + key.Unique + "|" + data - } - } - } - } -} - -func embedRights(p map[string]interface{}, rights Rights) { - data, _ := json.Marshal(rights) - _ = json.Unmarshal(data, &p) -} - -func thumbnailToFilemap(thumb *Photo) map[string]File { - if thumb != nil { - return map[string]File{"thumb": thumb.File} - } - return nil -} - -func isUserInList(user *User, list []User) bool { - for _, user2 := range list { - if user.ID == user2.ID { - return true - } - } - return false -} - -func intsToStrs(ns []int) (s []string) { - for _, n := range ns { - s = append(s, strconv.Itoa(n)) - } - return -} diff --git a/util_test.go b/util_test.go deleted file mode 100644 index f1937bb..0000000 --- a/util_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package telebot - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExtractOk(t *testing.T) { - data := []byte(`{"ok": true, "result": {}}`) - require.NoError(t, extractOk(data)) - - data = []byte(`{ - "ok": false, - "error_code": 400, - "description": "Bad Request: reply message not found" - }`) - assert.EqualError(t, extractOk(data), ErrNotFoundToReply.Error()) - - data = []byte(`{ - "ok": false, - "error_code": 429, - "description": "Too Many Requests: retry after 8", - "parameters": {"retry_after": 8} - }`) - assert.Equal(t, FloodError{ - err: NewError(429, "Too Many Requests: retry after 8"), - RetryAfter: 8, - }, extractOk(data)) - - data = []byte(`{ - "ok": false, - "error_code": 400, - "description": "Bad Request: group chat was upgraded to a supergroup chat", - "parameters": {"migrate_to_chat_id": -100123456789} - }`) - assert.Equal(t, GroupError{ - err: ErrGroupMigrated, - MigratedTo: -100123456789, - }, extractOk(data)) -} - -func TestExtractMessage(t *testing.T) { - data := []byte(`{"ok":true,"result":true}`) - _, err := extractMessage(data) - assert.Equal(t, ErrTrueResult, err) - - data = []byte(`{"ok":true,"result":{"foo":"bar"}}`) - _, err = extractMessage(data) - require.NoError(t, err) -} - -func TestEmbedRights(t *testing.T) { - rights := NoRestrictions() - params := map[string]interface{}{ - "chat_id": "1", - "user_id": "2", - } - embedRights(params, rights) - - expected := map[string]interface{}{ - "is_anonymous": false, - "chat_id": "1", - "user_id": "2", - "can_be_edited": true, - "can_send_messages": true, - "can_send_media_messages": true, - "can_send_polls": true, - "can_send_other_messages": true, - "can_add_web_page_previews": true, - "can_change_info": false, - "can_post_messages": false, - "can_edit_messages": false, - "can_delete_messages": false, - "can_invite_users": false, - "can_restrict_members": false, - "can_pin_messages": false, - "can_promote_members": false, - "can_manage_video_chats": false, - "can_manage_chat": false, - } - assert.Equal(t, expected, params) -}