feat: sync bot keyboards and callbacks
Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery). Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
parent
0c99ae0a9d
commit
bf965f610c
80 changed files with 7212 additions and 349 deletions
|
|
@ -36,7 +36,7 @@ func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) {
|
|||
if err != nil {
|
||||
return domain.BotInlineResult{}, err
|
||||
}
|
||||
markup, err := replyMarkupFromAPI(payload.ReplyMarkup)
|
||||
markup, err := inlineReplyMarkupFromAPI(payload.ReplyMarkup)
|
||||
if err != nil {
|
||||
return domain.BotInlineResult{}, err
|
||||
}
|
||||
|
|
@ -165,6 +165,64 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
|
|||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var shape map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &shape); err != nil {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
constructors := 0
|
||||
for _, key := range []string{"inline_keyboard", "keyboard", "remove_keyboard", "force_reply"} {
|
||||
if _, ok := shape[key]; ok {
|
||||
constructors++
|
||||
}
|
||||
}
|
||||
if constructors != 1 {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
if _, ok := shape["inline_keyboard"]; ok {
|
||||
return inlineKeyboardMarkupFromAPI(raw)
|
||||
}
|
||||
if _, ok := shape["keyboard"]; ok {
|
||||
return replyKeyboardMarkupFromAPI(raw)
|
||||
}
|
||||
if _, ok := shape["remove_keyboard"]; ok {
|
||||
var payload apiReplyKeyboardRemove
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || !payload.RemoveKeyboard {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: payload.Selective}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, replyMarkupErrFromDomain(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var payload apiForceReply
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || !payload.ForceReply {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupForceReply,
|
||||
SingleUse: true,
|
||||
Selective: payload.Selective,
|
||||
Placeholder: payload.InputFieldPlaceholder,
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, replyMarkupErrFromDomain(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func inlineReplyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
|
||||
markup, err := replyMarkupFromAPI(raw)
|
||||
if err != nil || markup == nil {
|
||||
return markup, err
|
||||
}
|
||||
if markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func inlineKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
|
||||
var payload apiInlineKeyboardMarkup
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
|
|
@ -172,7 +230,7 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
|
|||
if len(payload.InlineKeyboard) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))}
|
||||
for _, row := range payload.InlineKeyboard {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row))
|
||||
for _, button := range row {
|
||||
|
|
@ -193,19 +251,132 @@ func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
|
||||
if button.URL != "" {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil
|
||||
func replyKeyboardMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) {
|
||||
var payload apiReplyKeyboardMarkup
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || len(payload.Keyboard) == 0 {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
if button.CallbackData != nil {
|
||||
if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen {
|
||||
out := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: make([][]domain.MarkupButton, 0, len(payload.Keyboard)),
|
||||
Resize: payload.ResizeKeyboard,
|
||||
SingleUse: payload.OneTimeKeyboard,
|
||||
Selective: payload.Selective,
|
||||
Persistent: payload.IsPersistent,
|
||||
Placeholder: payload.InputFieldPlaceholder,
|
||||
}
|
||||
for _, row := range payload.Keyboard {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row))
|
||||
for _, button := range row {
|
||||
if button.Text == "" {
|
||||
return nil, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
if button.Unsupported {
|
||||
return nil, errors.New("BUTTON_TYPE_INVALID")
|
||||
}
|
||||
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := domain.MarkupButton{Type: domain.MarkupButtonText, Text: button.Text, Style: style, IconCustomEmojiID: icon}
|
||||
switch button.Kind {
|
||||
case "request_contact":
|
||||
item.Type = domain.MarkupButtonRequestPhone
|
||||
case "request_location":
|
||||
item.Type = domain.MarkupButtonRequestLocation
|
||||
case "request_poll":
|
||||
item.Type, item.PollType = domain.MarkupButtonRequestPoll, button.PollType
|
||||
case "request_users":
|
||||
item.Type, item.ButtonID, item.RequestPeerType = domain.MarkupButtonRequestPeer, button.RequestID, "user"
|
||||
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = button.MaxQuantity, button.RequestName, button.RequestUsername, button.RequestPhoto
|
||||
item.RequestPeerFilter = button.RequestPeerFilter
|
||||
case "request_chat":
|
||||
item.Type, item.ButtonID = domain.MarkupButtonRequestPeer, button.RequestID
|
||||
if button.ChatIsChannel {
|
||||
item.RequestPeerType = "broadcast"
|
||||
} else {
|
||||
item.RequestPeerType = "chat"
|
||||
}
|
||||
item.MaxQuantity, item.NameRequested, item.UsernameRequested, item.PhotoRequested = 1, button.RequestTitle, button.RequestUsername, button.RequestPhoto
|
||||
item.RequestPeerFilter = button.RequestPeerFilter
|
||||
case "web_app":
|
||||
item.Type, item.URL = domain.MarkupButtonSimpleWebView, button.WebAppURL
|
||||
}
|
||||
domainRow = append(domainRow, item)
|
||||
}
|
||||
out.Keyboard = append(out.Keyboard, domainRow)
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, replyMarkupErrFromDomain(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) {
|
||||
if button.Unsupported {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_TYPE_INVALID")
|
||||
}
|
||||
constructors := 0
|
||||
if button.URLSet {
|
||||
constructors++
|
||||
}
|
||||
if button.CallbackDataSet {
|
||||
constructors++
|
||||
}
|
||||
if button.WebAppSet {
|
||||
constructors++
|
||||
}
|
||||
if button.SwitchInlineSet {
|
||||
constructors++
|
||||
}
|
||||
if button.CopyTextSet {
|
||||
constructors++
|
||||
}
|
||||
if constructors != 1 {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
style, icon, err := markupButtonDecorationFromAPI(button.Style, button.IconCustomEmojiID, button.IconCustomEmojiIDSet)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
}
|
||||
if button.URLSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.CallbackDataSet {
|
||||
if button.CallbackData == "" || len([]byte(button.CallbackData)) > domain.MaxCallbackDataLen {
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID")
|
||||
}
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(button.CallbackData), Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.WebAppSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: button.Text, URL: button.WebAppURL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.SwitchInlineSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: button.Text, Query: button.SwitchInlineQuery, SamePeer: button.SwitchInlineSamePeer, PeerTypes: append([]string(nil), button.SwitchInlinePeerTypes...), Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
if button.CopyTextSet {
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: button.Text, CopyText: button.CopyText, Style: style, IconCustomEmojiID: icon}, nil
|
||||
}
|
||||
return domain.MarkupButton{}, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
|
||||
func markupButtonDecorationFromAPI(rawStyle, rawIcon string, iconSet bool) (domain.MarkupButtonStyle, int64, error) {
|
||||
style := domain.MarkupButtonStyle(strings.TrimSpace(rawStyle))
|
||||
switch style {
|
||||
case "", domain.MarkupButtonStylePrimary, domain.MarkupButtonStyleDanger, domain.MarkupButtonStyleSuccess:
|
||||
default:
|
||||
return "", 0, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
if !iconSet {
|
||||
return style, 0, nil
|
||||
}
|
||||
icon, err := strconv.ParseInt(strings.TrimSpace(rawIcon), 10, 64)
|
||||
if err != nil || icon <= 0 {
|
||||
return "", 0, errors.New("BUTTON_INVALID")
|
||||
}
|
||||
return style, icon, nil
|
||||
}
|
||||
|
||||
func replyMarkupErrFromDomain(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrButtonURLInvalid):
|
||||
|
|
@ -287,8 +458,342 @@ type apiInlineKeyboardMarkup struct {
|
|||
InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type apiInlineKeyboardButton struct {
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
CallbackData *string `json:"callback_data"`
|
||||
type apiReplyKeyboardMarkup struct {
|
||||
Keyboard [][]apiKeyboardButton `json:"keyboard"`
|
||||
IsPersistent bool `json:"is_persistent"`
|
||||
ResizeKeyboard bool `json:"resize_keyboard"`
|
||||
OneTimeKeyboard bool `json:"one_time_keyboard"`
|
||||
InputFieldPlaceholder string `json:"input_field_placeholder"`
|
||||
Selective bool `json:"selective"`
|
||||
}
|
||||
|
||||
type apiKeyboardButton struct {
|
||||
Text string
|
||||
Style string
|
||||
IconCustomEmojiID string
|
||||
IconCustomEmojiIDSet bool
|
||||
Unsupported bool
|
||||
Kind string
|
||||
PollType string
|
||||
RequestID int
|
||||
MaxQuantity int
|
||||
RequestName bool
|
||||
RequestUsername bool
|
||||
RequestPhoto bool
|
||||
RequestTitle bool
|
||||
ChatIsChannel bool
|
||||
WebAppURL string
|
||||
RequestPeerFilter *domain.BotRequestPeerFilter
|
||||
}
|
||||
|
||||
type apiChatAdministratorRights struct {
|
||||
IsAnonymous bool `json:"is_anonymous"`
|
||||
CanManageChat bool `json:"can_manage_chat"`
|
||||
CanDeleteMessages bool `json:"can_delete_messages"`
|
||||
CanManageVideoChats bool `json:"can_manage_video_chats"`
|
||||
CanRestrictMembers bool `json:"can_restrict_members"`
|
||||
CanPromoteMembers bool `json:"can_promote_members"`
|
||||
CanChangeInfo bool `json:"can_change_info"`
|
||||
CanInviteUsers bool `json:"can_invite_users"`
|
||||
CanPostStories bool `json:"can_post_stories"`
|
||||
CanEditStories bool `json:"can_edit_stories"`
|
||||
CanDeleteStories bool `json:"can_delete_stories"`
|
||||
CanPostMessages bool `json:"can_post_messages"`
|
||||
CanEditMessages bool `json:"can_edit_messages"`
|
||||
CanPinMessages bool `json:"can_pin_messages"`
|
||||
CanManageTopics bool `json:"can_manage_topics"`
|
||||
CanManageDirectMessages bool `json:"can_manage_direct_messages"`
|
||||
}
|
||||
|
||||
func domainRequestAdminRights(in *apiChatAdministratorRights) *domain.BotRequestAdminRights {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.BotRequestAdminRights{
|
||||
Anonymous: in.IsAnonymous, ManageChat: in.CanManageChat, DeleteMessages: in.CanDeleteMessages,
|
||||
ManageVideoChats: in.CanManageVideoChats, RestrictMembers: in.CanRestrictMembers,
|
||||
PromoteMembers: in.CanPromoteMembers, ChangeInfo: in.CanChangeInfo, InviteUsers: in.CanInviteUsers,
|
||||
PostStories: in.CanPostStories, EditStories: in.CanEditStories, DeleteStories: in.CanDeleteStories,
|
||||
PostMessages: in.CanPostMessages, EditMessages: in.CanEditMessages, PinMessages: in.CanPinMessages,
|
||||
ManageTopics: in.CanManageTopics, ManageDirectMessages: in.CanManageDirectMessages,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *apiKeyboardButton) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(trimmed, "\"") {
|
||||
b.Kind = "text"
|
||||
return json.Unmarshal([]byte(trimmed), &b.Text)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
text, ok := fields["text"]
|
||||
if !ok || json.Unmarshal(text, &b.Text) != nil {
|
||||
return errors.New("invalid keyboard button text")
|
||||
}
|
||||
if raw, ok := fields["style"]; ok {
|
||||
if err := json.Unmarshal(raw, &b.Style); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["icon_custom_emoji_id"]; ok {
|
||||
b.IconCustomEmojiIDSet = true
|
||||
if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
actions := 0
|
||||
if raw, ok := fields["request_contact"]; ok {
|
||||
var enabled bool
|
||||
if json.Unmarshal(raw, &enabled) != nil || !enabled {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind = "request_contact"
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["request_location"]; ok {
|
||||
var enabled bool
|
||||
if json.Unmarshal(raw, &enabled) != nil || !enabled {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind = "request_location"
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["request_poll"]; ok {
|
||||
var poll struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(raw, &poll) != nil {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind, b.PollType = "request_poll", poll.Type
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["request_users"]; ok {
|
||||
var request struct {
|
||||
RequestID int `json:"request_id"`
|
||||
UserIsBot *bool `json:"user_is_bot"`
|
||||
UserIsPremium *bool `json:"user_is_premium"`
|
||||
MaxQuantity int `json:"max_quantity"`
|
||||
RequestName bool `json:"request_name"`
|
||||
RequestUsername bool `json:"request_username"`
|
||||
RequestPhoto bool `json:"request_photo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind, b.RequestID, b.MaxQuantity, b.RequestName, b.RequestUsername, b.RequestPhoto = "request_users", request.RequestID, request.MaxQuantity, request.RequestName, request.RequestUsername, request.RequestPhoto
|
||||
b.RequestPeerFilter = &domain.BotRequestPeerFilter{}
|
||||
if request.UserIsBot != nil {
|
||||
b.RequestPeerFilter.UserIsBotSet, b.RequestPeerFilter.UserIsBot = true, *request.UserIsBot
|
||||
}
|
||||
if request.UserIsPremium != nil {
|
||||
b.RequestPeerFilter.UserIsPremiumSet, b.RequestPeerFilter.UserIsPremium = true, *request.UserIsPremium
|
||||
}
|
||||
if b.MaxQuantity == 0 {
|
||||
b.MaxQuantity = 1
|
||||
}
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["request_chat"]; ok {
|
||||
var request struct {
|
||||
RequestID int `json:"request_id"`
|
||||
ChatIsChannel bool `json:"chat_is_channel"`
|
||||
ChatIsForum *bool `json:"chat_is_forum"`
|
||||
ChatHasUsername *bool `json:"chat_has_username"`
|
||||
ChatIsCreated bool `json:"chat_is_created"`
|
||||
UserAdministratorRights *apiChatAdministratorRights `json:"user_administrator_rights"`
|
||||
BotAdministratorRights *apiChatAdministratorRights `json:"bot_administrator_rights"`
|
||||
BotIsMember bool `json:"bot_is_member"`
|
||||
RequestTitle bool `json:"request_title"`
|
||||
RequestUsername bool `json:"request_username"`
|
||||
RequestPhoto bool `json:"request_photo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &request) != nil || request.RequestID == 0 || (request.ChatIsChannel && (request.ChatIsForum != nil || request.BotIsMember)) {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind, b.RequestID, b.ChatIsChannel, b.RequestTitle, b.RequestUsername, b.RequestPhoto = "request_chat", request.RequestID, request.ChatIsChannel, request.RequestTitle, request.RequestUsername, request.RequestPhoto
|
||||
b.RequestPeerFilter = &domain.BotRequestPeerFilter{
|
||||
ChatIsCreated: request.ChatIsCreated, BotIsMember: request.BotIsMember,
|
||||
UserAdminRights: domainRequestAdminRights(request.UserAdministratorRights),
|
||||
BotAdminRights: domainRequestAdminRights(request.BotAdministratorRights),
|
||||
}
|
||||
if request.ChatIsForum != nil {
|
||||
b.RequestPeerFilter.ChatIsForumSet, b.RequestPeerFilter.ChatIsForum = true, *request.ChatIsForum
|
||||
}
|
||||
if request.ChatHasUsername != nil {
|
||||
b.RequestPeerFilter.ChatHasUsernameSet, b.RequestPeerFilter.ChatHasUsername = true, *request.ChatHasUsername
|
||||
}
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["web_app"]; ok {
|
||||
var app struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(raw, &app) != nil {
|
||||
b.Unsupported = true
|
||||
} else {
|
||||
b.Kind, b.WebAppURL = "web_app", app.URL
|
||||
actions++
|
||||
}
|
||||
}
|
||||
if actions == 0 {
|
||||
b.Kind = "text"
|
||||
}
|
||||
if actions > 1 {
|
||||
b.Unsupported = true
|
||||
}
|
||||
for key := range fields {
|
||||
switch key {
|
||||
case "text", "style", "icon_custom_emoji_id", "request_contact", "request_location", "request_poll", "request_users", "request_chat", "web_app":
|
||||
default:
|
||||
b.Unsupported = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type apiReplyKeyboardRemove struct {
|
||||
RemoveKeyboard bool `json:"remove_keyboard"`
|
||||
Selective bool `json:"selective"`
|
||||
}
|
||||
|
||||
type apiForceReply struct {
|
||||
ForceReply bool `json:"force_reply"`
|
||||
InputFieldPlaceholder string `json:"input_field_placeholder"`
|
||||
Selective bool `json:"selective"`
|
||||
}
|
||||
|
||||
type apiInlineKeyboardButton struct {
|
||||
Text string
|
||||
URL string
|
||||
URLSet bool
|
||||
CallbackData string
|
||||
CallbackDataSet bool
|
||||
Style string
|
||||
IconCustomEmojiID string
|
||||
IconCustomEmojiIDSet bool
|
||||
Unsupported bool
|
||||
WebAppURL string
|
||||
WebAppSet bool
|
||||
SwitchInlineQuery string
|
||||
SwitchInlineSet bool
|
||||
SwitchInlineSamePeer bool
|
||||
SwitchInlinePeerTypes []string
|
||||
CopyText string
|
||||
CopyTextSet bool
|
||||
}
|
||||
|
||||
func (b *apiInlineKeyboardButton) UnmarshalJSON(data []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
text, ok := fields["text"]
|
||||
if !ok || json.Unmarshal(text, &b.Text) != nil {
|
||||
return errors.New("invalid inline keyboard button text")
|
||||
}
|
||||
if raw, ok := fields["url"]; ok {
|
||||
b.URLSet = true
|
||||
if err := json.Unmarshal(raw, &b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["callback_data"]; ok {
|
||||
b.CallbackDataSet = true
|
||||
if err := json.Unmarshal(raw, &b.CallbackData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["web_app"]; ok {
|
||||
b.WebAppSet = true
|
||||
var app struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(raw, &app) != nil {
|
||||
return errors.New("invalid web app")
|
||||
}
|
||||
b.WebAppURL = app.URL
|
||||
}
|
||||
switchActions := 0
|
||||
if raw, ok := fields["switch_inline_query"]; ok {
|
||||
switchActions++
|
||||
b.SwitchInlineSet = true
|
||||
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
|
||||
return errors.New("invalid switch inline query")
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["switch_inline_query_current_chat"]; ok {
|
||||
switchActions++
|
||||
b.SwitchInlineSet, b.SwitchInlineSamePeer = true, true
|
||||
if json.Unmarshal(raw, &b.SwitchInlineQuery) != nil {
|
||||
return errors.New("invalid switch inline query")
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["switch_inline_query_chosen_chat"]; ok {
|
||||
switchActions++
|
||||
b.SwitchInlineSet = true
|
||||
var chosen struct {
|
||||
Query string `json:"query"`
|
||||
AllowUserChats bool `json:"allow_user_chats"`
|
||||
AllowBotChats bool `json:"allow_bot_chats"`
|
||||
AllowGroupChats bool `json:"allow_group_chats"`
|
||||
AllowChannelChats bool `json:"allow_channel_chats"`
|
||||
}
|
||||
if json.Unmarshal(raw, &chosen) != nil {
|
||||
return errors.New("invalid switch inline query")
|
||||
}
|
||||
b.SwitchInlineQuery = chosen.Query
|
||||
if chosen.AllowUserChats {
|
||||
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypePM)
|
||||
}
|
||||
if chosen.AllowBotChats {
|
||||
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBotPM)
|
||||
}
|
||||
if chosen.AllowGroupChats {
|
||||
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup)
|
||||
}
|
||||
if chosen.AllowChannelChats {
|
||||
b.SwitchInlinePeerTypes = append(b.SwitchInlinePeerTypes, store.InlineQueryPeerTypeBroadcast)
|
||||
}
|
||||
}
|
||||
if switchActions > 1 {
|
||||
b.Unsupported = true
|
||||
}
|
||||
if raw, ok := fields["copy_text"]; ok {
|
||||
b.CopyTextSet = true
|
||||
var copy struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(raw, ©) != nil {
|
||||
return errors.New("invalid copy text")
|
||||
}
|
||||
b.CopyText = copy.Text
|
||||
}
|
||||
if raw, ok := fields["style"]; ok {
|
||||
if err := json.Unmarshal(raw, &b.Style); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := fields["icon_custom_emoji_id"]; ok {
|
||||
b.IconCustomEmojiIDSet = true
|
||||
if err := json.Unmarshal(raw, &b.IconCustomEmojiID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for key := range fields {
|
||||
switch key {
|
||||
case "text", "url", "callback_data", "web_app", "switch_inline_query", "switch_inline_query_current_chat", "switch_inline_query_chosen_chat", "copy_text", "style", "icon_custom_emoji_id":
|
||||
default:
|
||||
b.Unsupported = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package botapi
|
|||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func apiInt(raw string, fallback int) int {
|
||||
|
|
@ -33,33 +35,42 @@ func botAPIMessageEntities(raw string) ([]domain.MessageEntity, error) {
|
|||
return messageEntitiesFromAPI(payload)
|
||||
}
|
||||
|
||||
func allowedUpdates(raw string) map[string]struct{} {
|
||||
func parseAllowedUpdates(raw string) ([]domain.BotAPIUpdateKind, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
return nil, errors.New("ALLOWED_UPDATES_INVALID")
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
return nil, errors.New("ALLOWED_UPDATES_INVALID")
|
||||
}
|
||||
out := make(map[string]struct{}, len(items))
|
||||
if len(items) > 100 {
|
||||
return nil, errors.New("ALLOWED_UPDATES_INVALID")
|
||||
}
|
||||
seen := make(map[domain.BotAPIUpdateKind]struct{}, len(items))
|
||||
out := make([]domain.BotAPIUpdateKind, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out[item] = struct{}{}
|
||||
if item == "" || len(item) > 64 {
|
||||
return nil, errors.New("ALLOWED_UPDATES_INVALID")
|
||||
}
|
||||
kind := domain.BotAPIUpdateKind(item)
|
||||
if _, ok := seen[kind]; !ok {
|
||||
seen[kind] = struct{}{}
|
||||
out = append(out, kind)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit int) []map[string]any {
|
||||
func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
out := make([]map[string]any, 0, min(len(events), limit))
|
||||
for _, event := range events {
|
||||
item, kind, ok := apiUpdate(event)
|
||||
if !ok || !updateAllowed(kind, allowed) {
|
||||
item, _, ok := apiUpdate(event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
|
|
@ -73,14 +84,6 @@ func apiUpdates(events []domain.UpdateEvent, allowed map[string]struct{}, limit
|
|||
return out
|
||||
}
|
||||
|
||||
func updateAllowed(kind string, allowed map[string]struct{}) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
_, ok := allowed[kind]
|
||||
return ok
|
||||
}
|
||||
|
||||
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
||||
if event.Pts <= 0 {
|
||||
return nil, "", false
|
||||
|
|
@ -92,7 +95,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
|||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"message": apiMessage(event.Message, event.Users),
|
||||
"message": apiMessage(event.Message, event.Users, event.Channels),
|
||||
}, "message", true
|
||||
case domain.UpdateEventEditMessage:
|
||||
if !apiMessageProjectable(event.Message) {
|
||||
|
|
@ -100,18 +103,90 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
|
|||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"edited_message": apiMessage(event.Message, event.Users),
|
||||
"edited_message": apiMessage(event.Message, event.Users, event.Channels),
|
||||
}, "edited_message", true
|
||||
case domain.UpdateEventBotCallbackQuery:
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.ID == 0 || callback.UserID == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
var from domain.User
|
||||
for _, user := range event.Users {
|
||||
if user.ID == callback.UserID {
|
||||
from = user
|
||||
break
|
||||
}
|
||||
}
|
||||
if from.ID == 0 {
|
||||
from = domain.User{ID: callback.UserID}
|
||||
}
|
||||
query := map[string]any{
|
||||
"id": strconv.FormatInt(callback.ID, 10),
|
||||
"from": apiUser(from),
|
||||
"chat_instance": strconv.FormatInt(callback.ChatInstance, 10),
|
||||
"data": string(callback.Data),
|
||||
}
|
||||
if callback.InlineMessage != nil {
|
||||
inlineMessageID, ok := encodeBotAPIInlineMessageID(*callback.InlineMessage)
|
||||
if !ok || callback.MessageID != 0 || callback.Peer != (domain.Peer{}) {
|
||||
return nil, "", false
|
||||
}
|
||||
query["inline_message_id"] = inlineMessageID
|
||||
} else {
|
||||
if callback.MessageID <= 0 || event.Message.ID != callback.MessageID {
|
||||
return nil, "", false
|
||||
}
|
||||
query["message"] = apiMessage(event.Message, event.Users, event.Channels)
|
||||
}
|
||||
return map[string]any{
|
||||
"update_id": event.Pts,
|
||||
"callback_query": query,
|
||||
}, "callback_query", true
|
||||
default:
|
||||
return nil, "", false
|
||||
}
|
||||
}
|
||||
|
||||
const botAPIInlineMessageIDVersion byte = 1
|
||||
|
||||
// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an
|
||||
// opaque, fixed-size Bot API token. AccessHash remains the authorization boundary; the
|
||||
// version byte lets us reject rather than reinterpret future shapes.
|
||||
func encodeBotAPIInlineMessageID(id domain.BotInlineMessageID) (string, bool) {
|
||||
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
|
||||
return "", false
|
||||
}
|
||||
buf := make([]byte, 1+4+8+4+8)
|
||||
buf[0] = botAPIInlineMessageIDVersion
|
||||
binary.LittleEndian.PutUint32(buf[1:5], uint32(id.DCID))
|
||||
binary.LittleEndian.PutUint64(buf[5:13], uint64(id.OwnerID))
|
||||
binary.LittleEndian.PutUint32(buf[13:17], uint32(id.ID))
|
||||
binary.LittleEndian.PutUint64(buf[17:25], uint64(id.AccessHash))
|
||||
return base64.RawURLEncoding.EncodeToString(buf), true
|
||||
}
|
||||
|
||||
func decodeBotAPIInlineMessageID(raw string) (domain.BotInlineMessageID, error) {
|
||||
buf, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil || len(buf) != 25 || buf[0] != botAPIInlineMessageIDVersion {
|
||||
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
|
||||
}
|
||||
id := domain.BotInlineMessageID{
|
||||
DCID: int(binary.LittleEndian.Uint32(buf[1:5])),
|
||||
OwnerID: int64(binary.LittleEndian.Uint64(buf[5:13])),
|
||||
ID: int(binary.LittleEndian.Uint32(buf[13:17])),
|
||||
AccessHash: int64(binary.LittleEndian.Uint64(buf[17:25])),
|
||||
}
|
||||
if id.DCID <= 0 || id.OwnerID == 0 || id.ID <= 0 || id.AccessHash == 0 {
|
||||
return domain.BotInlineMessageID{}, errors.New("INLINE_MESSAGE_ID_INVALID")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func apiMessageProjectable(msg domain.Message) bool {
|
||||
if msg.Out || msg.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
return msg.Body != "" || len(apiMessageMedia(msg.Media)) > 0
|
||||
return msg.Body != "" || len(apiMessageMedia(msg.Media, nil, nil)) > 0
|
||||
}
|
||||
|
||||
func apiUser(u domain.User) map[string]any {
|
||||
|
|
@ -133,11 +208,17 @@ func apiUser(u domain.User) map[string]any {
|
|||
return out
|
||||
}
|
||||
|
||||
func apiMessage(msg domain.Message, users []domain.User) map[string]any {
|
||||
func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domain.Channel) map[string]any {
|
||||
userByID := map[int64]domain.User{}
|
||||
for _, u := range users {
|
||||
userByID[u.ID] = u
|
||||
}
|
||||
channelByID := map[int64]domain.Channel{}
|
||||
if len(channelLists) > 0 {
|
||||
for _, channel := range channelLists[0] {
|
||||
channelByID[channel.ID] = channel
|
||||
}
|
||||
}
|
||||
out := map[string]any{
|
||||
"message_id": msg.ID,
|
||||
"date": msg.Date,
|
||||
|
|
@ -153,17 +234,25 @@ func apiMessage(msg domain.Message, users []domain.User) map[string]any {
|
|||
}
|
||||
out["from"] = apiUser(from)
|
||||
}
|
||||
media := apiMessageMedia(msg.Media)
|
||||
media := apiMessageMedia(msg.Media, userByID, channelByID)
|
||||
if msg.Body != "" {
|
||||
if len(media) > 0 {
|
||||
if _, photo := media["photo"]; photo {
|
||||
out["caption"] = msg.Body
|
||||
} else if _, document := media["document"]; document {
|
||||
out["caption"] = msg.Body
|
||||
} else if poll, ok := media["poll"].(map[string]any); ok {
|
||||
poll["description"] = msg.Body
|
||||
} else {
|
||||
out["text"] = msg.Body
|
||||
}
|
||||
}
|
||||
if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
|
||||
if len(media) > 0 {
|
||||
if _, photo := media["photo"]; photo {
|
||||
out["caption_entities"] = entities
|
||||
} else if _, document := media["document"]; document {
|
||||
out["caption_entities"] = entities
|
||||
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
|
||||
poll["description_entities"] = entities
|
||||
} else {
|
||||
out["entities"] = entities
|
||||
}
|
||||
|
|
@ -309,6 +398,11 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
|
|||
if markup.IsZero() {
|
||||
return nil
|
||||
}
|
||||
// Bot API Message.reply_markup is InlineKeyboardMarkup only. ReplyKeyboardMarkup,
|
||||
// ReplyKeyboardRemove and ForceReply are send parameters, not message response fields.
|
||||
if markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return nil
|
||||
}
|
||||
rows := make([][]map[string]any, 0, len(markup.Inline))
|
||||
for _, row := range markup.Inline {
|
||||
if len(row) == 0 {
|
||||
|
|
@ -317,11 +411,43 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
|
|||
apiRow := make([]map[string]any, 0, len(row))
|
||||
for _, button := range row {
|
||||
item := map[string]any{"text": button.Text}
|
||||
if button.Style != "" {
|
||||
item["style"] = string(button.Style)
|
||||
}
|
||||
if button.IconCustomEmojiID > 0 {
|
||||
item["icon_custom_emoji_id"] = strconv.FormatInt(button.IconCustomEmojiID, 10)
|
||||
}
|
||||
switch button.Type {
|
||||
case domain.MarkupButtonURL:
|
||||
item["url"] = button.URL
|
||||
case domain.MarkupButtonCallback:
|
||||
item["callback_data"] = string(button.Data)
|
||||
case domain.MarkupButtonWebView:
|
||||
item["web_app"] = map[string]any{"url": button.URL}
|
||||
case domain.MarkupButtonSwitchInline:
|
||||
switch {
|
||||
case button.SamePeer:
|
||||
item["switch_inline_query_current_chat"] = button.Query
|
||||
case len(button.PeerTypes) > 0:
|
||||
chosen := map[string]any{"query": button.Query}
|
||||
for _, peerType := range button.PeerTypes {
|
||||
switch peerType {
|
||||
case store.InlineQueryPeerTypePM:
|
||||
chosen["allow_user_chats"] = true
|
||||
case store.InlineQueryPeerTypeBotPM:
|
||||
chosen["allow_bot_chats"] = true
|
||||
case store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup:
|
||||
chosen["allow_group_chats"] = true
|
||||
case store.InlineQueryPeerTypeBroadcast:
|
||||
chosen["allow_channel_chats"] = true
|
||||
}
|
||||
}
|
||||
item["switch_inline_query_chosen_chat"] = chosen
|
||||
default:
|
||||
item["switch_inline_query"] = button.Query
|
||||
}
|
||||
case domain.MarkupButtonCopy:
|
||||
item["copy_text"] = map[string]any{"text": button.CopyText}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
|
@ -337,7 +463,7 @@ func apiReplyMarkup(markup *domain.MessageReplyMarkup) map[string]any {
|
|||
return map[string]any{"inline_keyboard": rows}
|
||||
}
|
||||
|
||||
func apiMessageMedia(media *domain.MessageMedia) map[string]any {
|
||||
func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, channels map[int64]domain.Channel) map[string]any {
|
||||
if media.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -356,11 +482,264 @@ func apiMessageMedia(media *domain.MessageMedia) map[string]any {
|
|||
return nil
|
||||
}
|
||||
return map[string]any{"document": apiDocument(*media.Document)}
|
||||
case domain.MessageMediaKindContact:
|
||||
if media.Contact == nil {
|
||||
return nil
|
||||
}
|
||||
contact := map[string]any{
|
||||
"phone_number": media.Contact.PhoneNumber,
|
||||
"first_name": media.Contact.FirstName,
|
||||
}
|
||||
if media.Contact.LastName != "" {
|
||||
contact["last_name"] = media.Contact.LastName
|
||||
}
|
||||
if media.Contact.Vcard != "" {
|
||||
contact["vcard"] = media.Contact.Vcard
|
||||
}
|
||||
if media.Contact.UserID != 0 {
|
||||
contact["user_id"] = media.Contact.UserID
|
||||
}
|
||||
return map[string]any{"contact": contact}
|
||||
case domain.MessageMediaKindGeo:
|
||||
if media.Geo == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"location": apiLocation(*media.Geo, nil)}
|
||||
case domain.MessageMediaKindVenue:
|
||||
if media.Venue == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"venue": apiVenue(*media.Venue)}
|
||||
case domain.MessageMediaKindGeoLive:
|
||||
if media.GeoLive == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"location": apiLocation(media.GeoLive.Geo, media.GeoLive)}
|
||||
case domain.MessageMediaKindPoll:
|
||||
if media.Poll == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"poll": apiPoll(*media.Poll, users)}
|
||||
case domain.MessageMediaKindService:
|
||||
if media.ServiceAction == nil {
|
||||
return nil
|
||||
}
|
||||
switch media.ServiceAction.Kind {
|
||||
case domain.MessageServiceActionWebViewDataSent:
|
||||
if media.ServiceAction.WebViewData == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"web_app_data": map[string]any{
|
||||
"data": media.ServiceAction.WebViewData.Data, "button_text": media.ServiceAction.WebViewData.ButtonText,
|
||||
}}
|
||||
case domain.MessageServiceActionRequestedPeer:
|
||||
return apiRequestedPeer(media.ServiceAction.RequestedPeer, users, channels)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any {
|
||||
out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long}
|
||||
if geo.AccuracyRadius > 0 {
|
||||
out["horizontal_accuracy"] = float64(geo.AccuracyRadius)
|
||||
}
|
||||
if live != nil {
|
||||
if live.Period > 0 {
|
||||
out["live_period"] = live.Period
|
||||
}
|
||||
if live.Heading > 0 {
|
||||
out["heading"] = live.Heading
|
||||
}
|
||||
if live.ProximityNotificationRadius > 0 {
|
||||
out["proximity_alert_radius"] = live.ProximityNotificationRadius
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiVenue(venue domain.MessageVenue) map[string]any {
|
||||
out := map[string]any{
|
||||
"location": apiLocation(venue.Geo, nil), "title": venue.Title, "address": venue.Address,
|
||||
}
|
||||
switch strings.ToLower(venue.Provider) {
|
||||
case "foursquare":
|
||||
if venue.VenueID != "" {
|
||||
out["foursquare_id"] = venue.VenueID
|
||||
}
|
||||
if venue.VenueType != "" {
|
||||
out["foursquare_type"] = venue.VenueType
|
||||
}
|
||||
case "gplaces", "google":
|
||||
if venue.VenueID != "" {
|
||||
out["google_place_id"] = venue.VenueID
|
||||
}
|
||||
if venue.VenueType != "" {
|
||||
out["google_place_type"] = venue.VenueType
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiPoll(poll domain.MessagePoll, users map[int64]domain.User) map[string]any {
|
||||
resultByOption := make(map[string]domain.MessagePollAnswerVoters)
|
||||
totalVoters := 0
|
||||
if poll.Results != nil {
|
||||
totalVoters = poll.Results.TotalVoters
|
||||
for _, result := range poll.Results.Voters {
|
||||
resultByOption[string(result.Option)] = result
|
||||
}
|
||||
}
|
||||
options := make([]map[string]any, 0, len(poll.Answers))
|
||||
correct := make([]int, 0, len(poll.Answers))
|
||||
for index, answer := range poll.Answers {
|
||||
persistentID := base64.RawURLEncoding.EncodeToString(answer.Option)
|
||||
if persistentID == "" {
|
||||
persistentID = strconv.Itoa(index)
|
||||
}
|
||||
result := resultByOption[string(answer.Option)]
|
||||
option := map[string]any{
|
||||
"persistent_id": persistentID, "text": answer.Text, "voter_count": result.Voters,
|
||||
}
|
||||
if entities := apiMessageEntities(answer.Entities, users); len(entities) > 0 {
|
||||
option["text_entities"] = entities
|
||||
}
|
||||
if answer.Media != nil {
|
||||
if projected := apiPollMedia(answer.Media); len(projected) > 0 {
|
||||
option["media"] = projected
|
||||
}
|
||||
}
|
||||
if result.Correct {
|
||||
correct = append(correct, index)
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
pollType := "regular"
|
||||
if poll.Quiz {
|
||||
pollType = "quiz"
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": strconv.FormatInt(poll.ID, 10), "question": poll.Question,
|
||||
"options": options, "total_voter_count": totalVoters, "is_closed": poll.Closed,
|
||||
"is_anonymous": !poll.PublicVoters, "type": pollType,
|
||||
"allows_multiple_answers": poll.MultipleChoice, "allows_revoting": !poll.RevotingDisabled,
|
||||
}
|
||||
if entities := apiMessageEntities(poll.QuestionEntities, users); len(entities) > 0 {
|
||||
out["question_entities"] = entities
|
||||
}
|
||||
if len(correct) > 0 {
|
||||
out["correct_option_ids"] = correct
|
||||
}
|
||||
if poll.Results != nil && poll.Results.Solution != "" {
|
||||
out["explanation"] = poll.Results.Solution
|
||||
if entities := apiMessageEntities(poll.Results.SolutionEntities, users); len(entities) > 0 {
|
||||
out["explanation_entities"] = entities
|
||||
}
|
||||
}
|
||||
if poll.ClosePeriod > 0 {
|
||||
out["open_period"] = poll.ClosePeriod
|
||||
}
|
||||
if poll.CloseDate > 0 {
|
||||
out["close_date"] = poll.CloseDate
|
||||
}
|
||||
if poll.AttachedMedia != nil {
|
||||
if projected := apiPollMedia(poll.AttachedMedia); len(projected) > 0 {
|
||||
out["media"] = projected
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func apiPollMedia(media *domain.MessageMedia) map[string]any {
|
||||
if media.IsZero() {
|
||||
return nil
|
||||
}
|
||||
switch media.Kind {
|
||||
case domain.MessageMediaKindPhoto:
|
||||
if media.Photo != nil {
|
||||
if sizes := apiPhotoSizes(*media.Photo); len(sizes) > 0 {
|
||||
return map[string]any{"photo": sizes}
|
||||
}
|
||||
}
|
||||
case domain.MessageMediaKindDocument:
|
||||
if media.Document != nil {
|
||||
return map[string]any{"document": apiDocument(*media.Document)}
|
||||
}
|
||||
case domain.MessageMediaKindGeo:
|
||||
if media.Geo != nil {
|
||||
return map[string]any{"location": apiLocation(*media.Geo, nil)}
|
||||
}
|
||||
case domain.MessageMediaKindVenue:
|
||||
if media.Venue != nil {
|
||||
return map[string]any{"venue": apiVenue(*media.Venue)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiRequestedPeer(action *domain.MessageRequestedPeerAction, _ map[int64]domain.User, _ map[int64]domain.Channel) map[string]any {
|
||||
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
allUsers := true
|
||||
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
details[detail.Peer] = detail
|
||||
}
|
||||
for _, peer := range action.Peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return nil
|
||||
}
|
||||
allUsers = allUsers && peer.Type == domain.PeerTypeUser
|
||||
}
|
||||
if allUsers {
|
||||
shared := make([]map[string]any, 0, len(action.Peers))
|
||||
for _, peer := range action.Peers {
|
||||
item := map[string]any{"user_id": peer.ID}
|
||||
detail := details[peer]
|
||||
if action.NameRequested {
|
||||
if detail.FirstName != "" {
|
||||
item["first_name"] = detail.FirstName
|
||||
}
|
||||
if detail.LastName != "" {
|
||||
item["last_name"] = detail.LastName
|
||||
}
|
||||
}
|
||||
if action.UsernameRequested && detail.Username != "" {
|
||||
item["username"] = detail.Username
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
|
||||
item["photo"] = photo
|
||||
}
|
||||
}
|
||||
shared = append(shared, item)
|
||||
}
|
||||
return map[string]any{"users_shared": map[string]any{"request_id": action.ButtonID, "users": shared}}
|
||||
}
|
||||
if len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
peer := action.Peers[0]
|
||||
shared := map[string]any{"request_id": action.ButtonID, "chat_id": -1000000000000 - peer.ID}
|
||||
detail := details[peer]
|
||||
if action.NameRequested && detail.Title != "" {
|
||||
shared["title"] = detail.Title
|
||||
}
|
||||
if action.UsernameRequested && detail.Username != "" {
|
||||
shared["username"] = detail.Username
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
if photo := apiPhotoSizes(*detail.Photo); len(photo) > 0 {
|
||||
shared["photo"] = photo
|
||||
}
|
||||
}
|
||||
return map[string]any{"chat_shared": shared}
|
||||
}
|
||||
|
||||
func apiPhotoSizes(photo domain.Photo) []map[string]any {
|
||||
return apiPhotoSizesWithPrefix(photo.Sizes, "photo:"+strconv.FormatInt(photo.ID, 10)+":")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import (
|
|||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -41,6 +43,7 @@ type GatewayService interface {
|
|||
BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, locationKey, remoteURL, fileName, mimeType string, fileBytes []byte, caption string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, silent bool, replyToMessageID int) (domain.Message, error)
|
||||
BotAPIEditMessageText(ctx context.Context, botID, chatID int64, messageID int, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (domain.Message, error)
|
||||
BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error)
|
||||
BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error)
|
||||
BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, callbackQueryID, text, url string, showAlert bool, cacheTime int) (bool, error)
|
||||
BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error)
|
||||
|
|
@ -51,6 +54,29 @@ type GatewayUpdateWaiter interface {
|
|||
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool
|
||||
}
|
||||
|
||||
type GatewayUpdateControl interface {
|
||||
BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error
|
||||
BotAPIDropPendingUpdates(ctx context.Context, botID int64) error
|
||||
BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error)
|
||||
}
|
||||
|
||||
type GatewayPollLease interface {
|
||||
AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
|
||||
ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error
|
||||
}
|
||||
|
||||
type GatewayWebhookControl interface {
|
||||
BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error
|
||||
BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error
|
||||
BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error)
|
||||
ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error)
|
||||
AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error)
|
||||
ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error
|
||||
RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error
|
||||
RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error
|
||||
ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, gateway GatewayService, logger *zap.Logger) (*http.Server, error) {
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return nil, nil
|
||||
|
|
@ -58,7 +84,7 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
|
|||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger}
|
||||
handler := &handler{bots: bots, users: users, webapps: webapps, gateway: gateway, logger: logger, webhookClient: newWebhookHTTPClient()}
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler.routes(),
|
||||
|
|
@ -76,6 +102,9 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
|
|||
logger.Warn("Bot API 网关退出", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
if webhooks, ok := gateway.(GatewayWebhookControl); ok {
|
||||
go runWebhookDispatcher(ctx, webhooks, gateway, handler.webhookClient, logger.Named("webhook"))
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
|
|
@ -86,11 +115,37 @@ func Start(ctx context.Context, addr string, bots BotsService, users UsersServic
|
|||
}
|
||||
|
||||
type handler struct {
|
||||
bots BotsService
|
||||
users UsersService
|
||||
webapps WebAppService
|
||||
gateway GatewayService
|
||||
logger *zap.Logger
|
||||
bots BotsService
|
||||
users UsersService
|
||||
webapps WebAppService
|
||||
gateway GatewayService
|
||||
logger *zap.Logger
|
||||
polls botAPIPollRegistry
|
||||
webhookClient *http.Client
|
||||
}
|
||||
|
||||
type botAPIPollRegistry struct {
|
||||
mu sync.Mutex
|
||||
active map[int64]struct{}
|
||||
}
|
||||
|
||||
func (p *botAPIPollRegistry) acquire(botID int64) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.active == nil {
|
||||
p.active = make(map[int64]struct{})
|
||||
}
|
||||
if _, exists := p.active[botID]; exists {
|
||||
return false
|
||||
}
|
||||
p.active[botID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *botAPIPollRegistry) release(botID int64) {
|
||||
p.mu.Lock()
|
||||
delete(p.active, botID)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -148,11 +203,11 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) {
|
|||
case "getfile":
|
||||
h.getFile(w, r, botID)
|
||||
case "deletewebhook":
|
||||
writeAPIOK(w, true)
|
||||
h.deleteWebhook(w, r, botID)
|
||||
case "getwebhookinfo":
|
||||
writeAPIOK(w, map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": 0})
|
||||
h.getWebhookInfo(w, r, botID)
|
||||
case "setwebhook":
|
||||
h.setWebhook(w, r)
|
||||
h.setWebhook(w, r, botID)
|
||||
case "setchatmenubutton":
|
||||
h.setChatMenuButton(w, r, botID)
|
||||
case "getchatmenubutton":
|
||||
|
|
@ -216,7 +271,14 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
|
|||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.ParseInt(strings.TrimSpace(values["offset"]), 10, 64)
|
||||
var offset int64
|
||||
if raw := strings.TrimSpace(values["offset"]); raw != "" {
|
||||
offset, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || offset < -10000 {
|
||||
writeAPIError(w, http.StatusBadRequest, "OFFSET_INVALID")
|
||||
return
|
||||
}
|
||||
}
|
||||
limit := apiInt(values["limit"], 100)
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
|
|
@ -231,7 +293,56 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
|
|||
if timeoutSeconds > 50 {
|
||||
timeoutSeconds = 50
|
||||
}
|
||||
allowed := allowedUpdates(values["allowed_updates"])
|
||||
if !h.polls.acquire(botID) {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
|
||||
return
|
||||
}
|
||||
defer h.polls.release(botID)
|
||||
if leases, ok := h.gateway.(GatewayPollLease); ok {
|
||||
owner := randomBotAPIOwner()
|
||||
leaseTTL := time.Duration(timeoutSeconds)*time.Second + 30*time.Second
|
||||
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, leaseTTL)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner); err != nil {
|
||||
h.logger.Warn("release bot api poll lease", zap.Int64("bot_user_id", botID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
if webhooks, ok := h.gateway.(GatewayWebhookControl); ok {
|
||||
if _, configured, err := webhooks.BotAPIWebhook(r.Context(), botID); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
} else if configured {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: can't use getUpdates method while webhook is active")
|
||||
return
|
||||
}
|
||||
}
|
||||
if raw, present := values["allowed_updates"]; present {
|
||||
allowed, err := parseAllowedUpdates(raw)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
control, ok := h.gateway.(GatewayUpdateControl)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotImplemented, "ALLOWED_UPDATES_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
if err := control.BotAPISetAllowedUpdates(r.Context(), botID, allowed); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
|
||||
for {
|
||||
version := botAPIUpdateWaitVersion(h.gateway, botID)
|
||||
|
|
@ -240,7 +351,7 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
|
|||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
updates := apiUpdates(events, allowed, limit)
|
||||
updates := apiUpdates(events, limit)
|
||||
if len(updates) > 0 || timeoutSeconds == 0 || time.Now().After(deadline) {
|
||||
writeAPIOK(w, updates)
|
||||
return
|
||||
|
|
@ -249,6 +360,84 @@ func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64
|
|||
}
|
||||
}
|
||||
|
||||
func randomBotAPIOwner() string {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err == nil {
|
||||
return fmt.Sprintf("%x", raw[:])
|
||||
}
|
||||
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func (h *handler) deleteWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
values, err := requestValues(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
control, ok := h.gateway.(GatewayWebhookControl)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
leaseOwner := randomBotAPIOwner()
|
||||
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
} else if found {
|
||||
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, leaseOwner, 30*time.Second)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
|
||||
return
|
||||
}
|
||||
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, leaseOwner) }()
|
||||
}
|
||||
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func (h *handler) getWebhookInfo(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
pending := 0
|
||||
if control, ok := h.gateway.(GatewayUpdateControl); ok {
|
||||
var err error
|
||||
pending, err = control.BotAPIPendingUpdateCount(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
}
|
||||
result := map[string]any{"url": "", "has_custom_certificate": false, "pending_update_count": pending}
|
||||
if control, ok := h.gateway.(GatewayWebhookControl); ok {
|
||||
config, found, err := control.BotAPIWebhook(r.Context(), botID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if found {
|
||||
result["url"] = config.URL
|
||||
result["max_connections"] = config.MaxConnections
|
||||
if config.AllowedUpdates != nil {
|
||||
allowed := make([]string, 0, len(config.AllowedUpdates))
|
||||
for _, kind := range config.AllowedUpdates {
|
||||
allowed = append(allowed, string(kind))
|
||||
}
|
||||
result["allowed_updates"] = allowed
|
||||
}
|
||||
if config.LastErrorDate > 0 {
|
||||
result["last_error_date"] = config.LastErrorDate
|
||||
result["last_error_message"] = config.LastErrorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
writeAPIOK(w, result)
|
||||
}
|
||||
|
||||
func botAPIUpdateWaitVersion(gateway GatewayService, botID int64) uint64 {
|
||||
waiter, ok := gateway.(GatewayUpdateWaiter)
|
||||
if !ok {
|
||||
|
|
@ -381,14 +570,22 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
|||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
messageID := apiInt(values["message_id"], 0)
|
||||
if messageID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
|
||||
rawInlineID := strings.TrimSpace(values["inline_message_id"])
|
||||
var chatID int64
|
||||
messageID := 0
|
||||
if rawInlineID == "" {
|
||||
chatID, err = strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64)
|
||||
if err != nil || chatID == 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID")
|
||||
return
|
||||
}
|
||||
messageID = apiInt(values["message_id"], 0)
|
||||
if messageID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_ID_INVALID")
|
||||
return
|
||||
}
|
||||
} else if strings.TrimSpace(values["chat_id"]) != "" || strings.TrimSpace(values["message_id"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "MESSAGE_IDENTIFIER_INVALID")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["parse_mode"]) != "" {
|
||||
|
|
@ -403,12 +600,26 @@ func (h *handler) editMessageText(w http.ResponseWriter, r *http.Request, botID
|
|||
var markup *domain.MessageReplyMarkup
|
||||
_, setReplyMarkup := values["reply_markup"]
|
||||
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
|
||||
markup, err = replyMarkupFromAPI(json.RawMessage(raw))
|
||||
markup, err = inlineReplyMarkupFromAPI(json.RawMessage(raw))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if rawInlineID != "" {
|
||||
inlineID, err := decodeBotAPIInlineMessageID(rawInlineID)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
ok, err := h.gateway.BotAPIEditInlineMessageText(r.Context(), botID, inlineID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, ok)
|
||||
return
|
||||
}
|
||||
msg, err := h.gateway.BotAPIEditMessageText(r.Context(), botID, chatID, messageID, values["text"], entities, setReplyMarkup, markup, apiBool(values["disable_web_page_preview"]))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
|
||||
|
|
@ -557,17 +768,136 @@ func (h *handler) downloadFile(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := requestValues(r)
|
||||
func (h *handler) setWebhook(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||
values, files, err := requestValuesWithFiles(r)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["url"]) == "" {
|
||||
control, ok := h.gateway.(GatewayWebhookControl)
|
||||
if !ok {
|
||||
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
rawURL := strings.TrimSpace(values["url"])
|
||||
if rawURL == "" {
|
||||
if err := control.BotAPIDeleteWebhook(r.Context(), botID, apiBool(values["drop_pending_updates"])); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusNotImplemented, "WEBHOOK_NOT_IMPLEMENTED")
|
||||
if err := validateWebhookURL(rawURL); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["certificate"]) != "" || len(files) != 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "CERTIFICATE_PINNING_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(values["ip_address"]) != "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "IP_ADDRESS_UNSUPPORTED")
|
||||
return
|
||||
}
|
||||
secret := strings.TrimSpace(values["secret_token"])
|
||||
if !validWebhookSecret(secret) {
|
||||
writeAPIError(w, http.StatusBadRequest, "SECRET_TOKEN_INVALID")
|
||||
return
|
||||
}
|
||||
maxConnections := apiInt(values["max_connections"], 40)
|
||||
if maxConnections < 1 || maxConnections > 100 {
|
||||
writeAPIError(w, http.StatusBadRequest, "MAX_CONNECTIONS_INVALID")
|
||||
return
|
||||
}
|
||||
var allowed []domain.BotAPIUpdateKind
|
||||
_, allowedUpdatesSet := values["allowed_updates"]
|
||||
if raw, present := values["allowed_updates"]; present {
|
||||
allowed, err = parseAllowedUpdates(raw)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
allowed = nil
|
||||
}
|
||||
}
|
||||
if !h.polls.acquire(botID) {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
|
||||
return
|
||||
}
|
||||
defer h.polls.release(botID)
|
||||
if leases, ok := h.gateway.(GatewayPollLease); ok {
|
||||
owner := randomBotAPIOwner()
|
||||
acquired, err := leases.AcquireBotAPIPollLease(r.Context(), botID, owner, 30*time.Second)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: another getUpdates request is active")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = leases.ReleaseBotAPIPollLease(releaseCtx, botID, owner)
|
||||
}()
|
||||
}
|
||||
webhookOwner := randomBotAPIOwner()
|
||||
if _, found, err := control.BotAPIWebhook(r.Context(), botID); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
} else if found {
|
||||
acquired, err := control.AcquireBotAPIWebhookLease(r.Context(), botID, webhookOwner, 30*time.Second)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
writeAPIError(w, http.StatusConflict, "CONFLICT: webhook delivery is active")
|
||||
return
|
||||
}
|
||||
defer func() { _ = control.ReleaseBotAPIWebhookLease(context.Background(), botID, webhookOwner) }()
|
||||
}
|
||||
if err := control.BotAPISetWebhook(r.Context(), domain.BotAPIWebhook{
|
||||
BotUserID: botID, URL: rawURL, SecretToken: secret,
|
||||
MaxConnections: maxConnections, AllowedUpdates: allowed, AllowedUpdatesSet: allowedUpdatesSet,
|
||||
}, apiBool(values["drop_pending_updates"])); err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR")
|
||||
return
|
||||
}
|
||||
writeAPIOK(w, true)
|
||||
}
|
||||
|
||||
func validateWebhookURL(raw string) error {
|
||||
if len(raw) > 2048 {
|
||||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
u, err := neturl.ParseRequestURI(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||
return errors.New("WEBHOOK_URL_INVALID")
|
||||
}
|
||||
if port := u.Port(); port != "" && port != "443" && port != "80" && port != "88" && port != "8443" {
|
||||
return errors.New("WEBHOOK_PORT_NOT_ALLOWED")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validWebhookSecret(secret string) bool {
|
||||
if secret == "" {
|
||||
return true
|
||||
}
|
||||
if len(secret) > 256 {
|
||||
return false
|
||||
}
|
||||
for _, r := range secret {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
|
|
@ -328,6 +329,312 @@ func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendMessageParsesAndProjectsReplyKeyboard(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
markup := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}, {Type: domain.MarkupButtonText, Text: "Status"}}},
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose an action",
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
self: domain.User{ID: 1001, FirstName: "Echo", Username: "echo_bot", Bot: true},
|
||||
sendMessage: domain.Message{
|
||||
ID: 10, OwnerUserID: 1001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001},
|
||||
Date: 1700000003, Body: "pick", Out: true, ReplyMarkup: markup,
|
||||
},
|
||||
}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{
|
||||
"chat_id":2001,
|
||||
"text":"pick",
|
||||
"reply_markup":{
|
||||
"keyboard":[["Help",{"text":"Status"}]],
|
||||
"resize_keyboard":true,
|
||||
"one_time_keyboard":true,
|
||||
"is_persistent":true,
|
||||
"input_field_placeholder":"Choose an action"
|
||||
}
|
||||
}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if gateway.sendMarkup == nil || gateway.sendMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
len(gateway.sendMarkup.Keyboard) != 1 || len(gateway.sendMarkup.Keyboard[0]) != 2 ||
|
||||
gateway.sendMarkup.Keyboard[0][0].Text != "Help" || !gateway.sendMarkup.Resize ||
|
||||
!gateway.sendMarkup.SingleUse || !gateway.sendMarkup.Persistent || gateway.sendMarkup.Placeholder != "Choose an action" {
|
||||
t.Fatalf("gateway reply keyboard = %#v", gateway.sendMarkup)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ReplyMarkup json.RawMessage `json:"reply_markup"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
// Bot API Message.reply_markup only contains InlineKeyboardMarkup; reply keyboards are
|
||||
// accepted send parameters but are deliberately absent from the returned Message object.
|
||||
if !resp.OK || len(resp.Result.ReplyMarkup) != 0 {
|
||||
t.Fatalf("reply keyboard response = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdatesProjectsCallbackQuery(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 123456, BotUserID: 1001, UserID: 2001,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, MessageID: 9,
|
||||
ChatInstance: 9988, Data: []byte("confirm"),
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
|
||||
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 77, Date: 1700000004,
|
||||
Peer: callback.Peer, BotCallbackQuery: callback,
|
||||
Message: domain.Message{
|
||||
ID: 9, OwnerUserID: 1001, Peer: callback.Peer,
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, Date: 1700000003,
|
||||
Body: "tap", Out: true,
|
||||
},
|
||||
Users: []domain.User{{ID: 1001, FirstName: "Echo", Bot: true}, {ID: 2001, FirstName: "Alice"}},
|
||||
}}}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{"allowed_updates":["callback_query"]}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
CallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
Data string `json:"data"`
|
||||
ChatInstance string `json:"chat_instance"`
|
||||
From struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"from"`
|
||||
Message struct {
|
||||
MessageID int `json:"message_id"`
|
||||
} `json:"message"`
|
||||
} `json:"callback_query"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK || len(resp.Result) != 1 || resp.Result[0].UpdateID != 77 ||
|
||||
resp.Result[0].CallbackQuery.ID != "123456" || resp.Result[0].CallbackQuery.Data != "confirm" ||
|
||||
resp.Result[0].CallbackQuery.ChatInstance != "9988" || resp.Result[0].CallbackQuery.From.ID != 2001 ||
|
||||
resp.Result[0].CallbackQuery.Message.MessageID != 9 {
|
||||
t.Fatalf("callback update response = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineCallbackProjectsOpaqueIDAndEditMessageTextUsesIt(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 998877}
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 123456, BotUserID: 1001, UserID: 2001,
|
||||
ChatInstance: 9988, Data: []byte("inline"), InlineMessage: inline,
|
||||
}
|
||||
gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{
|
||||
UserID: 1001, Type: domain.UpdateEventBotCallbackQuery, Pts: 78, Date: 1700000004,
|
||||
BotCallbackQuery: callback, Users: []domain.User{{ID: 2001, FirstName: "Alice"}},
|
||||
}}}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("getUpdates status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Result []struct {
|
||||
CallbackQuery struct {
|
||||
InlineMessageID string `json:"inline_message_id"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
} `json:"callback_query"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || len(response.Result) != 1 {
|
||||
t.Fatalf("callback response=%s err=%v", rec.Body.String(), err)
|
||||
}
|
||||
inlineToken := response.Result[0].CallbackQuery.InlineMessageID
|
||||
decoded, err := decodeBotAPIInlineMessageID(inlineToken)
|
||||
if err != nil || decoded != *inline || len(response.Result[0].CallbackQuery.Message) != 0 {
|
||||
t.Fatalf("inline token=%q decoded=%#v message=%s err=%v", inlineToken, decoded, response.Result[0].CallbackQuery.Message, err)
|
||||
}
|
||||
edit := performBotAPIRequest(t, h, bots.profile, "editMessageText", `{"inline_message_id":"`+inlineToken+`","text":"updated"}`)
|
||||
if edit.Code != http.StatusOK || !gateway.editInlineCalled || gateway.editInlineID != *inline {
|
||||
t.Fatalf("edit status=%d body=%s called=%v id=%#v", edit.Code, edit.Body.String(), gateway.editInlineCalled, gateway.editInlineID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyMarkupFromAPIReplyKeyboardVariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
kind domain.MessageReplyMarkupType
|
||||
err string
|
||||
}{
|
||||
{name: "remove", raw: `{"remove_keyboard":true,"selective":true}`, kind: domain.MessageReplyMarkupHide},
|
||||
{name: "force", raw: `{"force_reply":true,"input_field_placeholder":"Answer"}`, kind: domain.MessageReplyMarkupForceReply},
|
||||
{name: "contact", raw: `{"keyboard":[[{"text":"Phone","request_contact":true}]]}`, kind: domain.MessageReplyMarkupKeyboard},
|
||||
{name: "filtered users", raw: `{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2,"request_name":true}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
|
||||
{name: "filtered chat", raw: `{"keyboard":[[{"text":"Forum","request_chat":{"request_id":8,"chat_is_channel":false,"chat_is_forum":true,"chat_has_username":false,"chat_is_created":true,"bot_is_member":true,"user_administrator_rights":{"can_manage_chat":true,"can_delete_messages":true},"bot_administrator_rights":{"can_manage_chat":true}}}]]}`, kind: domain.MessageReplyMarkupKeyboard},
|
||||
{name: "unsupported legacy user request", raw: `{"keyboard":[[{"text":"User","request_user":{"request_id":1}}]]}`, err: "BUTTON_TYPE_INVALID"},
|
||||
{name: "multiple constructors", raw: `{"keyboard":[["A"]],"inline_keyboard":[[{"text":"B","callback_data":"b"}]]}`, err: "BUTTON_INVALID"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
markup, err := replyMarkupFromAPI(json.RawMessage(tt.raw))
|
||||
if tt.err != "" {
|
||||
if err == nil || err.Error() != tt.err {
|
||||
t.Fatalf("error = %v, want %s", err, tt.err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || markup == nil || markup.Kind() != tt.kind {
|
||||
t.Fatalf("markup = %#v err=%v, want kind %s", markup, err, tt.kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := inlineReplyMarkupFromAPI(json.RawMessage(`{"keyboard":[["A"]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
|
||||
t.Fatalf("inline-only parser error = %v, want BUTTON_INVALID", err)
|
||||
}
|
||||
if _, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Bad","url":"https://example.com","callback_data":"x"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
|
||||
t.Fatalf("multi-constructor inline button error = %v, want BUTTON_INVALID", err)
|
||||
}
|
||||
filtered, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Premium","request_users":{"request_id":7,"user_is_bot":false,"user_is_premium":true,"max_quantity":2}}]]}`))
|
||||
if err != nil || filtered == nil {
|
||||
t.Fatalf("filtered users markup=%#v err=%v", filtered, err)
|
||||
}
|
||||
filter := filtered.Keyboard[0][0].RequestPeerFilter
|
||||
if filter == nil || !filter.UserIsBotSet || filter.UserIsBot || !filter.UserIsPremiumSet || !filter.UserIsPremium {
|
||||
t.Fatalf("filtered users = %#v", filter)
|
||||
}
|
||||
webApp, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"App","web_app":{"url":"https://example.com"}}]]}`))
|
||||
if err != nil || webApp == nil || webApp.Inline[0][0].Type != domain.MarkupButtonWebView {
|
||||
t.Fatalf("web_app inline button = %#v err=%v", webApp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyMarkupFromAPIPreservesSemanticButtonStyles(t *testing.T) {
|
||||
reply, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Run","style":"primary","icon_custom_emoji_id":"123"}]]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("reply markup: %v", err)
|
||||
}
|
||||
button := reply.Keyboard[0][0]
|
||||
if button.Style != domain.MarkupButtonStylePrimary || button.IconCustomEmojiID != 123 {
|
||||
t.Fatalf("reply button = %#v", button)
|
||||
}
|
||||
inline, err := replyMarkupFromAPI(json.RawMessage(`{"inline_keyboard":[[{"text":"Delete","callback_data":"delete","style":"danger","icon_custom_emoji_id":"456"}]]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("inline markup: %v", err)
|
||||
}
|
||||
button = inline.Inline[0][0]
|
||||
if button.Style != domain.MarkupButtonStyleDanger || button.IconCustomEmojiID != 456 {
|
||||
t.Fatalf("inline button = %#v", button)
|
||||
}
|
||||
projected := apiReplyMarkup(inline)
|
||||
rows := projected["inline_keyboard"].([][]map[string]any)
|
||||
if rows[0][0]["style"] != "danger" || rows[0][0]["icon_custom_emoji_id"] != "456" {
|
||||
t.Fatalf("projected inline button = %#v", rows[0][0])
|
||||
}
|
||||
if _, err := replyMarkupFromAPI(json.RawMessage(`{"keyboard":[[{"text":"Bad","style":"rainbow"}]]}`)); err == nil || err.Error() != "BUTTON_INVALID" {
|
||||
t.Fatalf("invalid style error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWebhookDropsPendingAndWebhookInfoReportsCount(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{pendingCount: 7}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
|
||||
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"pending_update_count":7`) {
|
||||
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
|
||||
}
|
||||
drop := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{"drop_pending_updates":true}`)
|
||||
if drop.Code != http.StatusOK || !gateway.dropPending {
|
||||
t.Fatalf("deleteWebhook status=%d body=%s drop=%v", drop.Code, drop.Body.String(), gateway.dropPending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWebhookPersistsConfigReportsInfoAndConflictsWithPolling(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{pendingCount: 3}
|
||||
h := (&handler{bots: bots, gateway: gateway}).routes()
|
||||
set := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{
|
||||
"url":"https://bot.example.test/hook",
|
||||
"secret_token":"safe_secret-1",
|
||||
"max_connections":12,
|
||||
"allowed_updates":["message","callback_query"],
|
||||
"drop_pending_updates":true
|
||||
}`)
|
||||
if set.Code != http.StatusOK || !gateway.webhookFound || gateway.webhook.URL != "https://bot.example.test/hook" ||
|
||||
gateway.webhook.SecretToken != "safe_secret-1" || gateway.webhook.MaxConnections != 12 ||
|
||||
len(gateway.webhook.AllowedUpdates) != 2 || !gateway.webhook.AllowedUpdatesSet || !gateway.webhookDrop {
|
||||
t.Fatalf("setWebhook status=%d body=%s config=%#v", set.Code, set.Body.String(), gateway.webhook)
|
||||
}
|
||||
info := performBotAPIRequest(t, h, bots.profile, "getWebhookInfo", `{}`)
|
||||
if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"url":"https://bot.example.test/hook"`) ||
|
||||
!strings.Contains(info.Body.String(), `"max_connections":12`) || !strings.Contains(info.Body.String(), `"pending_update_count":3`) {
|
||||
t.Fatalf("getWebhookInfo status=%d body=%s", info.Code, info.Body.String())
|
||||
}
|
||||
reconfigure := performBotAPIRequest(t, h, bots.profile, "setWebhook", `{"url":"https://bot.example.test/new"}`)
|
||||
if reconfigure.Code != http.StatusOK || gateway.webhook.AllowedUpdatesSet {
|
||||
t.Fatalf("omitted allowed_updates status=%d body=%s config=%#v", reconfigure.Code, reconfigure.Body.String(), gateway.webhook)
|
||||
}
|
||||
poll := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`)
|
||||
if poll.Code != http.StatusConflict || !strings.Contains(poll.Body.String(), "webhook is active") {
|
||||
t.Fatalf("getUpdates status=%d body=%s", poll.Code, poll.Body.String())
|
||||
}
|
||||
del := performBotAPIRequest(t, h, bots.profile, "deleteWebhook", `{}`)
|
||||
if del.Code != http.StatusOK || !gateway.webhookDeleted || gateway.webhookFound {
|
||||
t.Fatalf("deleteWebhook status=%d body=%s deleted=%v", del.Code, del.Body.String(), gateway.webhookDeleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWebhookRejectsUnsafeParameters(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
h := (&handler{bots: bots, gateway: &fakeBotAPIGateway{}}).routes()
|
||||
tests := []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{`{"url":"http://example.test/hook"}`, "WEBHOOK_URL_INVALID"},
|
||||
{`{"url":"https://example.test:444/hook"}`, "WEBHOOK_PORT_NOT_ALLOWED"},
|
||||
{`{"url":"https://example.test/hook","secret_token":"bad secret"}`, "SECRET_TOKEN_INVALID"},
|
||||
{`{"url":"https://example.test/hook","max_connections":101}`, "MAX_CONNECTIONS_INVALID"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
rec := performBotAPIRequest(t, h, bots.profile, "setWebhook", tt.body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), tt.want) {
|
||||
t.Fatalf("setWebhook body=%s status=%d response=%s want=%s", tt.body, rec.Code, rec.Body.String(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollRegistryRejectsConcurrentPoller(t *testing.T) {
|
||||
var polls botAPIPollRegistry
|
||||
if !polls.acquire(1001) {
|
||||
t.Fatal("first poller was rejected")
|
||||
}
|
||||
if polls.acquire(1001) {
|
||||
t.Fatal("second poller for same bot was accepted")
|
||||
}
|
||||
if !polls.acquire(1002) {
|
||||
t.Fatal("different bot poller was rejected")
|
||||
}
|
||||
polls.release(1001)
|
||||
if !polls.acquire(1001) {
|
||||
t.Fatal("poller remained locked after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDocumentMultipartParsesFileAndCaption(t *testing.T) {
|
||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||
gateway := &fakeBotAPIGateway{
|
||||
|
|
@ -554,6 +861,108 @@ func TestAPIUpdateProjectsCaptionlessMediaMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAPIMessageProjectsReplyKeyboardResponses(t *testing.T) {
|
||||
base := domain.Message{
|
||||
ID: 10, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000010,
|
||||
}
|
||||
t.Run("contact", func(t *testing.T) {
|
||||
msg := base
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{
|
||||
PhoneNumber: "+12025550123", FirstName: "Alice", LastName: "Example", Vcard: "VCARD", UserID: 2001,
|
||||
}}
|
||||
contact := apiMessage(msg, nil)["contact"].(map[string]any)
|
||||
if contact["phone_number"] != "+12025550123" || contact["user_id"] != int64(2001) {
|
||||
t.Fatalf("contact=%#v", contact)
|
||||
}
|
||||
})
|
||||
t.Run("locations", func(t *testing.T) {
|
||||
msg := base
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{Lat: 1.5, Long: 2.5, AccuracyRadius: 7}}
|
||||
location := apiMessage(msg, nil)["location"].(map[string]any)
|
||||
if location["latitude"] != 1.5 || location["horizontal_accuracy"] != float64(7) {
|
||||
t.Fatalf("location=%#v", location)
|
||||
}
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{
|
||||
Geo: domain.MessageGeoPoint{Lat: 3.5, Long: 4.5}, Period: 60, Heading: 90, ProximityNotificationRadius: 25,
|
||||
}}
|
||||
location = apiMessage(msg, nil)["location"].(map[string]any)
|
||||
if location["live_period"] != 60 || location["heading"] != 90 || location["proximity_alert_radius"] != 25 {
|
||||
t.Fatalf("live location=%#v", location)
|
||||
}
|
||||
})
|
||||
t.Run("venue", func(t *testing.T) {
|
||||
msg := base
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{
|
||||
Geo: domain.MessageGeoPoint{Lat: 1, Long: 2}, Title: "Cafe", Address: "Main St",
|
||||
Provider: "foursquare", VenueID: "place-1", VenueType: "food/cafe",
|
||||
}}
|
||||
venue := apiMessage(msg, nil)["venue"].(map[string]any)
|
||||
if venue["title"] != "Cafe" || venue["foursquare_id"] != "place-1" {
|
||||
t.Fatalf("venue=%#v", venue)
|
||||
}
|
||||
})
|
||||
t.Run("poll", func(t *testing.T) {
|
||||
msg := base
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{
|
||||
ID: 77, Question: "Pick", Quiz: true, RevotingDisabled: true,
|
||||
Answers: []domain.MessagePollAnswer{{Text: "A", Option: []byte{1}}, {Text: "B", Option: []byte{2}}},
|
||||
Results: &domain.MessagePollResults{TotalVoters: 3, Voters: []domain.MessagePollAnswerVoters{
|
||||
{Option: []byte{1}, Voters: 1}, {Option: []byte{2}, Voters: 2, Correct: true},
|
||||
}, Solution: "Because B"},
|
||||
}}
|
||||
poll := apiMessage(msg, nil)["poll"].(map[string]any)
|
||||
options := poll["options"].([]map[string]any)
|
||||
correct := poll["correct_option_ids"].([]int)
|
||||
if poll["id"] != "77" || poll["type"] != "quiz" || poll["allows_revoting"] != false ||
|
||||
len(options) != 2 || options[1]["voter_count"] != 2 || len(correct) != 1 || correct[0] != 1 {
|
||||
t.Fatalf("poll=%#v", poll)
|
||||
}
|
||||
})
|
||||
t.Run("web_app_data", func(t *testing.T) {
|
||||
msg := base
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent,
|
||||
WebViewData: &domain.MessageWebViewDataAction{ButtonText: "Open", Data: `{"ok":true}`},
|
||||
}}
|
||||
data := apiMessage(msg, nil)["web_app_data"].(map[string]any)
|
||||
if data["button_text"] != "Open" || data["data"] != `{"ok":true}` {
|
||||
t.Fatalf("web_app_data=%#v", data)
|
||||
}
|
||||
})
|
||||
t.Run("shared_peers", func(t *testing.T) {
|
||||
msg := base
|
||||
sharedPhoto := domain.Photo{ID: 9001, Sizes: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
|
||||
}}}
|
||||
msg.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 42, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 3001}},
|
||||
Details: []domain.MessageRequestedPeerDetails{{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 3001}, FirstName: "Shared", Username: "shared_user", Photo: &sharedPhoto,
|
||||
}},
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
},
|
||||
}}
|
||||
projected := apiMessage(msg, nil)
|
||||
usersShared := projected["users_shared"].(map[string]any)
|
||||
sharedUsers := usersShared["users"].([]map[string]any)
|
||||
if usersShared["request_id"] != 42 || sharedUsers[0]["user_id"] != int64(3001) || sharedUsers[0]["username"] != "shared_user" || len(sharedUsers[0]["photo"].([]map[string]any)) != 1 {
|
||||
t.Fatalf("users_shared=%#v", usersShared)
|
||||
}
|
||||
msg.Media.ServiceAction.RequestedPeer.Peers = []domain.Peer{{Type: domain.PeerTypeChannel, ID: 55}}
|
||||
msg.Media.ServiceAction.RequestedPeer.Details = []domain.MessageRequestedPeerDetails{{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 55}, Title: "Shared Chat", Username: "shared_chat",
|
||||
}}
|
||||
projected = apiMessage(msg, nil)
|
||||
chatShared := projected["chat_shared"].(map[string]any)
|
||||
if chatShared["request_id"] != 42 || chatShared["chat_id"] != int64(-1000000000055) || chatShared["title"] != "Shared Chat" {
|
||||
t.Fatalf("chat_shared=%#v", chatShared)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret)
|
||||
|
|
@ -649,11 +1058,21 @@ type fakeBotAPIGateway struct {
|
|||
editCalled bool
|
||||
editSetMarkup bool
|
||||
editMessage domain.Message
|
||||
editInlineCalled bool
|
||||
editInlineID domain.BotInlineMessageID
|
||||
deleteCalled bool
|
||||
callbackCalled bool
|
||||
callbackID string
|
||||
fileLocationKey string
|
||||
fileChunks map[string]domain.FileChunk
|
||||
allowedUpdates []domain.BotAPIUpdateKind
|
||||
dropPending bool
|
||||
pendingCount int
|
||||
webhook domain.BotAPIWebhook
|
||||
webhookFound bool
|
||||
webhookDeleted bool
|
||||
webhookDrop bool
|
||||
webhookConfirmed int64
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) {
|
||||
|
|
@ -666,6 +1085,65 @@ func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset
|
|||
return append([]domain.UpdateEvent(nil), f.updates...), nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISetAllowedUpdates(_ context.Context, _ int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
f.allowedUpdates = append([]domain.BotAPIUpdateKind(nil), allowed...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDropPendingUpdates(context.Context, int64) error {
|
||||
f.dropPending = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIPendingUpdateCount(context.Context, int64) (int, error) {
|
||||
return f.pendingCount, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISetWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
f.webhook, f.webhookFound, f.webhookDrop = config, true, dropPending
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDeleteWebhook(_ context.Context, _ int64, dropPending bool) error {
|
||||
f.webhook, f.webhookFound, f.webhookDeleted, f.webhookDrop = domain.BotAPIWebhook{}, false, true, dropPending
|
||||
if dropPending {
|
||||
f.dropPending = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIWebhook(context.Context, int64) (domain.BotAPIWebhook, bool, error) {
|
||||
return f.webhook, f.webhookFound, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) ListDueBotAPIWebhooks(context.Context, int) ([]domain.BotAPIWebhook, error) {
|
||||
if !f.webhookFound {
|
||||
return nil, nil
|
||||
}
|
||||
return []domain.BotAPIWebhook{f.webhook}, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) AcquireBotAPIWebhookLease(context.Context, int64, string, time.Duration) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) ReleaseBotAPIWebhookLease(context.Context, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) RecordBotAPIWebhookFailure(context.Context, int64, string, time.Time, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) RecordBotAPIWebhookSuccess(context.Context, int64, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) ConfirmBotAPIWebhookDelivery(_ context.Context, _ int64, updateID int64) error {
|
||||
f.webhookConfirmed = updateID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPISendMessage(_ context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) {
|
||||
f.sendCalled = true
|
||||
f.sendBotID = botID
|
||||
|
|
@ -695,6 +1173,11 @@ func (f *fakeBotAPIGateway) BotAPIEditMessageText(_ context.Context, botID, chat
|
|||
return f.editMessage, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIEditInlineMessageText(_ context.Context, _ int64, inlineMessageID domain.BotInlineMessageID, _ string, _ []domain.MessageEntity, _ bool, _ *domain.MessageReplyMarkup, _ bool) (bool, error) {
|
||||
f.editInlineCalled, f.editInlineID = true, inlineMessageID
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAPIGateway) BotAPIDeleteMessage(context.Context, int64, int64, int) (bool, error) {
|
||||
f.deleteCalled = true
|
||||
return true, nil
|
||||
|
|
|
|||
259
internal/botapi/webhook.go
Normal file
259
internal/botapi/webhook.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookScanInterval = 250 * time.Millisecond
|
||||
webhookLeaseTTL = 30 * time.Second
|
||||
webhookIdleDelay = time.Hour
|
||||
webhookBotWorkers = 16
|
||||
webhookHTTPWorkers = 64
|
||||
webhookDueBatch = 64
|
||||
)
|
||||
|
||||
type webhookDispatcher struct {
|
||||
control GatewayWebhookControl
|
||||
gateway GatewayService
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
botSem chan struct{}
|
||||
httpSem chan struct{}
|
||||
}
|
||||
|
||||
func newWebhookHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 256,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 15 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
// A redirect could leak X-Telegram-Bot-Api-Secret-Token to another host.
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runWebhookDispatcher(ctx context.Context, control GatewayWebhookControl, gateway GatewayService, client *http.Client, logger *zap.Logger) {
|
||||
if control == nil || gateway == nil {
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
client = newWebhookHTTPClient()
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
d := &webhookDispatcher{
|
||||
control: control, gateway: gateway, client: client, logger: logger,
|
||||
botSem: make(chan struct{}, webhookBotWorkers), httpSem: make(chan struct{}, webhookHTTPWorkers),
|
||||
}
|
||||
d.scan(ctx)
|
||||
ticker := time.NewTicker(webhookScanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.scan(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *webhookDispatcher) scan(ctx context.Context) {
|
||||
configs, err := d.control.ListDueBotAPIWebhooks(ctx, webhookDueBatch)
|
||||
if err != nil {
|
||||
d.logger.Warn("list due bot api webhooks", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, config := range configs {
|
||||
select {
|
||||
case d.botSem <- struct{}{}:
|
||||
go func(config domain.BotAPIWebhook) {
|
||||
defer func() { <-d.botSem }()
|
||||
d.deliver(ctx, config)
|
||||
}(config)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *webhookDispatcher) deliver(parent context.Context, candidate domain.BotAPIWebhook) {
|
||||
ctx, cancel := context.WithTimeout(parent, webhookLeaseTTL)
|
||||
defer cancel()
|
||||
owner := randomBotAPIOwner()
|
||||
acquired, err := d.control.AcquireBotAPIWebhookLease(ctx, candidate.BotUserID, owner, webhookLeaseTTL)
|
||||
if err != nil {
|
||||
d.logger.Warn("acquire bot api webhook lease", zap.Int64("bot_user_id", candidate.BotUserID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
return
|
||||
}
|
||||
released := false
|
||||
defer func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer releaseCancel()
|
||||
_ = d.control.ReleaseBotAPIWebhookLease(releaseCtx, candidate.BotUserID, owner)
|
||||
}()
|
||||
|
||||
// Re-read after taking the lease so a stale due-list row can never deliver to
|
||||
// a URL that has since been deleted or replaced.
|
||||
config, found, err := d.control.BotAPIWebhook(ctx, candidate.BotUserID)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
events, err := d.gateway.BotAPIUpdates(ctx, config.BotUserID, 0)
|
||||
if err != nil {
|
||||
d.fail(ctx, config, owner, fmt.Errorf("load updates: %w", err))
|
||||
released = true
|
||||
return
|
||||
}
|
||||
if len(events) == 0 {
|
||||
err = d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, time.Now().Add(webhookIdleDelay))
|
||||
if err != nil {
|
||||
d.logger.Warn("idle bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
|
||||
}
|
||||
released = err == nil
|
||||
return
|
||||
}
|
||||
|
||||
limit := config.MaxConnections
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 40
|
||||
}
|
||||
if limit > len(events) {
|
||||
limit = len(events)
|
||||
}
|
||||
type delivery struct {
|
||||
index int
|
||||
updateID int64
|
||||
err error
|
||||
}
|
||||
results := make(chan delivery, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
item, _, ok := apiUpdate(events[i])
|
||||
if !ok {
|
||||
results <- delivery{index: i, updateID: int64(events[i].Pts), err: errors.New("update projection failed")}
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
results <- delivery{index: i, updateID: int64(events[i].Pts), err: err}
|
||||
continue
|
||||
}
|
||||
go func(index int, updateID int64, payload []byte) {
|
||||
select {
|
||||
case d.httpSem <- struct{}{}:
|
||||
defer func() { <-d.httpSem }()
|
||||
case <-ctx.Done():
|
||||
results <- delivery{index: index, updateID: updateID, err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
results <- delivery{index: index, updateID: updateID, err: d.post(ctx, config, payload)}
|
||||
}(i, int64(events[i].Pts), payload)
|
||||
}
|
||||
deliveries := make([]delivery, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
result := <-results
|
||||
deliveries[result.index] = result
|
||||
}
|
||||
confirmedID := int64(0)
|
||||
var firstErr error
|
||||
for _, result := range deliveries {
|
||||
if result.err != nil {
|
||||
firstErr = result.err
|
||||
break
|
||||
}
|
||||
confirmedID = result.updateID
|
||||
}
|
||||
if confirmedID > 0 {
|
||||
if err := d.control.ConfirmBotAPIWebhookDelivery(ctx, config.BotUserID, confirmedID); err != nil {
|
||||
firstErr = fmt.Errorf("confirm update %d: %w", confirmedID, err)
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
d.fail(ctx, config, owner, firstErr)
|
||||
released = true
|
||||
return
|
||||
}
|
||||
nextAttempt := time.Now()
|
||||
if limit == len(events) && len(events) < 100 {
|
||||
nextAttempt = nextAttempt.Add(webhookIdleDelay)
|
||||
}
|
||||
if err := d.control.RecordBotAPIWebhookSuccess(ctx, config.BotUserID, owner, nextAttempt); err != nil {
|
||||
d.logger.Warn("complete bot api webhook", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
released = true
|
||||
}
|
||||
|
||||
func (d *webhookDispatcher) post(ctx context.Context, config domain.BotAPIWebhook, payload []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if config.SecretToken != "" {
|
||||
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", config.SecretToken)
|
||||
}
|
||||
resp, err := d.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhook, owner string, cause error) {
|
||||
exponent := config.FailureCount
|
||||
if exponent < 0 {
|
||||
exponent = 0
|
||||
}
|
||||
if exponent > 8 {
|
||||
exponent = 8
|
||||
}
|
||||
delay := time.Second * time.Duration(1<<exponent)
|
||||
if delay > 5*time.Minute {
|
||||
delay = 5 * time.Minute
|
||||
}
|
||||
// Small deterministic jitter prevents synchronized retries without a global RNG lock.
|
||||
delay += time.Duration(config.BotUserID&255) * time.Millisecond
|
||||
message := cause.Error()
|
||||
if err := d.control.RecordBotAPIWebhookFailure(ctx, config.BotUserID, owner, time.Now().Add(delay), message); err != nil {
|
||||
d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
|
||||
}
|
||||
127
internal/botapi/webhook_test.go
Normal file
127
internal/botapi/webhook_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package botapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type recordingWebhookGateway struct {
|
||||
*fakeBotAPIGateway
|
||||
mu sync.Mutex
|
||||
failure string
|
||||
failureNext time.Time
|
||||
successNext time.Time
|
||||
recordedOwner string
|
||||
}
|
||||
|
||||
func (g *recordingWebhookGateway) RecordBotAPIWebhookFailure(_ context.Context, _ int64, owner string, next time.Time, message string) error {
|
||||
g.mu.Lock()
|
||||
g.recordedOwner, g.failure, g.failureNext = owner, message, next
|
||||
g.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *recordingWebhookGateway) RecordBotAPIWebhookSuccess(_ context.Context, _ int64, owner string, next time.Time) error {
|
||||
g.mu.Lock()
|
||||
g.recordedOwner, g.successNext = owner, next
|
||||
g.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func webhookEvents(ids ...int) []domain.UpdateEvent {
|
||||
out := make([]domain.UpdateEvent, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, domain.UpdateEvent{
|
||||
UserID: 1001, Type: domain.UpdateEventNewMessage, Pts: id, Date: 1700000000 + id,
|
||||
Message: domain.Message{
|
||||
ID: id, OwnerUserID: 1001, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: 2001}, Date: 1700000000 + id, Body: "message", Out: false,
|
||||
},
|
||||
Users: []domain.User{{ID: 2001, FirstName: "Alice"}},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWebhookDispatcherPostsInParallelWithSecretAndConfirmsContiguousBatch(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
received := make(map[int]bool)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("X-Telegram-Bot-Api-Secret-Token"); got != "secret_1" {
|
||||
t.Errorf("secret header = %q", got)
|
||||
}
|
||||
var update struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
t.Errorf("decode webhook: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
received[update.UpdateID] = true
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
base := &fakeBotAPIGateway{
|
||||
updates: webhookEvents(11, 12, 13),
|
||||
webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, SecretToken: "secret_1", MaxConnections: 3},
|
||||
webhookFound: true,
|
||||
}
|
||||
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
|
||||
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
|
||||
d.deliver(context.Background(), base.webhook)
|
||||
|
||||
mu.Lock()
|
||||
count := len(received)
|
||||
mu.Unlock()
|
||||
if count != 3 || base.webhookConfirmed != 13 {
|
||||
t.Fatalf("received=%v confirmed=%d", received, base.webhookConfirmed)
|
||||
}
|
||||
gateway.mu.Lock()
|
||||
successNext, failure := gateway.successNext, gateway.failure
|
||||
gateway.mu.Unlock()
|
||||
if !successNext.After(time.Now().Add(30*time.Minute)) || failure != "" {
|
||||
t.Fatalf("success next=%v failure=%q", successNext, failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var update struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&update)
|
||||
if update.UpdateID == 22 {
|
||||
http.Error(w, "retry", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
base := &fakeBotAPIGateway{
|
||||
updates: webhookEvents(21, 22, 23),
|
||||
webhook: domain.BotAPIWebhook{BotUserID: 1001, URL: server.URL, MaxConnections: 3},
|
||||
webhookFound: true,
|
||||
}
|
||||
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
|
||||
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
|
||||
d.deliver(context.Background(), base.webhook)
|
||||
|
||||
gateway.mu.Lock()
|
||||
failure, retryAt := gateway.failure, gateway.failureNext
|
||||
gateway.mu.Unlock()
|
||||
if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) {
|
||||
t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue