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:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

@ -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, &copy) != 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
}

View file

@ -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)+":")
}

View file

@ -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) {

View file

@ -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
View 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))
}

View 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)
}
}