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
|
|
@ -1276,6 +1276,9 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send
|
|||
if req.UserID != userID {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
if req.RandomID != 0 && !req.IdempotencyPreflighted {
|
||||
fingerprint, err := store.ChannelSendFingerprint(req)
|
||||
if err != nil {
|
||||
|
|
@ -1343,6 +1346,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
|
|||
if req.UserID != userID || req.ChannelID == 0 || req.ID <= 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.SetReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
|
||||
return domain.EditChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
||||
|
|
@ -1359,6 +1367,11 @@ func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req dom
|
|||
if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 {
|
||||
return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if req.SetReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
|
||||
return domain.EditChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
req.ViaBotEditBotID = botID
|
||||
return s.channels.EditChannelMessage(ctx, req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,7 +461,14 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
|
|||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := &domain.MessageReplyMarkup{}
|
||||
out := &domain.MessageReplyMarkup{
|
||||
Type: in.Type,
|
||||
Resize: in.Resize,
|
||||
SingleUse: in.SingleUse,
|
||||
Selective: in.Selective,
|
||||
Persistent: in.Persistent,
|
||||
Placeholder: in.Placeholder,
|
||||
}
|
||||
if len(in.Inline) > 0 {
|
||||
out.Inline = make([][]domain.MarkupButton, len(in.Inline))
|
||||
for i, row := range in.Inline {
|
||||
|
|
@ -472,6 +479,12 @@ func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.Messa
|
|||
}
|
||||
}
|
||||
}
|
||||
if len(in.Keyboard) > 0 {
|
||||
out.Keyboard = make([][]domain.MarkupButton, len(in.Keyboard))
|
||||
for i, row := range in.Keyboard {
|
||||
out.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,41 @@ func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, e
|
|||
return s.media.GetPhoto(ctx, id)
|
||||
}
|
||||
|
||||
type photoBatchStore interface {
|
||||
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
|
||||
}
|
||||
|
||||
// GetPhotos loads immutable photo metadata in caller order without requiring
|
||||
// one storage round-trip per requested-peer response. PostgreSQL implements the
|
||||
// optional batch primitive; lightweight stores retain a bounded fallback.
|
||||
func (s *Service) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
|
||||
if s == nil || s.media == nil || len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if batch, ok := s.media.(photoBatchStore); ok {
|
||||
return batch.GetPhotos(ctx, ids)
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
out := make([]domain.Photo, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
photo, found, err := s.media.GetPhoto(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out = append(out, photo)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDocument 按 id 返回已存储文档(贴纸 / 文件)。
|
||||
func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) {
|
||||
return s.media.GetDocument(ctx, id)
|
||||
|
|
|
|||
|
|
@ -100,6 +100,9 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain.
|
|||
if req.SenderUserID != userID {
|
||||
return domain.SendPrivateTextResult{}, domain.ErrAuthenticatedScopeInvalid
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if req.RandomID != 0 && !req.IdempotencyPreflighted {
|
||||
fingerprint, err := store.PrivateSendFingerprint(req)
|
||||
if err != nil {
|
||||
|
|
@ -263,6 +266,32 @@ func (s *Service) GetMessages(ctx context.Context, userID int64, ids []int) (dom
|
|||
return s.projectMessageUsers(ctx, userID, list)
|
||||
}
|
||||
|
||||
type messageByUIDStore interface {
|
||||
GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
|
||||
}
|
||||
|
||||
// GetMessageByUID translates a shared private message id into one owner's exact box row.
|
||||
// It is intentionally an optional capability so lightweight MessageStore test doubles that
|
||||
// never exercise callback translation do not need a meaningless implementation.
|
||||
func (s *Service) GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
|
||||
if s == nil || userID == 0 || uid == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
provider, ok := s.messages.(messageByUIDStore)
|
||||
if !ok {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
msg, found, err := provider.GetByUID(ctx, userID, uid)
|
||||
if err != nil || !found {
|
||||
return domain.Message{}, found, err
|
||||
}
|
||||
list, err := s.projectMessageUsers(ctx, userID, domain.MessageList{Messages: []domain.Message{msg}})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
return domain.Message{}, false, err
|
||||
}
|
||||
return list.Messages[0], true, nil
|
||||
}
|
||||
|
||||
// GetHistory 返回当前账号某个 peer 的历史消息。
|
||||
func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
return s.list(ctx, userID, filter)
|
||||
|
|
@ -406,6 +435,11 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit
|
|||
if req.OwnerUserID == 0 {
|
||||
req.OwnerUserID = userID
|
||||
}
|
||||
if req.SetReplyMarkup {
|
||||
if err := domain.ValidateReplyMarkup(req.ReplyMarkup); err != nil {
|
||||
return domain.EditMessageResult{OwnerUserID: userID}, err
|
||||
}
|
||||
}
|
||||
return s.messages.EditMessage(ctx, req)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -204,15 +204,19 @@ type BotAttachMenuState struct {
|
|||
|
||||
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
|
||||
type BotRequestedWebViewButton struct {
|
||||
WebAppReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
WebAppReqID string
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
ButtonID int
|
||||
Text string
|
||||
PeerType string
|
||||
MaxQuantity int
|
||||
PeerFilter *BotRequestPeerFilter
|
||||
NameRequested bool
|
||||
UsernameRequested bool
|
||||
PhotoRequested bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答
|
||||
|
|
|
|||
|
|
@ -6,8 +6,32 @@ type BotAPIUpdateKind string
|
|||
const (
|
||||
BotAPIUpdateMessage BotAPIUpdateKind = "message"
|
||||
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message"
|
||||
BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query"
|
||||
)
|
||||
|
||||
// BotCallbackQuery is the protocol-neutral payload shared by MTProto
|
||||
// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection.
|
||||
type BotCallbackQuery struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
ChatInstance int64
|
||||
Data []byte
|
||||
InlineMessage *BotInlineMessageID
|
||||
}
|
||||
|
||||
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
|
||||
// It can be projected both to MTProto and to Bot API's opaque
|
||||
// inline_message_id without leaking tg types into the store boundary.
|
||||
type BotInlineMessageID struct {
|
||||
DCID int
|
||||
OwnerID int64
|
||||
ID int
|
||||
AccessHash int64
|
||||
}
|
||||
|
||||
// BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id
|
||||
// and is global across all bots, matching Telegram Bot API's monotonic offset
|
||||
// contract without reusing MTProto pts from user/channel logs.
|
||||
|
|
@ -19,6 +43,7 @@ type BotAPIUpdate struct {
|
|||
MessageID int
|
||||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
}
|
||||
|
||||
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
|
||||
|
|
@ -30,4 +55,5 @@ type EnqueueBotAPIUpdateRequest struct {
|
|||
MessageID int
|
||||
SourcePts int
|
||||
Date int
|
||||
Callback *BotCallbackQuery
|
||||
}
|
||||
|
|
|
|||
21
internal/domain/botapi_webhook.go
Normal file
21
internal/domain/botapi_webhook.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// BotAPIWebhook is durable delivery configuration and observable retry state.
|
||||
// The token secret is never stored here: authentication remains owned by BotProfile.
|
||||
type BotAPIWebhook struct {
|
||||
BotUserID int64
|
||||
URL string
|
||||
SecretToken string
|
||||
MaxConnections int
|
||||
AllowedUpdates []BotAPIUpdateKind
|
||||
// AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty)
|
||||
// setWebhook parameter from omission, which must preserve the previous
|
||||
// getUpdates/setWebhook policy atomically at the store boundary.
|
||||
AllowedUpdatesSet bool
|
||||
FailureCount int
|
||||
LastErrorDate int
|
||||
LastErrorMessage string
|
||||
NextAttemptAt time.Time
|
||||
}
|
||||
|
|
@ -188,20 +188,23 @@ const (
|
|||
|
||||
// ChannelAdminRights is a domain-only representation of Telegram admin rights.
|
||||
type ChannelAdminRights struct {
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
ManageChat bool
|
||||
ManageTopics bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ManageLinkedPeers bool
|
||||
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
|
||||
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
|
||||
ManageDirectMessages bool
|
||||
|
|
@ -210,19 +213,22 @@ type ChannelAdminRights struct {
|
|||
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
|
||||
func CreatorChannelAdminRights() ChannelAdminRights {
|
||||
return ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageRanks: true,
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageChat: true,
|
||||
ManageTopics: true,
|
||||
ManageRanks: true,
|
||||
ManageLinkedPeers: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -610,6 +610,23 @@ type MessageWebViewDataAction struct {
|
|||
type MessageRequestedPeerAction struct {
|
||||
ButtonID int `json:"button_id"`
|
||||
Peers []Peer `json:"peers"`
|
||||
// Details is the immutable, permission-gated snapshot delivered to the bot.
|
||||
// It is kept separate from Peers because the sender-side MTProto action only
|
||||
// exposes peer identities, while the bot-side/Bot API view may additionally
|
||||
// expose the requested name, username, and profile photo.
|
||||
Details []MessageRequestedPeerDetails `json:"details,omitempty"`
|
||||
NameRequested bool `json:"name_requested,omitempty"`
|
||||
UsernameRequested bool `json:"username_requested,omitempty"`
|
||||
PhotoRequested bool `json:"photo_requested,omitempty"`
|
||||
}
|
||||
|
||||
type MessageRequestedPeerDetails struct {
|
||||
Peer Peer `json:"peer"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
}
|
||||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ type Message struct {
|
|||
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
|
||||
// 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。
|
||||
Effect int64
|
||||
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可
|
||||
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
|
||||
// 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
|
|
@ -283,7 +283,7 @@ type SendPrivateTextRequest struct {
|
|||
// BusinessAutomationKind is internal app-layer metadata used to suppress
|
||||
// recursive greeting/away automation for server-generated replies.
|
||||
BusinessAutomationKind BusinessAutomationKind
|
||||
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。
|
||||
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
// RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。
|
||||
RichMessage *MessageRichMessage
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ const (
|
|||
MaxCallbackDataLen = 64
|
||||
// MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。
|
||||
MaxMarkupButtonTextLen = 256
|
||||
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
|
||||
MaxReplyKeyboardButtonTextLen = 64
|
||||
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
|
||||
MaxReplyKeyboardPlaceholderLen = 64
|
||||
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
|
||||
MaxBotCallbackAnswerLen = 200
|
||||
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。
|
||||
|
|
@ -38,20 +42,81 @@ var (
|
|||
ErrStartParamInvalid = errors.New("start param invalid")
|
||||
)
|
||||
|
||||
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
|
||||
// MarkupButtonType 标识消息键盘按钮类型。
|
||||
type MarkupButtonType string
|
||||
|
||||
const (
|
||||
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
|
||||
MarkupButtonText MarkupButtonType = "text"
|
||||
// MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。
|
||||
MarkupButtonCallback MarkupButtonType = "callback"
|
||||
// MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
MarkupButtonURL MarkupButtonType = "url"
|
||||
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
|
||||
MarkupButtonRequestLocation MarkupButtonType = "request_location"
|
||||
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
|
||||
MarkupButtonRequestPeer MarkupButtonType = "request_peer"
|
||||
MarkupButtonWebView MarkupButtonType = "webview"
|
||||
MarkupButtonSimpleWebView MarkupButtonType = "simple_webview"
|
||||
MarkupButtonSwitchInline MarkupButtonType = "switch_inline"
|
||||
MarkupButtonCopy MarkupButtonType = "copy"
|
||||
)
|
||||
|
||||
// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。
|
||||
// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
|
||||
// intentionally exposes semantic colors instead of arbitrary RGB values.
|
||||
type MarkupButtonStyle string
|
||||
|
||||
const (
|
||||
MarkupButtonStylePrimary MarkupButtonStyle = "primary"
|
||||
MarkupButtonStyleDanger MarkupButtonStyle = "danger"
|
||||
MarkupButtonStyleSuccess MarkupButtonStyle = "success"
|
||||
)
|
||||
|
||||
// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without
|
||||
// importing protocol types into persisted message state.
|
||||
type BotRequestAdminRights struct {
|
||||
Anonymous bool `json:"anonymous,omitempty"`
|
||||
ManageChat bool `json:"manage_chat,omitempty"`
|
||||
DeleteMessages bool `json:"delete_messages,omitempty"`
|
||||
ManageVideoChats bool `json:"manage_video_chats,omitempty"`
|
||||
RestrictMembers bool `json:"restrict_members,omitempty"`
|
||||
PromoteMembers bool `json:"promote_members,omitempty"`
|
||||
ChangeInfo bool `json:"change_info,omitempty"`
|
||||
InviteUsers bool `json:"invite_users,omitempty"`
|
||||
PostStories bool `json:"post_stories,omitempty"`
|
||||
EditStories bool `json:"edit_stories,omitempty"`
|
||||
DeleteStories bool `json:"delete_stories,omitempty"`
|
||||
PostMessages bool `json:"post_messages,omitempty"`
|
||||
EditMessages bool `json:"edit_messages,omitempty"`
|
||||
PinMessages bool `json:"pin_messages,omitempty"`
|
||||
ManageTopics bool `json:"manage_topics,omitempty"`
|
||||
ManageDirectMessages bool `json:"manage_direct_messages,omitempty"`
|
||||
}
|
||||
|
||||
type BotRequestPeerFilter struct {
|
||||
UserIsBotSet bool `json:"user_is_bot_set,omitempty"`
|
||||
UserIsBot bool `json:"user_is_bot,omitempty"`
|
||||
UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"`
|
||||
UserIsPremium bool `json:"user_is_premium,omitempty"`
|
||||
ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"`
|
||||
ChatHasUsername bool `json:"chat_has_username,omitempty"`
|
||||
ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"`
|
||||
ChatIsForum bool `json:"chat_is_forum,omitempty"`
|
||||
ChatIsCreated bool `json:"chat_is_created,omitempty"`
|
||||
BotIsMember bool `json:"bot_is_member,omitempty"`
|
||||
UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"`
|
||||
BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"`
|
||||
}
|
||||
|
||||
// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮;
|
||||
// inline keyboard 当前接受 callback/url。
|
||||
type MarkupButton struct {
|
||||
Type MarkupButtonType `json:"type"`
|
||||
Text string `json:"text"`
|
||||
// Style is one of primary/danger/success. Empty means the client default.
|
||||
Style MarkupButtonStyle `json:"style,omitempty"`
|
||||
// IconCustomEmojiID is the optional custom emoji rendered before Text.
|
||||
IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"`
|
||||
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64
|
||||
// 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。
|
||||
Data []byte `json:"data,omitempty"`
|
||||
|
|
@ -60,11 +125,68 @@ type MarkupButton struct {
|
|||
// RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password,
|
||||
// 2FA SRP 校验 P3 stub)。
|
||||
RequiresPassword bool `json:"requires_password,omitempty"`
|
||||
// PollType is empty, "regular", or "quiz" for request_poll.
|
||||
PollType string `json:"poll_type,omitempty"`
|
||||
// ButtonID and request-peer fields preserve Bot API request_id and the
|
||||
// client-side chooser shape. RequestPeerType is user/chat/broadcast.
|
||||
ButtonID int `json:"button_id,omitempty"`
|
||||
RequestPeerType string `json:"request_peer_type,omitempty"`
|
||||
MaxQuantity int `json:"max_quantity,omitempty"`
|
||||
NameRequested bool `json:"name_requested,omitempty"`
|
||||
UsernameRequested bool `json:"username_requested,omitempty"`
|
||||
PhotoRequested bool `json:"photo_requested,omitempty"`
|
||||
RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
SamePeer bool `json:"same_peer,omitempty"`
|
||||
PeerTypes []string `json:"peer_types,omitempty"`
|
||||
CopyText string `json:"copy_text,omitempty"`
|
||||
}
|
||||
|
||||
// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。
|
||||
// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。
|
||||
type MessageReplyMarkupType string
|
||||
|
||||
const (
|
||||
MessageReplyMarkupInline MessageReplyMarkupType = "inline"
|
||||
MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard"
|
||||
MessageReplyMarkupHide MessageReplyMarkupType = "hide"
|
||||
MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
|
||||
)
|
||||
|
||||
// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline
|
||||
// 非空表示 0110 之前已经持久化的合法 inline keyboard,Kind 会将其解释为 inline。
|
||||
type MessageReplyMarkup struct {
|
||||
Inline [][]MarkupButton `json:"inline,omitempty"`
|
||||
Type MessageReplyMarkupType `json:"type,omitempty"`
|
||||
Inline [][]MarkupButton `json:"inline,omitempty"`
|
||||
Keyboard [][]MarkupButton `json:"keyboard,omitempty"`
|
||||
Resize bool `json:"resize,omitempty"`
|
||||
SingleUse bool `json:"single_use,omitempty"`
|
||||
Selective bool `json:"selective,omitempty"`
|
||||
Persistent bool `json:"persistent,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
}
|
||||
|
||||
// Kind 返回 markup constructor;兼容已落库的无 Type inline 快照。
|
||||
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Type != "" {
|
||||
return m.Type
|
||||
}
|
||||
if len(m.Inline) > 0 {
|
||||
return MessageReplyMarkupInline
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。
|
||||
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
|
||||
switch m.Kind() {
|
||||
case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
|
||||
|
|
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
|
|||
if m == nil {
|
||||
return true
|
||||
}
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > 0 {
|
||||
return false
|
||||
switch m.Kind() {
|
||||
case MessageReplyMarkupInline:
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case MessageReplyMarkupKeyboard:
|
||||
for _, row := range m.Keyboard {
|
||||
if len(row) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case MessageReplyMarkupHide, MessageReplyMarkupForceReply:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。
|
||||
// 空 markup 合法(视为清空/无键盘)。
|
||||
// ValidateReplyMarkup 校验 markup constructor、结构与按钮,校验须先于落库(I9)。
|
||||
// 空 inline markup 合法(视为清空/无键盘)。
|
||||
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if len(m.Inline) > MaxMarkupRows {
|
||||
kind := m.Kind()
|
||||
if kind == "" {
|
||||
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case MessageReplyMarkupInline:
|
||||
if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return validateMarkupRows(m.Inline, false)
|
||||
case MessageReplyMarkupKeyboard:
|
||||
if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
if len(m.Keyboard) == 0 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return validateMarkupRows(m.Keyboard, true)
|
||||
case MessageReplyMarkupHide:
|
||||
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return nil
|
||||
case MessageReplyMarkupForceReply:
|
||||
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error {
|
||||
if len(rows) > MaxMarkupRows {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
total := 0
|
||||
for _, row := range m.Inline {
|
||||
if len(row) > MaxMarkupButtonsPerRow {
|
||||
for _, row := range rows {
|
||||
if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
total += len(row)
|
||||
|
|
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
|||
return ErrButtonInvalid
|
||||
}
|
||||
for i := range row {
|
||||
if err := validateMarkupButton(row[i]); err != nil {
|
||||
if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateMarkupButton(b MarkupButton) error {
|
||||
func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
|
||||
text := strings.TrimSpace(b.Text)
|
||||
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
switch b.Style {
|
||||
case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess:
|
||||
default:
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
if b.IconCustomEmojiID < 0 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
if replyKeyboard {
|
||||
if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
switch b.Type {
|
||||
case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation:
|
||||
case MarkupButtonRequestPoll:
|
||||
if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
case MarkupButtonRequestPeer:
|
||||
if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 ||
|
||||
(b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
if b.RequestPeerFilter != nil {
|
||||
filter := b.RequestPeerFilter
|
||||
if b.RequestPeerType == "user" {
|
||||
if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember ||
|
||||
filter.UserAdminRights != nil || filter.BotAdminRights != nil {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
} else {
|
||||
if filter.UserIsBotSet || filter.UserIsPremiumSet ||
|
||||
(b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
case MarkupButtonSimpleWebView:
|
||||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return ErrButtonTypeInvalid
|
||||
}
|
||||
if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch b.Type {
|
||||
case MarkupButtonCallback:
|
||||
if len(b.Data) > MaxCallbackDataLen {
|
||||
|
|
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
|
|||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
case MarkupButtonWebView:
|
||||
if err := validateButtonURL(b.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
case MarkupButtonSwitchInline:
|
||||
if utf8.RuneCountInString(b.Query) > 256 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
case MarkupButtonCopy:
|
||||
if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
default:
|
||||
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
|
||||
return ErrButtonTypeInvalid
|
||||
|
|
|
|||
|
|
@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) {
|
|||
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
|
||||
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
|
||||
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
|
||||
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
|
||||
{"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil},
|
||||
{"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid},
|
||||
{"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid},
|
||||
{"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid},
|
||||
{"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid},
|
||||
{"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid},
|
||||
{"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid},
|
||||
{"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil},
|
||||
{"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil},
|
||||
{"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
|
||||
{"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) {
|
|||
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
|
||||
t.Fatal("markup with a button must not be zero")
|
||||
}
|
||||
if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() {
|
||||
t.Fatal("reply keyboard with a button must not be zero")
|
||||
}
|
||||
if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() {
|
||||
t.Fatal("hide keyboard constructor must not be zero")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ const (
|
|||
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
|
||||
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
// UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号
|
||||
// pts/difference/outbox。
|
||||
UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query"
|
||||
// UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览
|
||||
// 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建,
|
||||
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
|
||||
|
|
@ -110,6 +113,7 @@ type UpdateEvent struct {
|
|||
QuickReplies []QuickReply
|
||||
QuickReply QuickReply
|
||||
QuickReplyMessage QuickReplyMessage
|
||||
BotCallbackQuery *BotCallbackQuery
|
||||
}
|
||||
|
||||
// LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import (
|
|||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
var botAPIAuthKeyID = [8]byte{'B', 'O', 'T', 'A', 'P', 'I', 0, 1}
|
||||
|
|
@ -61,6 +64,114 @@ func (r *Router) BotAPIUpdates(ctx context.Context, botID int64, offset int64) (
|
|||
return r.enrichUpdateEvents(ctx, botID, diff.Events), nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetAllowedUpdates(ctx context.Context, botID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.SetBotAPIAllowedUpdates(ctx, botID, allowed)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDropPendingUpdates(ctx context.Context, botID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.DropPendingBotAPIUpdates(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIPendingUpdateCount(ctx context.Context, botID int64) (int, error) {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.PendingBotAPIUpdateCount(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIPollLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
return leases.AcquireBotAPIPollLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIPollLease(ctx context.Context, botID int64, owner string) error {
|
||||
leases, ok := r.deps.BotAPIUpdates.(store.BotAPIPollLeaseStore)
|
||||
if !ok || botID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return leases.ReleaseBotAPIPollLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPISetWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.SetBotAPIWebhook(ctx, config, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIDeleteWebhook(ctx context.Context, botID int64, dropPending bool) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return errors.New("WEBHOOK_UNSUPPORTED")
|
||||
}
|
||||
return webhooks.DeleteBotAPIWebhook(ctx, botID, dropPending)
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIWebhook(ctx context.Context, botID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return domain.BotAPIWebhook{}, false, nil
|
||||
}
|
||||
return webhooks.BotAPIWebhook(ctx, botID)
|
||||
}
|
||||
|
||||
func (r *Router) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return webhooks.ListDueBotAPIWebhooks(ctx, limit)
|
||||
}
|
||||
|
||||
func (r *Router) AcquireBotAPIWebhookLease(ctx context.Context, botID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return webhooks.AcquireBotAPIWebhookLease(ctx, botID, owner, ttl)
|
||||
}
|
||||
|
||||
func (r *Router) ReleaseBotAPIWebhookLease(ctx context.Context, botID int64, owner string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.ReleaseBotAPIWebhookLease(ctx, botID, owner)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookFailure(ctx context.Context, botID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookFailure(ctx, botID, owner, nextAttempt, message)
|
||||
}
|
||||
|
||||
func (r *Router) RecordBotAPIWebhookSuccess(ctx context.Context, botID int64, owner string, nextAttempt time.Time) error {
|
||||
webhooks, ok := r.deps.BotAPIUpdates.(store.BotAPIWebhookStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return webhooks.RecordBotAPIWebhookSuccess(ctx, botID, owner, nextAttempt)
|
||||
}
|
||||
|
||||
func (r *Router) ConfirmBotAPIWebhookDelivery(ctx context.Context, botID, updateID int64) error {
|
||||
if r == nil || r.deps.BotAPIUpdates == nil || botID <= 0 || updateID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, updateID)
|
||||
}
|
||||
|
||||
// BotAPISendMessage sends a text message as a bot through the normal private
|
||||
// or channel message state machine. Positive chat_id is a user private chat;
|
||||
// -1000000000000-channel_id is a supergroup/channel chat.
|
||||
|
|
@ -72,6 +183,12 @@ func (r *Router) BotAPISendMessage(ctx context.Context, botID, chatID int64, tex
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if text == "" {
|
||||
return domain.Message{}, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
|
|
@ -122,6 +239,12 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
|
|||
if !ok {
|
||||
return domain.Message{}, errors.New("CHAT_ID_INVALID")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return domain.Message{}, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, botID, peer, replyMarkup); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if utf8.RuneCountInString(caption) > domain.MaxMessageTextLength {
|
||||
return domain.Message{}, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
|
|
@ -400,6 +523,37 @@ func (r *Router) BotAPIEditMessageText(ctx context.Context, botID, chatID int64,
|
|||
return self.Message, nil
|
||||
}
|
||||
|
||||
func (r *Router) BotAPIEditInlineMessageText(ctx context.Context, botID int64, inlineMessageID domain.BotInlineMessageID, text string, entities []domain.MessageEntity, setReplyMarkup bool, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview bool) (bool, error) {
|
||||
if r == nil || botID == 0 || !r.userIsBot(ctx, botID) {
|
||||
return false, errors.New("BOT_INVALID")
|
||||
}
|
||||
if text == "" {
|
||||
return false, errors.New("MESSAGE_EMPTY")
|
||||
}
|
||||
if utf8.RuneCountInString(text) > domain.MaxMessageTextLength {
|
||||
return false, errors.New("MESSAGE_TOO_LONG")
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(replyMarkup); err != nil {
|
||||
return false, replyMarkupErr(err)
|
||||
}
|
||||
req := &tg.MessagesEditInlineBotMessageRequest{
|
||||
ID: tgInputBotInlineMessageID(inlineMessageID),
|
||||
NoWebpage: disableWebPagePreview,
|
||||
}
|
||||
req.SetMessage(text)
|
||||
if len(entities) > 0 {
|
||||
req.SetEntities(tgMessageEntities(entities))
|
||||
}
|
||||
if setReplyMarkup {
|
||||
wire := tgReplyMarkup(replyMarkup)
|
||||
if wire == nil {
|
||||
wire = &tg.ReplyInlineMarkup{}
|
||||
}
|
||||
req.SetReplyMarkup(wire)
|
||||
}
|
||||
return r.onMessagesEditInlineBotMessage(WithUserID(ctx, botID), req)
|
||||
}
|
||||
|
||||
// BotAPIDeleteMessage deletes a bot-owned private message with revoke=true so
|
||||
// the target user's MTProto clients observe the normal delete update.
|
||||
func (r *Router) BotAPIDeleteMessage(ctx context.Context, botID, chatID int64, messageID int) (bool, error) {
|
||||
|
|
@ -440,12 +594,18 @@ func (r *Router) BotAPIAnswerCallbackQuery(ctx context.Context, botID int64, cal
|
|||
if cacheTime < 0 {
|
||||
cacheTime = 0
|
||||
}
|
||||
r.callbacks.resolve(botID, queryID, domain.BotCallbackAnswer{
|
||||
resolved, resolveErr := r.callbacks.resolveContext(ctx, botID, queryID, domain.BotCallbackAnswer{
|
||||
Alert: showAlert,
|
||||
Message: text,
|
||||
URL: url,
|
||||
CacheTime: cacheTime,
|
||||
})
|
||||
if resolveErr != nil {
|
||||
return false, resolveErr
|
||||
}
|
||||
if !resolved {
|
||||
return false, errors.New("QUERY_ID_INVALID")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
appbots "telesrv/internal/app/bots"
|
||||
|
|
@ -17,6 +20,170 @@ import (
|
|||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestBotAPICallbackQueryPrivatePollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("private-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.messages.SendPrivateText(fixture.ctx, fixture.bot.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: fixture.bot.ID, RecipientUserID: fixture.owner.ID,
|
||||
RandomID: 90001, Message: "tap private", Date: 200, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
if _, err := fixture.router.resolveBotCallbackQuery(
|
||||
fixture.ctx,
|
||||
fixture.owner.ID,
|
||||
domain.Peer{Type: domain.PeerTypeUser, ID: fixture.bot.ID},
|
||||
sent.RecipientMessage.ID,
|
||||
[]byte("forged-callback-data"),
|
||||
); !tgerr.Is(err, "DATA_INVALID") {
|
||||
t.Fatalf("forged callback data err = %v, want DATA_INVALID", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: fixture.bot.ID, AccessHash: fixture.bot.AccessHash},
|
||||
MsgID: sent.RecipientMessage.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
answer, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- struct {
|
||||
answer *tg.MessagesBotCallbackAnswer
|
||||
err error
|
||||
}{answer: answer, err: err}
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
if event.Message.ID != sent.SenderMessage.ID || event.Message.OwnerUserID != fixture.bot.ID || !event.Message.Out {
|
||||
t.Fatalf("callback message = %+v, want bot-side box id %d", event.Message, sent.SenderMessage.ID)
|
||||
}
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.UserID != fixture.owner.ID || callback.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}) ||
|
||||
callback.MessageID != sent.SenderMessage.ID || string(callback.Data) != string(data) {
|
||||
t.Fatalf("callback = %+v", callback)
|
||||
}
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "accepted", "", false, 0); err != nil || !ok {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery = %v, %v", ok, err)
|
||||
}
|
||||
select {
|
||||
case result := <-answerCh:
|
||||
if result.err != nil || result.answer == nil || result.answer.Message != "accepted" {
|
||||
t.Fatalf("callback answer = %+v err=%v", result.answer, result.err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQueryRejectsExpiredOrUnknownAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
if ok, err := fixture.router.BotAPIAnswerCallbackQuery(fixture.ctx, fixture.bot.ID, "999", "late", "", false, 0); err == nil || ok || !strings.Contains(err.Error(), "QUERY_ID_INVALID") {
|
||||
t.Fatalf("unknown answer = ok=%v err=%v", ok, err)
|
||||
}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 1, BotUserID: fixture.bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1,
|
||||
Date: 100,
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 2, BotUserID: fixture.bot.ID, UserID: fixture.owner.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fixture.owner.ID}, MessageID: 1, ChatInstance: 3,
|
||||
},
|
||||
}
|
||||
if _, ok := botAPIQueuedUpdateKind(fixture.bot.ID, item, time.Unix(100, 0).Add(botCallbackTimeout)); ok {
|
||||
t.Fatal("callback at answer deadline remained deliverable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackDoesNotHydrateNonexistentChatMessage(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 17, AccessHash: 9988}
|
||||
item := domain.BotAPIUpdate{
|
||||
ID: 55, BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(now.Unix()),
|
||||
Callback: &domain.BotCallbackQuery{
|
||||
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99,
|
||||
Data: []byte("inline"), InlineMessage: inline,
|
||||
},
|
||||
}
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(1001, item, nil, nil, now)
|
||||
if !ok || event.Type != domain.UpdateEventBotCallbackQuery || event.Message.ID != 0 || event.Peer != (domain.Peer{}) ||
|
||||
event.BotCallbackQuery == nil || event.BotCallbackQuery.InlineMessage == nil || *event.BotCallbackQuery.InlineMessage != *inline {
|
||||
t.Fatalf("inline callback event=%#v ok=%v", event, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPICallbackQuerySupergroupPollingAndAnswer(t *testing.T) {
|
||||
fixture := newBotAPIReceiveFixture(t, false)
|
||||
data := []byte("group-confirm")
|
||||
markup := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: [][]domain.MarkupButton{{{
|
||||
Type: domain.MarkupButtonCallback, Text: "Confirm", Data: data,
|
||||
}}}}
|
||||
sent, err := fixture.channels.SendMessage(fixture.ctx, fixture.bot.ID, domain.SendChannelMessageRequest{
|
||||
UserID: fixture.bot.ID, ChannelID: fixture.channel.ID, RandomID: 90002,
|
||||
Message: "tap group", Date: 201, ReplyMarkup: markup, SkipRecipientLookup: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(WithUserID(context.Background(), fixture.owner.ID), 5*time.Second)
|
||||
defer cancel()
|
||||
answerCh := make(chan error, 1)
|
||||
go func() {
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: &tg.InputPeerChannel{ChannelID: fixture.channel.ID, AccessHash: fixture.channel.AccessHash},
|
||||
MsgID: sent.Message.ID,
|
||||
}
|
||||
req.SetData(data)
|
||||
_, err := fixture.router.onMessagesGetBotCallbackAnswer(ctx, req)
|
||||
answerCh <- err
|
||||
}()
|
||||
|
||||
event := waitForBotAPICallbackEvent(t, ctx, fixture.router, fixture.bot.ID)
|
||||
callback := event.BotCallbackQuery
|
||||
if callback == nil || callback.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: fixture.channel.ID}) ||
|
||||
callback.MessageID != sent.Message.ID || event.Message.ID != sent.Message.ID || !event.Message.Out {
|
||||
t.Fatalf("group callback event = %+v", event)
|
||||
}
|
||||
if _, err := fixture.router.BotAPIAnswerCallbackQuery(ctx, fixture.bot.ID, strconv.FormatInt(callback.ID, 10), "", "", false, 0); err != nil {
|
||||
t.Fatalf("BotAPIAnswerCallbackQuery: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-answerCh:
|
||||
if err != nil {
|
||||
t.Fatalf("group callback answer: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("group callback answer did not unblock requester")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBotAPICallbackEvent(t *testing.T, ctx context.Context, router *Router, botID int64) domain.UpdateEvent {
|
||||
t.Helper()
|
||||
for {
|
||||
events, err := router.BotAPIUpdates(ctx, botID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPIUpdates: %v", err)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Type == domain.UpdateEventBotCallbackQuery {
|
||||
return event
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("callback query did not reach Bot API queue")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userStore := memory.NewUserStore()
|
||||
|
|
@ -49,7 +216,12 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
}, zaptest.NewLogger(t), clock.System)
|
||||
|
||||
chatID := -botAPIChannelChatIDBase - created.Channel.ID
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, nil, false, false, 0)
|
||||
replyKeyboard := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
}
|
||||
msg, err := r.BotAPISendMessage(ctx, bot.ID, chatID, "hello Group1 from bot api", nil, replyKeyboard, false, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("BotAPISendMessage: %v", err)
|
||||
}
|
||||
|
|
@ -67,7 +239,9 @@ func TestBotAPISendMessageToSupergroupChatID(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("GetHistory: %v", err)
|
||||
}
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body {
|
||||
if len(history.Messages) < 1 || history.Messages[0].SenderUserID != bot.ID || history.Messages[0].Body != msg.Body ||
|
||||
history.Messages[0].ReplyMarkup == nil || history.Messages[0].ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
history.Messages[0].ReplyMarkup.Keyboard[0][0].Text != "Help" {
|
||||
t.Fatalf("history messages = %+v, want bot channel message", history.Messages)
|
||||
}
|
||||
if pushed := sessions.pushedUserIDs(); !fanoutHasID(pushed, owner.ID) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const botAPIGetUpdatesLimit = 100
|
||||
const (
|
||||
botAPIGetUpdatesLimit = 100
|
||||
botAPIMaxNegativeOffset = 10000
|
||||
)
|
||||
|
||||
type botAPIChannelBotMemberProvider interface {
|
||||
ActiveBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error)
|
||||
|
|
@ -17,24 +22,43 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return nil, nil
|
||||
}
|
||||
fromID := int64(1)
|
||||
if offset > 0 {
|
||||
var items []domain.BotAPIUpdate
|
||||
if offset < 0 {
|
||||
if offset < -botAPIMaxNegativeOffset {
|
||||
return nil, errors.New("OFFSET_INVALID")
|
||||
}
|
||||
var err error
|
||||
items, err = r.deps.BotAPIUpdates.ListTailBotAPIUpdates(ctx, botID, int(-offset), botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > 0 && items[0].ID > 1 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, items[0].ID-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if offset > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, offset-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fromID = offset
|
||||
} else if confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err := r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if offset >= 0 {
|
||||
confirmed, found, err := r.deps.BotAPIUpdates.ConfirmedBotAPIUpdateID(ctx, botID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
fromID = confirmed + 1
|
||||
}
|
||||
items, err = r.deps.BotAPIUpdates.ListBotAPIUpdates(ctx, botID, fromID, botAPIGetUpdatesLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items)
|
||||
events, leadingSkipped := r.botAPIQueuedUpdateEvents(ctx, botID, items, r.clock.Now())
|
||||
if leadingSkipped > 0 {
|
||||
if err := r.deps.BotAPIUpdates.ConfirmBotAPIUpdates(ctx, botID, leadingSkipped); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -46,13 +70,16 @@ func (r *Router) botAPIQueuedUpdates(ctx context.Context, botID int64, offset in
|
|||
return r.enrichUpdateEvents(ctx, botID, events), nil
|
||||
}
|
||||
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate) ([]domain.UpdateEvent, int64) {
|
||||
func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, items []domain.BotAPIUpdate, now time.Time) ([]domain.UpdateEvent, int64) {
|
||||
privateIDs := make([]int, 0)
|
||||
privateSeen := make(map[int]struct{})
|
||||
channelIDs := make(map[int64][]int)
|
||||
channelSeen := make(map[int64]map[int]struct{})
|
||||
for _, item := range items {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item); !ok {
|
||||
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
|
||||
continue
|
||||
}
|
||||
if item.Callback != nil && item.Callback.InlineMessage != nil {
|
||||
continue
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
|
|
@ -79,7 +106,7 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
|
|||
events := make([]domain.UpdateEvent, 0, len(items))
|
||||
leadingSkipped := int64(0)
|
||||
for _, item := range items {
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages)
|
||||
event, ok := botAPIQueuedUpdateEventFromMessages(botID, item, privateMessages, channelMessages, now)
|
||||
if !ok {
|
||||
if len(events) == 0 {
|
||||
leadingSkipped = item.ID
|
||||
|
|
@ -101,7 +128,7 @@ func (r *Router) botAPIQueuedPrivateMessages(ctx context.Context, botID int64, i
|
|||
}
|
||||
out := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, msg := range list.Messages {
|
||||
if msg.ID <= 0 || msg.Out || !botAPIMessageProjectable(msg) {
|
||||
if msg.ID <= 0 || msg.OwnerUserID != botID {
|
||||
continue
|
||||
}
|
||||
out[msg.ID] = msg
|
||||
|
|
@ -127,10 +154,6 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
if msg.ID <= 0 || msg.Deleted || msg.Action != nil {
|
||||
continue
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
continue
|
||||
}
|
||||
byID[msg.ID] = msg
|
||||
}
|
||||
if len(byID) > 0 {
|
||||
|
|
@ -140,14 +163,37 @@ func (r *Router) botAPIQueuedChannelMessages(ctx context.Context, botID int64, i
|
|||
return out
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID || item.MessageID <= 0 {
|
||||
func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time) (domain.UpdateEventType, bool) {
|
||||
if item.ID <= 0 || item.BotUserID != botID {
|
||||
return "", false
|
||||
}
|
||||
eventType, ok := botAPIUpdateEventType(item.Kind)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
|
||||
return "", false
|
||||
}
|
||||
cb := item.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != botID || cb.UserID <= 0 ||
|
||||
cb.ChatInstance == 0 || len(cb.Data) > domain.MaxCallbackDataLen {
|
||||
return "", false
|
||||
}
|
||||
if cb.InlineMessage != nil {
|
||||
inline := cb.InlineMessage
|
||||
if item.MessageID != 0 || item.Peer != (domain.Peer{}) || cb.MessageID != 0 || cb.Peer != (domain.Peer{}) ||
|
||||
inline.DCID <= 0 || inline.OwnerID == 0 || inline.ID <= 0 || inline.AccessHash == 0 {
|
||||
return "", false
|
||||
}
|
||||
return eventType, true
|
||||
}
|
||||
if item.MessageID <= 0 || cb.Peer != item.Peer || cb.MessageID != item.MessageID {
|
||||
return "", false
|
||||
}
|
||||
} else if item.MessageID <= 0 {
|
||||
return "", false
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if item.Peer.ID <= 0 {
|
||||
|
|
@ -159,17 +205,48 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate) (domain.Updat
|
|||
return eventType, true
|
||||
}
|
||||
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item)
|
||||
func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, privateMessages map[int]domain.Message, channelMessages map[int64]map[int]domain.ChannelMessage, now time.Time) (domain.UpdateEvent, bool) {
|
||||
eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
|
||||
if !ok {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
inline := *item.Callback.InlineMessage
|
||||
callback.InlineMessage = &inline
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
switch item.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
msg, found := privateMessages[item.MessageID]
|
||||
if !found {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: msg,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if msg.Out || !botAPIMessageProjectable(msg) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
msg.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
|
|
@ -186,6 +263,23 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
|
|||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected := botAPIMessageFromChannel(botID, msg)
|
||||
if eventType == domain.UpdateEventBotCallbackQuery {
|
||||
callback := *item.Callback
|
||||
callback.Data = append([]byte(nil), item.Callback.Data...)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
Type: eventType,
|
||||
Pts: int(item.ID),
|
||||
PtsCount: 1,
|
||||
Date: item.Date,
|
||||
Peer: item.Peer,
|
||||
Message: projected,
|
||||
BotCallbackQuery: &callback,
|
||||
}, true
|
||||
}
|
||||
if projected.Out || !botAPIMessageProjectable(projected) {
|
||||
return domain.UpdateEvent{}, false
|
||||
}
|
||||
projected.Pts = int(item.ID)
|
||||
return domain.UpdateEvent{
|
||||
UserID: botID,
|
||||
|
|
@ -207,6 +301,8 @@ func botAPIUpdateEventType(kind domain.BotAPIUpdateKind) (domain.UpdateEventType
|
|||
return domain.UpdateEventNewMessage, true
|
||||
case domain.BotAPIUpdateEditedMessage:
|
||||
return domain.UpdateEventEditMessage, true
|
||||
case domain.BotAPIUpdateCallbackQuery:
|
||||
return domain.UpdateEventBotCallbackQuery, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -410,7 +506,56 @@ func botAPIMessageMediaProjectable(media *domain.MessageMedia) bool {
|
|||
return media.Photo != nil
|
||||
case domain.MessageMediaKindDocument:
|
||||
return media.Document != nil
|
||||
case domain.MessageMediaKindContact:
|
||||
return media.Contact != nil
|
||||
case domain.MessageMediaKindGeo:
|
||||
return media.Geo != nil
|
||||
case domain.MessageMediaKindVenue:
|
||||
return media.Venue != nil
|
||||
case domain.MessageMediaKindPoll:
|
||||
return media.Poll != nil
|
||||
case domain.MessageMediaKindGeoLive:
|
||||
return media.GeoLive != nil
|
||||
case domain.MessageMediaKindService:
|
||||
if media.ServiceAction == nil {
|
||||
return false
|
||||
}
|
||||
switch media.ServiceAction.Kind {
|
||||
case domain.MessageServiceActionWebViewDataSent:
|
||||
return media.ServiceAction.WebViewData != nil
|
||||
case domain.MessageServiceActionRequestedPeer:
|
||||
return botAPIRequestedPeerProjectable(media.ServiceAction.RequestedPeer)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func botAPIRequestedPeerProjectable(action *domain.MessageRequestedPeerAction) bool {
|
||||
if action == nil || action.ButtonID == 0 || len(action.Peers) == 0 || len(action.Peers) > domain.MaxBotRequestedPeerQuantity {
|
||||
return false
|
||||
}
|
||||
details := make(map[domain.Peer]struct{}, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
if detail.Peer.ID == 0 || (detail.Peer.Type != domain.PeerTypeUser && detail.Peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
details[detail.Peer] = struct{}{}
|
||||
}
|
||||
requiresDetails := action.NameRequested || action.UsernameRequested || action.PhotoRequested
|
||||
allUsers := true
|
||||
for _, peer := range action.Peers {
|
||||
if peer.ID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) {
|
||||
return false
|
||||
}
|
||||
if requiresDetails {
|
||||
if _, ok := details[peer]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
allUsers = allUsers && peer.Type == domain.PeerTypeUser
|
||||
}
|
||||
return allUsers || (len(action.Peers) == 1 && action.Peers[0].Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
|
|
|||
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
70
internal/rpc/botapi_update_queue_projection_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBotAPIMessageMediaProjectableReplyKeyboardResponses(t *testing.T) {
|
||||
validRequestedUsers := &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeUser, ID: 1002}},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
media *domain.MessageMedia
|
||||
want bool
|
||||
}{
|
||||
{"contact", &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{}}, true},
|
||||
{"geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &domain.MessageGeoPoint{}}, true},
|
||||
{"venue", &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{}}, true},
|
||||
{"poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: &domain.MessagePoll{}}, true},
|
||||
{"live geo", &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &domain.MessageGeoLive{}}, true},
|
||||
{"web app", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionWebViewDataSent, WebViewData: &domain.MessageWebViewDataAction{},
|
||||
}}, true},
|
||||
{"requested users", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: validRequestedUsers,
|
||||
}}, true},
|
||||
{"requested disclosure without snapshot", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}}, NameRequested: true,
|
||||
},
|
||||
}}, false},
|
||||
{"mixed requested peers", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer, RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 7, Peers: []domain.Peer{{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55}},
|
||||
},
|
||||
}}, false},
|
||||
{"unrelated service", &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionPhoneCall, Call: &domain.MessagePhoneCallAction{},
|
||||
}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := botAPIMessageMediaProjectable(tt.media); got != tt.want {
|
||||
t.Fatalf("projectable=%v want=%v media=%#v", got, tt.want, tt.media)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMessagePeerRefsIncludesRequestedPeers(t *testing.T) {
|
||||
users := map[int64]struct{}{}
|
||||
channels := map[int64]struct{}{}
|
||||
collectMessagePeerRefs(domain.Message{Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{ButtonID: 1, Peers: []domain.Peer{
|
||||
{Type: domain.PeerTypeUser, ID: 1001}, {Type: domain.PeerTypeChannel, ID: 55},
|
||||
}},
|
||||
},
|
||||
}}, 0, users, channels)
|
||||
if _, ok := users[1001]; !ok {
|
||||
t.Fatalf("requested user refs=%v", users)
|
||||
}
|
||||
if _, ok := channels[55]; !ok {
|
||||
t.Fatalf("requested channel refs=%v", channels)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -18,8 +20,13 @@ const botCallbackTimeout = 25 * time.Second
|
|||
func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") }
|
||||
func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery
|
||||
// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
type privateMessageByUIDService interface {
|
||||
GetMessageByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error)
|
||||
}
|
||||
|
||||
// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把同一 callback query
|
||||
// 同时投递到在线 MTProto bot session 与 Bot API update_id 队列,挂起等待 bot 的
|
||||
// setBotCallbackAnswer/answerCallbackQuery,或超时回 BOT_RESPONSE_TIMEOUT。
|
||||
func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) {
|
||||
userID, _, err := r.currentUserID(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -32,11 +39,6 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。
|
||||
if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
botUserID := peer.ID
|
||||
// game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西),
|
||||
// 不挂起、不推送(避免给 bot 投递无法处理的 game query)。
|
||||
if req.Game {
|
||||
|
|
@ -46,32 +48,64 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
if !hasData {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
if len(data) > domain.MaxCallbackDataLen {
|
||||
return nil, dataInvalidErr()
|
||||
}
|
||||
// 校验目标消息存在于请求者自己的盒、且对端正是该 bot。
|
||||
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
|
||||
return nil, messageIDInvalidErr()
|
||||
}
|
||||
msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID)
|
||||
callback, err := r.resolveBotCallbackQuery(ctx, userID, peer, req.MsgID, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
botUserID := callback.BotUserID
|
||||
|
||||
queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), botUserID, userID, botCallbackTimeout)
|
||||
if err != nil {
|
||||
r.log.Warn("register shared bot callback query", zap.Int64("bot_user_id", botUserID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !ok || msg.Peer != peer {
|
||||
return nil, messageIDInvalidErr()
|
||||
defer r.callbacks.deregisterContext(context.Background(), botUserID, queryID)
|
||||
callback.ID = queryID
|
||||
|
||||
// Bot API callback_query shares the dedicated durable update_id queue with message and
|
||||
// edited_message. The callback answer waiter itself remains ephemeral/process-local.
|
||||
if r.deps.BotAPIUpdates != nil {
|
||||
if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botUserID,
|
||||
Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: callback.Peer,
|
||||
MessageID: callback.MessageID,
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
Callback: &callback,
|
||||
}); err != nil {
|
||||
r.log.Warn("enqueue bot api callback query",
|
||||
zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
return nil, internalErr()
|
||||
} else if created {
|
||||
r.notifyBotAPIUpdate(botUserID)
|
||||
}
|
||||
}
|
||||
|
||||
queryID, pending := r.callbacks.register(botUserID, userID)
|
||||
defer r.callbacks.deregister(queryID)
|
||||
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给
|
||||
// bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。
|
||||
// MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。
|
||||
update := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID,
|
||||
UserID: userID,
|
||||
Peer: &tg.PeerUser{UserID: userID},
|
||||
MsgID: req.MsgID,
|
||||
ChatInstance: chatInstanceFor(botUserID, userID),
|
||||
// updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference);私聊 MessageID
|
||||
// 已翻译为 bot 视角 box id,channel 使用共享 message id。
|
||||
var update tg.UpdateClass
|
||||
if callback.InlineMessage != nil {
|
||||
inline := &tg.UpdateInlineBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID,
|
||||
MsgID: tgInputBotInlineMessageID(*callback.InlineMessage), ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
inline.SetData(data)
|
||||
update = inline
|
||||
} else {
|
||||
direct := &tg.UpdateBotCallbackQuery{
|
||||
QueryID: queryID, UserID: userID, Peer: tgPeer(callback.Peer),
|
||||
MsgID: callback.MessageID, ChatInstance: callback.ChatInstance,
|
||||
}
|
||||
direct.SetData(data)
|
||||
update = direct
|
||||
}
|
||||
update.SetData(data)
|
||||
r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{
|
||||
Updates: []tg.UpdateClass{update},
|
||||
Date: int(r.clock.Now().Unix()),
|
||||
|
|
@ -79,14 +113,149 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case ans := <-pending.ch:
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
case <-ticker.C:
|
||||
ans, found, err := r.callbacks.sharedAnswer(waitCtx, botUserID, queryID)
|
||||
if err != nil {
|
||||
r.log.Warn("read shared bot callback answer", zap.Int64("bot_user_id", botUserID), zap.Int64("query_id", queryID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return tgBotCallbackAnswer(ans), nil
|
||||
}
|
||||
case <-waitCtx.Done():
|
||||
return nil, botResponseTimeoutErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveBotCallbackQuery validates the clicked message and resolves the bot-visible message
|
||||
// identity. Inline-mode via_bot messages require updateInlineBotCallbackQuery + signed inline
|
||||
// ids and therefore remain an explicit blocked path instead of being misrouted here.
|
||||
func (r *Router) resolveBotCallbackQuery(ctx context.Context, userID int64, peer domain.Peer, msgID int, data []byte) (domain.BotCallbackQuery, error) {
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
msg, found, err := r.lookupOwnerMessage(ctx, userID, msgID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || msg.Peer != peer || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForPrivateMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceFor(msg.ViaBotID, userID), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.From.Type != domain.PeerTypeUser || msg.From.ID == 0 || !r.userIsBot(ctx, msg.From.ID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
provider, ok := r.deps.Messages.(privateMessageByUIDService)
|
||||
if !ok || msg.UID == 0 {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
botMessage, found, err := provider.GetMessageByUID(ctx, msg.From.ID, msg.UID)
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, internalErr()
|
||||
}
|
||||
if !found || botMessage.ID <= 0 || botMessage.OwnerUserID != msg.From.ID ||
|
||||
botMessage.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.From.ID,
|
||||
UserID: userID,
|
||||
Peer: botMessage.Peer,
|
||||
MessageID: botMessage.ID,
|
||||
ChatInstance: chatInstanceFor(msg.From.ID, userID),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil {
|
||||
return domain.BotCallbackQuery{}, peerIDInvalidErr()
|
||||
}
|
||||
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID})
|
||||
if err != nil {
|
||||
return domain.BotCallbackQuery{}, channelInvalidErr(err)
|
||||
}
|
||||
if len(history.Messages) != 1 {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
msg := history.Messages[0]
|
||||
if msg.ID != msgID || msg.Deleted || msg.ReplyMarkup == nil || msg.ReplyMarkup.Kind() != domain.MessageReplyMarkupInline || msg.ReplyMarkup.IsZero() {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
if !replyMarkupContainsCallbackData(msg.ReplyMarkup, data) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
if msg.ViaBotID != 0 {
|
||||
if !r.userIsBot(ctx, msg.ViaBotID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
inlineID, ok := r.inputInlineMessageIDForChannelMessage(msg.ViaBotID, msg).(*tg.InputBotInlineMessageID64)
|
||||
if !ok {
|
||||
return domain.BotCallbackQuery{}, messageIDInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.ViaBotID, UserID: userID,
|
||||
ChatInstance: chatInstanceForPeer(msg.ViaBotID, peer), Data: append([]byte(nil), data...),
|
||||
InlineMessage: domainInlineMessageID(inlineID),
|
||||
}, nil
|
||||
}
|
||||
if msg.SenderUserID == 0 || !r.userIsBot(ctx, msg.SenderUserID) {
|
||||
return domain.BotCallbackQuery{}, dataInvalidErr()
|
||||
}
|
||||
return domain.BotCallbackQuery{
|
||||
BotUserID: msg.SenderUserID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
MessageID: msg.ID,
|
||||
ChatInstance: chatInstanceForPeer(msg.SenderUserID, peer),
|
||||
Data: append([]byte(nil), data...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func domainInlineMessageID(id *tg.InputBotInlineMessageID64) *domain.BotInlineMessageID {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return &domain.BotInlineMessageID{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func tgInputBotInlineMessageID(id domain.BotInlineMessageID) tg.InputBotInlineMessageIDClass {
|
||||
return &tg.InputBotInlineMessageID64{DCID: id.DCID, OwnerID: id.OwnerID, ID: id.ID, AccessHash: id.AccessHash}
|
||||
}
|
||||
|
||||
func replyMarkupContainsCallbackData(markup *domain.MessageReplyMarkup, data []byte) bool {
|
||||
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||
return false
|
||||
}
|
||||
for _, row := range markup.Inline {
|
||||
for _, button := range row {
|
||||
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的
|
||||
// getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。
|
||||
func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) {
|
||||
|
|
@ -106,7 +275,9 @@ func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.Mes
|
|||
}
|
||||
// resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否
|
||||
// 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。
|
||||
r.callbacks.resolve(botID, req.QueryID, ans)
|
||||
if _, err := r.callbacks.resolveContext(ctx, botID, req.QueryID, ans); err != nil {
|
||||
return false, internalErr()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -689,12 +689,15 @@ func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg
|
|||
case *tg.InputKeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
out.NameRequested = b.NameRequested
|
||||
out.UsernameRequested = b.UsernameRequested
|
||||
out.PhotoRequested = b.PhotoRequested
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
out.ButtonID = b.ButtonID
|
||||
out.Text = strings.TrimSpace(b.Text)
|
||||
out.PeerType = requestPeerTypeName(b.PeerType)
|
||||
out.PeerType, out.PeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
out.MaxQuantity = b.MaxQuantity
|
||||
default:
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
|
|
@ -722,7 +725,7 @@ func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.Key
|
|||
return &tg.KeyboardButtonRequestPeer{
|
||||
Text: button.Text,
|
||||
ButtonID: button.ButtonID,
|
||||
PeerType: tgRequestPeerType(button.PeerType),
|
||||
PeerType: tgRequestPeerTypeWithFilter(button.PeerType, button.PeerFilter),
|
||||
MaxQuantity: button.MaxQuantity,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,29 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer
|
||||
// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经
|
||||
// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道
|
||||
// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。
|
||||
// callbackRegistry keeps local waiter channels and mirrors ownership/answers to
|
||||
// a short-lived shared store. The shared CAS is the source of truth when wired:
|
||||
// it lets getBotCallbackAnswer and answerCallbackQuery land on different nodes
|
||||
// without accepting two answers or trusting a process-local owner map.
|
||||
type callbackRegistry struct {
|
||||
mu sync.Mutex
|
||||
pending map[int64]*pendingCallback
|
||||
shared store.BotCallbackRegistryStore
|
||||
}
|
||||
|
||||
type pendingCallback struct {
|
||||
|
|
@ -26,35 +33,77 @@ type pendingCallback struct {
|
|||
userID int64
|
||||
}
|
||||
|
||||
func newCallbackRegistry() *callbackRegistry {
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback)}
|
||||
func newCallbackRegistry(shared ...store.BotCallbackRegistryStore) *callbackRegistry {
|
||||
var sharedStore store.BotCallbackRegistryStore
|
||||
if len(shared) > 0 {
|
||||
sharedStore = shared[0]
|
||||
}
|
||||
return &callbackRegistry{pending: make(map[int64]*pendingCallback), shared: sharedStore}
|
||||
}
|
||||
|
||||
// register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须
|
||||
// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。
|
||||
func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var queryID int64
|
||||
for {
|
||||
queryID = randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; !exists {
|
||||
break
|
||||
queryID, pending, _ := c.registerContext(context.Background(), time.Now(), botUserID, userID, botCallbackTimeout)
|
||||
return queryID, pending
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) registerContext(ctx context.Context, now time.Time, botUserID, userID int64, ttl time.Duration) (int64, *pendingCallback, error) {
|
||||
for attempts := 0; attempts < 32; attempts++ {
|
||||
p := &pendingCallback{
|
||||
ch: make(chan domain.BotCallbackAnswer, 1),
|
||||
done: make(chan struct{}),
|
||||
botUserID: botUserID,
|
||||
userID: userID,
|
||||
}
|
||||
c.mu.Lock()
|
||||
queryID := randomNonZeroInt64()
|
||||
if _, exists := c.pending[queryID]; exists {
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
c.mu.Unlock()
|
||||
if c.shared == nil {
|
||||
return queryID, p, nil
|
||||
}
|
||||
created, err := c.shared.PutBotCallbackPending(ctx, store.BotCallbackPending{
|
||||
QueryID: queryID, BotUserID: botUserID, UserID: userID, CreatedAt: now,
|
||||
}, ttl)
|
||||
if err != nil {
|
||||
c.removeLocal(queryID)
|
||||
return 0, nil, err
|
||||
}
|
||||
if created {
|
||||
return queryID, p, nil
|
||||
}
|
||||
c.removeLocal(queryID)
|
||||
}
|
||||
c.pending[queryID] = p
|
||||
return queryID, p
|
||||
return 0, nil, fmt.Errorf("allocate bot callback query id")
|
||||
}
|
||||
|
||||
// deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在
|
||||
// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。
|
||||
func (c *callbackRegistry) deregister(queryID int64) {
|
||||
c.deregisterContext(context.Background(), 0, queryID)
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deregisterContext(ctx context.Context, botUserID, queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
if botUserID == 0 {
|
||||
botUserID = p.botUserID
|
||||
}
|
||||
delete(c.pending, queryID)
|
||||
close(p.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if c.shared != nil && botUserID > 0 {
|
||||
_ = c.shared.DeleteBotCallbackPending(ctx, botUserID, queryID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) removeLocal(queryID int64) {
|
||||
c.mu.Lock()
|
||||
if p, ok := c.pending[queryID]; ok {
|
||||
delete(c.pending, queryID)
|
||||
|
|
@ -73,6 +122,23 @@ func (c *callbackRegistry) size() int {
|
|||
// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID
|
||||
// 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。
|
||||
func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
resolved, _ := c.resolveContext(context.Background(), callerBotID, queryID, ans)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) resolveContext(ctx context.Context, callerBotID, queryID int64, ans domain.BotCallbackAnswer) (bool, error) {
|
||||
if c.shared != nil {
|
||||
resolved, err := c.shared.ResolveBotCallback(ctx, callerBotID, queryID, ans)
|
||||
if err != nil || !resolved {
|
||||
return resolved, err
|
||||
}
|
||||
c.deliver(callerBotID, queryID, ans)
|
||||
return true, nil
|
||||
}
|
||||
return c.deliver(callerBotID, queryID, ans), nil
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) deliver(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool {
|
||||
c.mu.Lock()
|
||||
p, ok := c.pending[queryID]
|
||||
if !ok || p.botUserID != callerBotID {
|
||||
|
|
@ -89,6 +155,37 @@ func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCal
|
|||
return true
|
||||
}
|
||||
|
||||
func (c *callbackRegistry) sharedAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
|
||||
if c.shared == nil {
|
||||
return domain.BotCallbackAnswer{}, false, nil
|
||||
}
|
||||
return c.shared.GetBotCallbackAnswer(ctx, botUserID, queryID)
|
||||
}
|
||||
|
||||
func (r *Router) RunBotCallbackAnswerSubscriber(ctx context.Context) {
|
||||
if r == nil || r.callbacks == nil || r.callbacks.shared == nil {
|
||||
return
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
err := r.callbacks.shared.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
|
||||
r.callbacks.deliver(push.BotUserID, push.QueryID, push.Answer)
|
||||
})
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil && r.log != nil {
|
||||
r.log.Warn("bot callback answer subscriber disconnected", zap.Error(err))
|
||||
}
|
||||
timer := time.NewTimer(time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止
|
||||
// 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的
|
||||
// 撞键复核会再保证唯一性),绝不卡住整个 registry。
|
||||
|
|
@ -125,3 +222,24 @@ func chatInstanceFor(botUserID, userID int64) int64 {
|
|||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// chatInstanceForPeer extends the stable hash to non-private chats without allowing a
|
||||
// channel id to collide with a numerically equal private user id.
|
||||
func chatInstanceForPeer(botUserID int64, peer domain.Peer) int64 {
|
||||
h := fnv.New64a()
|
||||
var buf [17]byte
|
||||
binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID))
|
||||
binary.LittleEndian.PutUint64(buf[8:16], uint64(peer.ID))
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeChannel:
|
||||
buf[16] = 2
|
||||
default:
|
||||
buf[16] = 1
|
||||
}
|
||||
_, _ = h.Write(buf[:])
|
||||
v := int64(h.Sum64())
|
||||
if v == 0 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -800,21 +800,23 @@ func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage
|
|||
|
||||
func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights {
|
||||
return tg.ChatAdminRights{
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages,
|
||||
DeleteMessages: rights.DeleteMessages,
|
||||
PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories,
|
||||
DeleteStories: rights.DeleteStories,
|
||||
BanUsers: rights.BanUsers,
|
||||
InviteUsers: rights.InviteUsers,
|
||||
PinMessages: rights.PinMessages,
|
||||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
Other: true,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ChangeInfo: rights.ChangeInfo,
|
||||
PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages,
|
||||
DeleteMessages: rights.DeleteMessages,
|
||||
PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories,
|
||||
DeleteStories: rights.DeleteStories,
|
||||
BanUsers: rights.BanUsers,
|
||||
InviteUsers: rights.InviteUsers,
|
||||
PinMessages: rights.PinMessages,
|
||||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
Other: true,
|
||||
ManageTopics: rights.ManageTopics,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ManageLinkedPeers: rights.ManageLinkedPeers,
|
||||
// manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum,
|
||||
// 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
|
|
@ -840,7 +842,10 @@ func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRigh
|
|||
AddAdmins: rights.AddAdmins,
|
||||
Anonymous: rights.Anonymous,
|
||||
ManageCall: rights.ManageCall,
|
||||
ManageChat: rights.Other,
|
||||
ManageTopics: rights.ManageTopics,
|
||||
ManageRanks: rights.ManageRanks,
|
||||
ManageLinkedPeers: rights.ManageLinkedPeers,
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
|
@ -9,9 +10,30 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// validateReplyMarkupForPeer enforces the Bot API/TL boundary that reply keyboards control
|
||||
// a chat input field and are not supported in broadcast channels. Inline keyboards remain
|
||||
// valid in both megagroups and broadcasts.
|
||||
func (r *Router) validateReplyMarkupForPeer(ctx context.Context, userID int64, peer domain.Peer, markup *domain.MessageReplyMarkup) error {
|
||||
if markup == nil || !markup.IsReplyKeyboardFamily() || peer.Type != domain.PeerTypeChannel {
|
||||
return nil
|
||||
}
|
||||
if r == nil || r.deps.Channels == nil {
|
||||
return channelInvalidErr(domain.ErrChannelInvalid)
|
||||
}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return channelInvalidErr(err)
|
||||
}
|
||||
if view.Channel.Broadcast && !view.Channel.Megagroup {
|
||||
return replyMarkupInvalidErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// P3 reply_markup 错误码(对齐官方)。
|
||||
func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") }
|
||||
func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") }
|
||||
func buttonTypeInvalidErr() error { return tgerr.New(400, "BUTTON_TYPE_INVALID") }
|
||||
func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") }
|
||||
|
||||
// replyMarkupErr 把 domain 校验错误映射为客户端错误码。
|
||||
|
|
@ -21,18 +43,21 @@ func replyMarkupErr(err error) error {
|
|||
return buttonDataInvalidErr()
|
||||
case errors.Is(err, domain.ErrButtonURLInvalid):
|
||||
return buttonURLInvalidErr()
|
||||
case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid):
|
||||
case errors.Is(err, domain.ErrButtonTypeInvalid):
|
||||
return buttonTypeInvalidErr()
|
||||
case errors.Is(err, domain.ErrButtonInvalid):
|
||||
return buttonInvalidErr()
|
||||
default:
|
||||
return replyMarkupInvalidErr()
|
||||
}
|
||||
}
|
||||
|
||||
// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义:
|
||||
// domainReplyMarkupForSender 解析只能携带 inline keyboard 的入站 reply_markup(inline
|
||||
// result / edit 路径)。普通消息发送使用 domainOutgoingReplyMarkupForSender。
|
||||
// 语义:
|
||||
// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil,不报错——对齐
|
||||
// 官方「普通用户 markup 无效」,I1)。
|
||||
// - 仅 ReplyInlineMarkup 被处理;reply keyboard 家族(自定义键盘/隐藏/强制回复)
|
||||
// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4)。
|
||||
// - 仅 ReplyInlineMarkup 被处理;bot 的 reply keyboard 家族在这些上下文中显式拒绝。
|
||||
// - inline 行内按钮仅 callback / url;其它按钮类型(webview/game/url_auth/
|
||||
// request_* 等)→ ErrButtonTypeInvalid(拒绝整条发送,绝不半实现下发)。
|
||||
// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。
|
||||
|
|
@ -42,8 +67,7 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
|
|||
}
|
||||
inline, ok := markup.(*tg.ReplyInlineMarkup)
|
||||
if !ok {
|
||||
// reply keyboard / hide / force-reply:P3 不支持,丢弃。
|
||||
return nil, nil
|
||||
return nil, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
parsed, err := domainInlineMarkup(inline)
|
||||
if err != nil {
|
||||
|
|
@ -58,8 +82,99 @@ func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*
|
|||
return parsed, nil
|
||||
}
|
||||
|
||||
// domainOutgoingReplyMarkupForSender 解析普通 sendMessage/sendMedia 的完整 reply markup。
|
||||
// 非 bot 携带 markup 仍按官方权限边界静默丢弃;bot 的未知/未实现按钮则拒绝整条消息,
|
||||
// 避免客户端看到一个被服务端悄悄改形的键盘。
|
||||
func domainOutgoingReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) {
|
||||
if markup == nil || !senderIsBot {
|
||||
return nil, nil
|
||||
}
|
||||
switch v := markup.(type) {
|
||||
case *tg.ReplyInlineMarkup:
|
||||
return domainReplyMarkupForSender(v, true)
|
||||
case *tg.ReplyKeyboardMarkup:
|
||||
out := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: make([][]domain.MarkupButton, 0, len(v.Rows)),
|
||||
Resize: v.Resize,
|
||||
SingleUse: v.SingleUse,
|
||||
Selective: v.Selective,
|
||||
Persistent: v.Persistent,
|
||||
Placeholder: v.Placeholder,
|
||||
}
|
||||
for _, row := range v.Rows {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
|
||||
for _, button := range row.Buttons {
|
||||
parsed, err := domainReplyKeyboardButton(button)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRow = append(domainRow, parsed)
|
||||
}
|
||||
out.Keyboard = append(out.Keyboard, domainRow)
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
case *tg.ReplyKeyboardHide:
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupHide, Selective: v.Selective}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
case *tg.ReplyKeyboardForceReply:
|
||||
out := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupForceReply,
|
||||
SingleUse: v.SingleUse,
|
||||
Selective: v.Selective,
|
||||
Placeholder: v.Placeholder,
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func domainReplyKeyboardButton(button tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(button)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
}
|
||||
base := domain.MarkupButton{Style: style, IconCustomEmojiID: icon}
|
||||
switch b := button.(type) {
|
||||
case *tg.KeyboardButton:
|
||||
base.Type, base.Text = domain.MarkupButtonText, b.Text
|
||||
case *tg.KeyboardButtonRequestPhone:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPhone, b.Text
|
||||
case *tg.KeyboardButtonRequestGeoLocation:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestLocation, b.Text
|
||||
case *tg.KeyboardButtonRequestPoll:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPoll, b.Text
|
||||
if quiz, ok := b.GetQuiz(); ok {
|
||||
if quiz {
|
||||
base.PollType = "quiz"
|
||||
} else {
|
||||
base.PollType = "regular"
|
||||
}
|
||||
}
|
||||
case *tg.KeyboardButtonRequestPeer:
|
||||
base.Type, base.Text = domain.MarkupButtonRequestPeer, b.Text
|
||||
base.ButtonID, base.MaxQuantity = b.ButtonID, b.MaxQuantity
|
||||
base.RequestPeerType, base.RequestPeerFilter = domainRequestPeerFilter(b.PeerType)
|
||||
case *tg.KeyboardButtonSimpleWebView:
|
||||
base.Type, base.Text, base.URL = domain.MarkupButtonSimpleWebView, b.Text, b.URL
|
||||
default:
|
||||
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) {
|
||||
out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
|
||||
out := &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupInline, Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))}
|
||||
for _, row := range inline.Rows {
|
||||
domainRow := make([]domain.MarkupButton, 0, len(row.Buttons))
|
||||
for _, btn := range row.Buttons {
|
||||
|
|
@ -75,31 +190,119 @@ func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarku
|
|||
}
|
||||
|
||||
func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) {
|
||||
style, icon, err := domainMarkupButtonStyle(btn)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, err
|
||||
}
|
||||
switch b := btn.(type) {
|
||||
case *tg.KeyboardButtonCallback:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonCallback,
|
||||
Text: b.Text,
|
||||
Data: append([]byte(nil), b.Data...),
|
||||
RequiresPassword: b.RequiresPassword,
|
||||
Type: domain.MarkupButtonCallback,
|
||||
Text: b.Text,
|
||||
Style: style,
|
||||
IconCustomEmojiID: icon,
|
||||
Data: append([]byte(nil), b.Data...),
|
||||
RequiresPassword: b.RequiresPassword,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonURL:
|
||||
return domain.MarkupButton{
|
||||
Type: domain.MarkupButtonURL,
|
||||
Text: b.Text,
|
||||
URL: b.URL,
|
||||
Type: domain.MarkupButtonURL, Text: b.Text, URL: b.URL,
|
||||
Style: style, IconCustomEmojiID: icon,
|
||||
}, nil
|
||||
case *tg.KeyboardButtonWebView:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonWebView, Text: b.Text, URL: b.URL, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonSwitchInline:
|
||||
peerTypes, err := preparedInlinePeerTypesFromTG(b.PeerTypes)
|
||||
if err != nil {
|
||||
return domain.MarkupButton{}, domain.ErrButtonInvalid
|
||||
}
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonSwitchInline, Text: b.Text, Query: b.Query, SamePeer: b.SamePeer, PeerTypes: peerTypes, Style: style, IconCustomEmojiID: icon}, nil
|
||||
case *tg.KeyboardButtonCopy:
|
||||
return domain.MarkupButton{Type: domain.MarkupButtonCopy, Text: b.Text, CopyText: b.CopyText, Style: style, IconCustomEmojiID: icon}, nil
|
||||
default:
|
||||
// webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。
|
||||
return domain.MarkupButton{}, domain.ErrButtonTypeInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。
|
||||
func domainMarkupButtonStyle(btn tg.KeyboardButtonClass) (domain.MarkupButtonStyle, int64, error) {
|
||||
style, ok := btn.GetStyle()
|
||||
if !ok {
|
||||
return "", 0, nil
|
||||
}
|
||||
colors := 0
|
||||
var out domain.MarkupButtonStyle
|
||||
if style.GetBgPrimary() {
|
||||
colors++
|
||||
out = domain.MarkupButtonStylePrimary
|
||||
}
|
||||
if style.GetBgDanger() {
|
||||
colors++
|
||||
out = domain.MarkupButtonStyleDanger
|
||||
}
|
||||
if style.GetBgSuccess() {
|
||||
colors++
|
||||
out = domain.MarkupButtonStyleSuccess
|
||||
}
|
||||
icon, hasIcon := style.GetIcon()
|
||||
if colors > 1 || (hasIcon && icon <= 0) || (colors == 0 && !hasIcon) {
|
||||
return "", 0, domain.ErrButtonInvalid
|
||||
}
|
||||
return out, icon, nil
|
||||
}
|
||||
|
||||
func tgMarkupButtonStyle(btn domain.MarkupButton) (tg.KeyboardButtonStyle, bool) {
|
||||
var out tg.KeyboardButtonStyle
|
||||
switch btn.Style {
|
||||
case domain.MarkupButtonStylePrimary:
|
||||
out.SetBgPrimary(true)
|
||||
case domain.MarkupButtonStyleDanger:
|
||||
out.SetBgDanger(true)
|
||||
case domain.MarkupButtonStyleSuccess:
|
||||
out.SetBgSuccess(true)
|
||||
}
|
||||
if btn.IconCustomEmojiID > 0 {
|
||||
out.SetIcon(btn.IconCustomEmojiID)
|
||||
}
|
||||
return out, btn.Style != "" || btn.IconCustomEmojiID > 0
|
||||
}
|
||||
|
||||
// tgReplyMarkup 把存储的协议中立快照还原为对应 ReplyMarkup constructor。
|
||||
func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
|
||||
if m.IsZero() {
|
||||
return nil
|
||||
}
|
||||
switch m.Kind() {
|
||||
case domain.MessageReplyMarkupKeyboard:
|
||||
rows := make([]tg.KeyboardButtonRow, 0, len(m.Keyboard))
|
||||
for _, row := range m.Keyboard {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
|
||||
for _, btn := range row {
|
||||
buttons = append(buttons, tgReplyKeyboardButton(btn))
|
||||
}
|
||||
rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons})
|
||||
}
|
||||
return &tg.ReplyKeyboardMarkup{
|
||||
Resize: m.Resize,
|
||||
SingleUse: m.SingleUse,
|
||||
Selective: m.Selective,
|
||||
Persistent: m.Persistent,
|
||||
Rows: rows,
|
||||
Placeholder: m.Placeholder,
|
||||
}
|
||||
case domain.MessageReplyMarkupHide:
|
||||
return &tg.ReplyKeyboardHide{Selective: m.Selective}
|
||||
case domain.MessageReplyMarkupForceReply:
|
||||
return &tg.ReplyKeyboardForceReply{
|
||||
SingleUse: m.SingleUse,
|
||||
Selective: m.Selective,
|
||||
Placeholder: m.Placeholder,
|
||||
}
|
||||
case domain.MessageReplyMarkupInline:
|
||||
// Continue below.
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline))
|
||||
for _, row := range m.Inline {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0, len(row))
|
||||
|
|
@ -114,12 +317,202 @@ func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass {
|
|||
func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
||||
switch btn.Type {
|
||||
case domain.MarkupButtonURL:
|
||||
return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
|
||||
out := &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonWebView:
|
||||
out := &tg.KeyboardButtonWebView{Text: btn.Text, URL: btn.URL}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonSwitchInline:
|
||||
out := &tg.KeyboardButtonSwitchInline{Text: btn.Text, Query: btn.Query, SamePeer: btn.SamePeer}
|
||||
if len(btn.PeerTypes) > 0 {
|
||||
out.SetPeerTypes(tgPreparedInlinePeerTypes(btn.PeerTypes))
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
case domain.MarkupButtonCopy:
|
||||
out := &tg.KeyboardButtonCopy{Text: btn.Text, CopyText: btn.CopyText}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
default: // callback
|
||||
out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data}
|
||||
if btn.RequiresPassword {
|
||||
out.SetRequiresPassword(true)
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
out.SetStyle(style)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func tgReplyKeyboardButton(btn domain.MarkupButton) tg.KeyboardButtonClass {
|
||||
var out tg.KeyboardButtonClass
|
||||
switch btn.Type {
|
||||
case domain.MarkupButtonRequestPhone:
|
||||
out = &tg.KeyboardButtonRequestPhone{Text: btn.Text}
|
||||
case domain.MarkupButtonRequestLocation:
|
||||
out = &tg.KeyboardButtonRequestGeoLocation{Text: btn.Text}
|
||||
case domain.MarkupButtonRequestPoll:
|
||||
button := &tg.KeyboardButtonRequestPoll{Text: btn.Text}
|
||||
if btn.PollType == "quiz" {
|
||||
button.SetQuiz(true)
|
||||
} else if btn.PollType == "regular" {
|
||||
button.SetQuiz(false)
|
||||
}
|
||||
out = button
|
||||
case domain.MarkupButtonRequestPeer:
|
||||
out = &tg.KeyboardButtonRequestPeer{Text: btn.Text, ButtonID: btn.ButtonID, PeerType: tgRequestPeerTypeWithFilter(btn.RequestPeerType, btn.RequestPeerFilter), MaxQuantity: btn.MaxQuantity}
|
||||
case domain.MarkupButtonSimpleWebView:
|
||||
out = &tg.KeyboardButtonSimpleWebView{Text: btn.Text, URL: btn.URL}
|
||||
default:
|
||||
out = &tg.KeyboardButton{Text: btn.Text}
|
||||
}
|
||||
if style, ok := tgMarkupButtonStyle(btn); ok {
|
||||
if setter, ok := out.(interface{ SetStyle(tg.KeyboardButtonStyle) }); ok {
|
||||
setter.SetStyle(style)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func domainRequestPeerFilter(peerType tg.RequestPeerTypeClass) (string, *domain.BotRequestPeerFilter) {
|
||||
filter := &domain.BotRequestPeerFilter{}
|
||||
switch v := peerType.(type) {
|
||||
case *tg.RequestPeerTypeUser:
|
||||
if value, ok := v.GetBot(); ok {
|
||||
filter.UserIsBotSet, filter.UserIsBot = true, value
|
||||
}
|
||||
if value, ok := v.GetPremium(); ok {
|
||||
filter.UserIsPremiumSet, filter.UserIsPremium = true, value
|
||||
}
|
||||
if !filter.UserIsBotSet && !filter.UserIsPremiumSet {
|
||||
return "user", nil
|
||||
}
|
||||
return "user", filter
|
||||
case *tg.RequestPeerTypeChat:
|
||||
filter.ChatIsCreated, filter.BotIsMember = v.Creator, v.BotParticipant
|
||||
if value, ok := v.GetHasUsername(); ok {
|
||||
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
|
||||
}
|
||||
if value, ok := v.GetForum(); ok {
|
||||
filter.ChatIsForumSet, filter.ChatIsForum = true, value
|
||||
}
|
||||
if rights, ok := v.GetUserAdminRights(); ok {
|
||||
mapped := domainBotRequestAdminRights(rights)
|
||||
filter.UserAdminRights = &mapped
|
||||
}
|
||||
if rights, ok := v.GetBotAdminRights(); ok {
|
||||
mapped := domainBotRequestAdminRights(rights)
|
||||
filter.BotAdminRights = &mapped
|
||||
}
|
||||
if botRequestPeerFilterZero(filter) {
|
||||
return "chat", nil
|
||||
}
|
||||
return "chat", filter
|
||||
case *tg.RequestPeerTypeBroadcast:
|
||||
filter.ChatIsCreated = v.Creator
|
||||
if value, ok := v.GetHasUsername(); ok {
|
||||
filter.ChatHasUsernameSet, filter.ChatHasUsername = true, value
|
||||
}
|
||||
if rights, ok := v.GetUserAdminRights(); ok {
|
||||
mapped := domainBotRequestAdminRights(rights)
|
||||
filter.UserAdminRights = &mapped
|
||||
}
|
||||
if rights, ok := v.GetBotAdminRights(); ok {
|
||||
mapped := domainBotRequestAdminRights(rights)
|
||||
filter.BotAdminRights = &mapped
|
||||
}
|
||||
if botRequestPeerFilterZero(filter) {
|
||||
return "broadcast", nil
|
||||
}
|
||||
return "broadcast", filter
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func botRequestPeerFilterZero(filter *domain.BotRequestPeerFilter) bool {
|
||||
return filter == nil || (!filter.UserIsBotSet && !filter.UserIsPremiumSet && !filter.ChatHasUsernameSet &&
|
||||
!filter.ChatIsForumSet && !filter.ChatIsCreated && !filter.BotIsMember && filter.UserAdminRights == nil && filter.BotAdminRights == nil)
|
||||
}
|
||||
|
||||
func tgRequestPeerTypeWithFilter(kind string, filter *domain.BotRequestPeerFilter) tg.RequestPeerTypeClass {
|
||||
switch kind {
|
||||
case "chat":
|
||||
out := &tg.RequestPeerTypeChat{}
|
||||
if filter != nil {
|
||||
out.Creator, out.BotParticipant = filter.ChatIsCreated, filter.BotIsMember
|
||||
if filter.ChatHasUsernameSet {
|
||||
out.SetHasUsername(filter.ChatHasUsername)
|
||||
}
|
||||
if filter.ChatIsForumSet {
|
||||
out.SetForum(filter.ChatIsForum)
|
||||
}
|
||||
if filter.UserAdminRights != nil {
|
||||
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
|
||||
}
|
||||
if filter.BotAdminRights != nil {
|
||||
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case "broadcast":
|
||||
out := &tg.RequestPeerTypeBroadcast{}
|
||||
if filter != nil {
|
||||
out.Creator = filter.ChatIsCreated
|
||||
if filter.ChatHasUsernameSet {
|
||||
out.SetHasUsername(filter.ChatHasUsername)
|
||||
}
|
||||
if filter.UserAdminRights != nil {
|
||||
out.SetUserAdminRights(tgBotRequestAdminRights(*filter.UserAdminRights))
|
||||
}
|
||||
if filter.BotAdminRights != nil {
|
||||
out.SetBotAdminRights(tgBotRequestAdminRights(*filter.BotAdminRights))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
out := &tg.RequestPeerTypeUser{}
|
||||
if filter != nil {
|
||||
if filter.UserIsBotSet {
|
||||
out.SetBot(filter.UserIsBot)
|
||||
}
|
||||
if filter.UserIsPremiumSet {
|
||||
out.SetPremium(filter.UserIsPremium)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func domainBotRequestAdminRights(rights tg.ChatAdminRights) domain.BotRequestAdminRights {
|
||||
return domain.BotRequestAdminRights{
|
||||
Anonymous: rights.Anonymous, ManageChat: rights.Other, DeleteMessages: rights.DeleteMessages,
|
||||
ManageVideoChats: rights.ManageCall, RestrictMembers: rights.BanUsers, PromoteMembers: rights.AddAdmins,
|
||||
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
}
|
||||
}
|
||||
|
||||
func tgBotRequestAdminRights(rights domain.BotRequestAdminRights) tg.ChatAdminRights {
|
||||
return tg.ChatAdminRights{
|
||||
Anonymous: rights.Anonymous, Other: rights.ManageChat, DeleteMessages: rights.DeleteMessages,
|
||||
ManageCall: rights.ManageVideoChats, BanUsers: rights.RestrictMembers, AddAdmins: rights.PromoteMembers,
|
||||
ChangeInfo: rights.ChangeInfo, InviteUsers: rights.InviteUsers, PostStories: rights.PostStories,
|
||||
EditStories: rights.EditStories, DeleteStories: rights.DeleteStories, PostMessages: rights.PostMessages,
|
||||
EditMessages: rights.EditMessages, PinMessages: rights.PinMessages, ManageTopics: rights.ManageTopics,
|
||||
ManageDirectMessages: rights.ManageDirectMessages,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
internal/rpc/convert_markup_test.go
Normal file
171
internal/rpc/convert_markup_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestReplyKeyboardTLDomainRoundTrip(t *testing.T) {
|
||||
in := &tg.ReplyKeyboardMarkup{
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Selective: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose",
|
||||
Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButton{Text: "Help"},
|
||||
func() *tg.KeyboardButton {
|
||||
button := &tg.KeyboardButton{Text: "Status"}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgPrimary(true)
|
||||
style.SetIcon(123456)
|
||||
button.SetStyle(style)
|
||||
return button
|
||||
}(),
|
||||
}}},
|
||||
}
|
||||
got, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
if err != nil {
|
||||
t.Fatalf("domainOutgoingReplyMarkupForSender: %v", err)
|
||||
}
|
||||
if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 ||
|
||||
len(got.Keyboard[0]) != 2 || got.Keyboard[0][0].Text != "Help" || !got.Resize ||
|
||||
!got.SingleUse || !got.Selective || !got.Persistent || got.Placeholder != "Choose" {
|
||||
t.Fatalf("domain markup = %#v", got)
|
||||
}
|
||||
if got.Keyboard[0][1].Style != domain.MarkupButtonStylePrimary || got.Keyboard[0][1].IconCustomEmojiID != 123456 {
|
||||
t.Fatalf("second button decoration = %#v", got.Keyboard[0][1])
|
||||
}
|
||||
wire, ok := tgReplyMarkup(got).(*tg.ReplyKeyboardMarkup)
|
||||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 2 {
|
||||
t.Fatalf("wire markup = %#v", wire)
|
||||
}
|
||||
if button, ok := wire.Rows[0].Buttons[1].(*tg.KeyboardButton); !ok || button.Text != "Status" {
|
||||
t.Fatalf("second button = %#v", wire.Rows[0].Buttons[1])
|
||||
} else if style, ok := button.GetStyle(); !ok || !style.GetBgPrimary() || style.Icon != 123456 {
|
||||
t.Fatalf("second button style = %#v ok=%v", style, ok)
|
||||
}
|
||||
if !wire.Resize || !wire.SingleUse || !wire.Selective || !wire.Persistent || wire.Placeholder != "Choose" {
|
||||
t.Fatalf("wire flags = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineButtonStyleTLDomainRoundTrip(t *testing.T) {
|
||||
button := &tg.KeyboardButtonCallback{Text: "Delete", Data: []byte("delete")}
|
||||
style := tg.KeyboardButtonStyle{}
|
||||
style.SetBgDanger(true)
|
||||
button.SetStyle(style)
|
||||
got, err := domainReplyMarkupForSender(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{button}}}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("domainReplyMarkupForSender: %v", err)
|
||||
}
|
||||
if got.Inline[0][0].Style != domain.MarkupButtonStyleDanger {
|
||||
t.Fatalf("domain style = %#v", got.Inline[0][0])
|
||||
}
|
||||
wire := tgReplyMarkup(got).(*tg.ReplyInlineMarkup).Rows[0].Buttons[0].(*tg.KeyboardButtonCallback)
|
||||
if roundTrip, ok := wire.GetStyle(); !ok || !roundTrip.GetBgDanger() {
|
||||
t.Fatalf("wire style = %#v ok=%v", roundTrip, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardHideAndForceReplyTLDomainRoundTrip(t *testing.T) {
|
||||
hide, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardHide{Selective: true}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("hide parse: %v", err)
|
||||
}
|
||||
if wire, ok := tgReplyMarkup(hide).(*tg.ReplyKeyboardHide); !ok || !wire.Selective {
|
||||
t.Fatalf("hide wire = %#v", wire)
|
||||
}
|
||||
force, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardForceReply{
|
||||
SingleUse: true, Selective: true, Placeholder: "Answer",
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("force parse: %v", err)
|
||||
}
|
||||
if wire, ok := tgReplyMarkup(force).(*tg.ReplyKeyboardForceReply); !ok || !wire.SingleUse || !wire.Selective || wire.Placeholder != "Answer" {
|
||||
t.Fatalf("force wire = %#v", wire)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardRequestPhoneTLDomainRoundTrip(t *testing.T) {
|
||||
markup, err := domainOutgoingReplyMarkupForSender(&tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{
|
||||
Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonRequestPhone{Text: "Share phone"}},
|
||||
}}}, true)
|
||||
if err != nil || markup == nil || len(markup.Keyboard) != 1 || len(markup.Keyboard[0]) != 1 ||
|
||||
markup.Keyboard[0][0].Type != domain.MarkupButtonRequestPhone {
|
||||
t.Fatalf("request_phone markup = %#v err=%v", markup, err)
|
||||
}
|
||||
wire, ok := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
|
||||
if !ok || len(wire.Rows) != 1 || len(wire.Rows[0].Buttons) != 1 {
|
||||
t.Fatalf("request_phone wire = %#v", wire)
|
||||
}
|
||||
if _, ok := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPhone); !ok {
|
||||
t.Fatalf("request_phone button = %#v", wire.Rows[0].Buttons[0])
|
||||
}
|
||||
if _, err := domainReplyMarkupForSender(&tg.ReplyKeyboardHide{}, true); err == nil {
|
||||
t.Fatal("inline-only edit/result parser must reject reply-keyboard constructors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyKeyboardRequestPeerFiltersTLDomainRoundTrip(t *testing.T) {
|
||||
userType := &tg.RequestPeerTypeUser{}
|
||||
userType.SetBot(false)
|
||||
userType.SetPremium(true)
|
||||
chatType := &tg.RequestPeerTypeChat{Creator: true, BotParticipant: true}
|
||||
chatType.SetHasUsername(false)
|
||||
chatType.SetForum(true)
|
||||
chatType.SetUserAdminRights(tg.ChatAdminRights{DeleteMessages: true, ManageTopics: true})
|
||||
in := &tg.ReplyKeyboardMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Premium person", ButtonID: 1, PeerType: userType, MaxQuantity: 2},
|
||||
&tg.KeyboardButtonRequestPeer{Text: "Forum", ButtonID: 2, PeerType: chatType, MaxQuantity: 1},
|
||||
}}}}
|
||||
markup, err := domainOutgoingReplyMarkupForSender(in, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parse request peer filters: %v", err)
|
||||
}
|
||||
userFilter := markup.Keyboard[0][0].RequestPeerFilter
|
||||
chatFilter := markup.Keyboard[0][1].RequestPeerFilter
|
||||
if userFilter == nil || !userFilter.UserIsBotSet || userFilter.UserIsBot || !userFilter.UserIsPremiumSet || !userFilter.UserIsPremium {
|
||||
t.Fatalf("user filter = %#v", userFilter)
|
||||
}
|
||||
if chatFilter == nil || !chatFilter.ChatIsCreated || !chatFilter.BotIsMember || !chatFilter.ChatHasUsernameSet ||
|
||||
chatFilter.ChatHasUsername || !chatFilter.ChatIsForumSet || !chatFilter.ChatIsForum ||
|
||||
chatFilter.UserAdminRights == nil || !chatFilter.UserAdminRights.DeleteMessages || !chatFilter.UserAdminRights.ManageTopics {
|
||||
t.Fatalf("chat filter = %#v", chatFilter)
|
||||
}
|
||||
wire := tgReplyMarkup(markup).(*tg.ReplyKeyboardMarkup)
|
||||
wireUser := wire.Rows[0].Buttons[0].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeUser)
|
||||
if bot, ok := wireUser.GetBot(); !ok || bot {
|
||||
t.Fatalf("wire user bot=%v ok=%v", bot, ok)
|
||||
}
|
||||
if premium, ok := wireUser.GetPremium(); !ok || !premium {
|
||||
t.Fatalf("wire user premium=%v ok=%v", premium, ok)
|
||||
}
|
||||
wireChat := wire.Rows[0].Buttons[1].(*tg.KeyboardButtonRequestPeer).PeerType.(*tg.RequestPeerTypeChat)
|
||||
if !wireChat.Creator || !wireChat.BotParticipant {
|
||||
t.Fatalf("wire chat = %#v", wireChat)
|
||||
}
|
||||
if hasUsername, ok := wireChat.GetHasUsername(); !ok || hasUsername {
|
||||
t.Fatalf("wire has_username=%v ok=%v", hasUsername, ok)
|
||||
}
|
||||
if rights, ok := wireChat.GetUserAdminRights(); !ok || !rights.DeleteMessages || !rights.ManageTopics {
|
||||
t.Fatalf("wire rights=%#v ok=%v", rights, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputRequestPeerButtonPreservesRequestedMetadata(t *testing.T) {
|
||||
button := &tg.InputKeyboardButtonRequestPeer{
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
Text: "Share", ButtonID: 99, PeerType: &tg.RequestPeerTypeUser{}, MaxQuantity: 3,
|
||||
}
|
||||
got, err := domainRequestedButtonFromTG(1001, nil, button)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.NameRequested || !got.UsernameRequested || !got.PhotoRequested || got.MaxQuantity != 3 {
|
||||
t.Fatalf("requested button=%#v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ func tgMessage(m domain.Message) tg.MessageClass {
|
|||
msg.SetInvertMedia(true)
|
||||
}
|
||||
}
|
||||
// reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
// reply_markup(bot reply/inline keyboard):仅普通 tg.Message 携带(service 消息不带)。
|
||||
if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil {
|
||||
msg.SetReplyMarkup(markup)
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass {
|
|||
if msg.Out {
|
||||
return &tg.MessageActionRequestedPeerSentMe{
|
||||
ButtonID: shared.ButtonID,
|
||||
Peers: tgRequestedPeers(shared.Peers),
|
||||
Peers: tgRequestedPeers(shared),
|
||||
}
|
||||
}
|
||||
return &tg.MessageActionRequestedPeer{
|
||||
|
|
@ -294,14 +294,43 @@ func tgPeerList(peers []domain.Peer) []tg.PeerClass {
|
|||
return out
|
||||
}
|
||||
|
||||
func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass {
|
||||
out := make([]tg.RequestedPeerClass, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
func tgRequestedPeers(action *domain.MessageRequestedPeerAction) []tg.RequestedPeerClass {
|
||||
if action == nil {
|
||||
return nil
|
||||
}
|
||||
details := make(map[domain.Peer]domain.MessageRequestedPeerDetails, len(action.Details))
|
||||
for _, detail := range action.Details {
|
||||
details[detail.Peer] = detail
|
||||
}
|
||||
out := make([]tg.RequestedPeerClass, 0, len(action.Peers))
|
||||
for _, peer := range action.Peers {
|
||||
detail := details[peer]
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
out = append(out, &tg.RequestedPeerUser{UserID: peer.ID})
|
||||
item := &tg.RequestedPeerUser{UserID: peer.ID}
|
||||
if action.NameRequested {
|
||||
item.SetFirstName(detail.FirstName)
|
||||
item.SetLastName(detail.LastName)
|
||||
}
|
||||
if action.UsernameRequested {
|
||||
item.SetUsername(detail.Username)
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
item.SetPhoto(tgPhoto(*detail.Photo))
|
||||
}
|
||||
out = append(out, item)
|
||||
case domain.PeerTypeChannel:
|
||||
out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID})
|
||||
item := &tg.RequestedPeerChannel{ChannelID: peer.ID}
|
||||
if action.NameRequested {
|
||||
item.SetTitle(detail.Title)
|
||||
}
|
||||
if action.UsernameRequested {
|
||||
item.SetUsername(detail.Username)
|
||||
}
|
||||
if action.PhotoRequested && detail.Photo != nil {
|
||||
item.SetPhoto(tgPhoto(*detail.Photo))
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -821,6 +821,7 @@ type Deps struct {
|
|||
Updates UpdatesService
|
||||
BootstrapUpdates store.BootstrapUpdateJobStore
|
||||
BotAPIUpdates store.BotAPIUpdateStore
|
||||
BotCallbacks store.BotCallbackRegistryStore
|
||||
Contacts ContactsService
|
||||
Dialogs DialogsService
|
||||
Chatlists ChatlistsService
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
|
@ -67,6 +68,7 @@ func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.Messages
|
|||
if err != nil {
|
||||
return nil, messageSendErr(err)
|
||||
}
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||||
var users []tg.UserClass
|
||||
var chats []tg.ChatClass
|
||||
if !res.Duplicate {
|
||||
|
|
@ -101,16 +103,27 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if !found || !botUser.Bot {
|
||||
return nil, botInvalidErr()
|
||||
}
|
||||
webAppReqID, ok := req.GetWebappReqID()
|
||||
if !ok || webAppReqID == "" {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || button.ButtonID != req.ButtonID {
|
||||
return nil, buttonDataInvalidErr()
|
||||
webAppReqID, fromWebApp := req.GetWebappReqID()
|
||||
idempotencyKey := webAppReqID
|
||||
var button domain.BotRequestedWebViewButton
|
||||
if fromWebApp {
|
||||
if webAppReqID == "" {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
var found bool
|
||||
button, found, err = r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
if !found || button.ButtonID != req.ButtonID {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
} else {
|
||||
idempotencyKey = "message:" + strconv.Itoa(req.MsgID)
|
||||
button, err = r.requestPeerButtonFromMessage(ctx, userID, botUser.ID, req.MsgID, req.ButtonID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity {
|
||||
return nil, buttonDataInvalidErr()
|
||||
|
|
@ -121,11 +134,17 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !requestedPeerTypeMatches(button.PeerType, resolved) {
|
||||
if matches, err := r.requestedPeerMatches(ctx, userID, botUser.ID, button, resolved); err != nil {
|
||||
return nil, internalErr()
|
||||
} else if !matches {
|
||||
return nil, buttonDataInvalidErr()
|
||||
}
|
||||
peers = append(peers, resolved)
|
||||
}
|
||||
details, err := r.requestedPeerDetails(ctx, userID, peers, button)
|
||||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -134,14 +153,18 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: userID,
|
||||
RecipientUserID: botUser.ID,
|
||||
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers),
|
||||
RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, idempotencyKey, button.ButtonID, peers),
|
||||
Media: &domain.MessageMedia{
|
||||
Kind: domain.MessageMediaKindService,
|
||||
ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: button.ButtonID,
|
||||
Peers: peers,
|
||||
ButtonID: button.ButtonID,
|
||||
Peers: peers,
|
||||
Details: details,
|
||||
NameRequested: button.NameRequested,
|
||||
UsernameRequested: button.UsernameRequested,
|
||||
PhotoRequested: button.PhotoRequested,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -153,7 +176,10 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
r.enqueueBotAPIPrivateMessageUpdateAsync(ctx, res)
|
||||
if fromWebApp {
|
||||
_ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID)
|
||||
}
|
||||
var users []tg.UserClass
|
||||
var chats []tg.ChatClass
|
||||
if !res.Duplicate {
|
||||
|
|
@ -163,6 +189,128 @@ func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.Mes
|
|||
return tgPrivateSendResultUpdates(res, res.SenderMessage.RandomID, false, users, chats), nil
|
||||
}
|
||||
|
||||
type requestedPeerPhotoProvider interface {
|
||||
GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error)
|
||||
}
|
||||
|
||||
func (r *Router) requestedPeerDetails(ctx context.Context, viewerUserID int64, peers []domain.Peer, button domain.BotRequestedWebViewButton) ([]domain.MessageRequestedPeerDetails, error) {
|
||||
details := make([]domain.MessageRequestedPeerDetails, len(peers))
|
||||
for i, peer := range peers {
|
||||
details[i].Peer = peer
|
||||
}
|
||||
if !button.NameRequested && !button.UsernameRequested && !button.PhotoRequested {
|
||||
return details, nil
|
||||
}
|
||||
userIDs := make(map[int64]struct{})
|
||||
channelIDs := make(map[int64]struct{})
|
||||
for _, peer := range peers {
|
||||
addDomainPeerRef(peer, 0, userIDs, channelIDs)
|
||||
}
|
||||
cache := newViewerPeerCache(r)
|
||||
users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))
|
||||
channels := cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))
|
||||
userByID := make(map[int64]domain.User, len(users))
|
||||
channelByID := make(map[int64]domain.Channel, len(channels))
|
||||
photoIDs := make([]int64, 0, len(peers))
|
||||
for _, user := range users {
|
||||
userByID[user.ID] = user
|
||||
if button.PhotoRequested && user.PhotoID != 0 {
|
||||
photoIDs = append(photoIDs, user.PhotoID)
|
||||
}
|
||||
}
|
||||
for _, channel := range channels {
|
||||
channelByID[channel.ID] = channel
|
||||
if button.PhotoRequested && channel.PhotoID != 0 {
|
||||
photoIDs = append(photoIDs, channel.PhotoID)
|
||||
}
|
||||
}
|
||||
photoByID := make(map[int64]domain.Photo, len(photoIDs))
|
||||
if len(photoIDs) > 0 {
|
||||
provider, ok := r.deps.Files.(requestedPeerPhotoProvider)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested peer photo provider unavailable")
|
||||
}
|
||||
photos, err := provider.GetPhotos(ctx, photoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, photo := range photos {
|
||||
photoByID[photo.ID] = photo
|
||||
}
|
||||
}
|
||||
for i, peer := range peers {
|
||||
detail := &details[i]
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
user, ok := userByID[peer.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested user %d not hydrated", peer.ID)
|
||||
}
|
||||
if button.NameRequested {
|
||||
detail.FirstName, detail.LastName = user.FirstName, user.LastName
|
||||
}
|
||||
if button.UsernameRequested {
|
||||
detail.Username = user.Username
|
||||
}
|
||||
if button.PhotoRequested && user.PhotoID != 0 {
|
||||
photo, ok := photoByID[user.PhotoID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested user photo %d missing", user.PhotoID)
|
||||
}
|
||||
detail.Photo = &photo
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
channel, ok := channelByID[peer.ID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested channel %d not hydrated", peer.ID)
|
||||
}
|
||||
if button.NameRequested {
|
||||
detail.Title = channel.Title
|
||||
}
|
||||
if button.UsernameRequested {
|
||||
detail.Username = channel.Username
|
||||
}
|
||||
if button.PhotoRequested && channel.PhotoID != 0 {
|
||||
photo, ok := photoByID[channel.PhotoID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("requested channel photo %d missing", channel.PhotoID)
|
||||
}
|
||||
detail.Photo = &photo
|
||||
}
|
||||
}
|
||||
}
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (r *Router) requestPeerButtonFromMessage(ctx context.Context, userID, botUserID int64, messageID, buttonID int) (domain.BotRequestedWebViewButton, error) {
|
||||
if messageID <= 0 || messageID > domain.MaxMessageBoxID || buttonID == 0 {
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
message, found, err := r.lookupOwnerMessage(ctx, userID, messageID)
|
||||
if err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, internalErr()
|
||||
}
|
||||
if !found || message.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) ||
|
||||
message.From != (domain.Peer{Type: domain.PeerTypeUser, ID: botUserID}) || message.ReplyMarkup == nil ||
|
||||
message.ReplyMarkup.Kind() != domain.MessageReplyMarkupKeyboard {
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
for _, row := range message.ReplyMarkup.Keyboard {
|
||||
for _, item := range row {
|
||||
if item.Type != domain.MarkupButtonRequestPeer || item.ButtonID != buttonID {
|
||||
continue
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{
|
||||
BotUserID: botUserID, UserID: userID, ButtonID: item.ButtonID,
|
||||
PeerType: item.RequestPeerType, MaxQuantity: item.MaxQuantity, PeerFilter: item.RequestPeerFilter,
|
||||
NameRequested: item.NameRequested, UsernameRequested: item.UsernameRequested,
|
||||
PhotoRequested: item.PhotoRequested,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr()
|
||||
}
|
||||
|
||||
func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
|
||||
switch kind {
|
||||
case "user", "":
|
||||
|
|
@ -174,6 +322,97 @@ func requestedPeerTypeMatches(kind string, peer domain.Peer) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) requestedPeerMatches(ctx context.Context, userID, botUserID int64, button domain.BotRequestedWebViewButton, peer domain.Peer) (bool, error) {
|
||||
if !requestedPeerTypeMatches(button.PeerType, peer) {
|
||||
return false, nil
|
||||
}
|
||||
filter := button.PeerFilter
|
||||
if filter == nil {
|
||||
return true, nil
|
||||
}
|
||||
if peer.Type == domain.PeerTypeUser {
|
||||
if r.deps.Users == nil {
|
||||
return false, nil
|
||||
}
|
||||
user, found, err := r.deps.Users.ByID(ctx, userID, peer.ID)
|
||||
if err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
if filter.UserIsBotSet && user.Bot != filter.UserIsBot {
|
||||
return false, nil
|
||||
}
|
||||
if filter.UserIsPremiumSet && user.PremiumActiveAt(r.clock.Now().Unix()) != filter.UserIsPremium {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if r.deps.Channels == nil {
|
||||
return false, nil
|
||||
}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
channel := view.Channel
|
||||
if button.PeerType == "chat" && (!channel.Megagroup || channel.Broadcast) {
|
||||
return false, nil
|
||||
}
|
||||
if button.PeerType == "broadcast" && !channel.Broadcast {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatHasUsernameSet && (channel.Username != "") != filter.ChatHasUsername {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatIsForumSet && channel.Forum != filter.ChatIsForum {
|
||||
return false, nil
|
||||
}
|
||||
if filter.ChatIsCreated && view.Self.Role != domain.ChannelRoleCreator {
|
||||
return false, nil
|
||||
}
|
||||
if filter.UserAdminRights != nil && !channelMemberHasRequestRights(view.Self, *filter.UserAdminRights) {
|
||||
return false, nil
|
||||
}
|
||||
if filter.BotIsMember || filter.BotAdminRights != nil {
|
||||
botMember, err := r.deps.Channels.GetParticipant(ctx, userID, peer.ID, botUserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if botMember.Status != domain.ChannelMemberActive {
|
||||
return false, nil
|
||||
}
|
||||
if filter.BotAdminRights != nil && !channelMemberHasRequestRights(botMember, *filter.BotAdminRights) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func channelMemberHasRequestRights(member domain.ChannelMember, required domain.BotRequestAdminRights) bool {
|
||||
if member.Role == domain.ChannelRoleCreator {
|
||||
return true
|
||||
}
|
||||
if member.Role != domain.ChannelRoleAdmin {
|
||||
return false
|
||||
}
|
||||
rights := member.AdminRights
|
||||
return (!required.Anonymous || rights.Anonymous) &&
|
||||
(!required.ManageChat || rights.ManageChat) &&
|
||||
(!required.DeleteMessages || rights.DeleteMessages) &&
|
||||
(!required.ManageVideoChats || rights.ManageCall) &&
|
||||
(!required.RestrictMembers || rights.BanUsers) &&
|
||||
(!required.PromoteMembers || rights.AddAdmins) &&
|
||||
(!required.ChangeInfo || rights.ChangeInfo) &&
|
||||
(!required.InviteUsers || rights.InviteUsers) &&
|
||||
(!required.PostStories || rights.PostStories) &&
|
||||
(!required.EditStories || rights.EditStories) &&
|
||||
(!required.DeleteStories || rights.DeleteStories) &&
|
||||
(!required.PostMessages || rights.PostMessages) &&
|
||||
(!required.EditMessages || rights.EditMessages) &&
|
||||
(!required.PinMessages || rights.PinMessages) &&
|
||||
(!required.ManageTopics || rights.ManageTopics) &&
|
||||
(!required.ManageDirectMessages || rights.ManageDirectMessages)
|
||||
}
|
||||
|
||||
func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 {
|
||||
parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)}
|
||||
for _, peer := range peers {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import (
|
|||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
|
||||
ownerCtx := WithUserID(ctx, f.owner.ID)
|
||||
|
||||
updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{
|
||||
|
|
@ -46,6 +48,13 @@ func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) {
|
|||
if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID {
|
||||
t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID)
|
||||
}
|
||||
botAPIEvents, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
|
||||
if err != nil || len(botAPIEvents) != 1 || botAPIEvents[0].Message.Media == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData == nil ||
|
||||
botAPIEvents[0].Message.Media.ServiceAction.WebViewData.Data != `{"ok":true}` {
|
||||
t.Fatalf("bot api webview events=%#v err=%v", botAPIEvents, err)
|
||||
}
|
||||
|
||||
botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{
|
||||
HasPeer: true,
|
||||
|
|
@ -140,6 +149,41 @@ func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing
|
|||
}
|
||||
}
|
||||
|
||||
func TestMessagesSendBotRequestedPeerQueuesBotAPIResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
f.router.deps.BotAPIUpdates = memory.NewBotAPIUpdateStore()
|
||||
ownerCtx := WithUserID(ctx, f.owner.ID)
|
||||
button := domain.MarkupButton{
|
||||
Type: domain.MarkupButtonRequestPeer, Text: "Share user", ButtonID: 77,
|
||||
RequestPeerType: "user", MaxQuantity: 1, NameRequested: true, UsernameRequested: true,
|
||||
}
|
||||
requestMessage, err := f.router.deps.Messages.SendPrivateText(ctx, f.bot.ID, domain.SendPrivateTextRequest{
|
||||
SenderUserID: f.bot.ID, RecipientUserID: f.owner.ID, RandomID: 7001, Message: "Choose",
|
||||
ReplyMarkup: &domain.MessageReplyMarkup{Type: domain.MessageReplyMarkupKeyboard, Keyboard: [][]domain.MarkupButton{{button}}},
|
||||
Date: 1700000100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send request message: %v", err)
|
||||
}
|
||||
if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{
|
||||
Peer: inputPeerUser(f.bot), MsgID: requestMessage.RecipientMessage.ID, ButtonID: button.ButtonID,
|
||||
RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)},
|
||||
}); err != nil {
|
||||
t.Fatalf("send requested peer: %v", err)
|
||||
}
|
||||
events, err := f.router.BotAPIUpdates(ctx, f.bot.ID, 0)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("bot api requested-peer events=%#v err=%v", events, err)
|
||||
}
|
||||
action := events[0].Message.Media.ServiceAction.RequestedPeer
|
||||
if action == nil || action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0].ID != f.peer.ID ||
|
||||
len(action.Details) != 1 || action.Details[0].Peer != action.Peers[0] || action.Details[0].FirstName != f.peer.FirstName ||
|
||||
!action.NameRequested || !action.UsernameRequested {
|
||||
t.Fatalf("requested-peer action=%#v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newInlineBotRPCTestFixture(t)
|
||||
|
|
|
|||
|
|
@ -169,15 +169,20 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
|
|||
sendErr = err
|
||||
return nil, sendErr
|
||||
}
|
||||
// reply_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force;
|
||||
// 非 bot 静默丢弃。仅请求携带 markup 时查询 is_bot。
|
||||
// 仅在请求携带 markup 时才查 is_bot,避免普通发送多打一次查询。
|
||||
var replyMarkup *domain.MessageReplyMarkup
|
||||
if req.ReplyMarkup != nil {
|
||||
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
if err != nil {
|
||||
sendErr = replyMarkupErr(err)
|
||||
return nil, sendErr
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
sendErr = err
|
||||
return nil, sendErr
|
||||
}
|
||||
}
|
||||
// rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。
|
||||
// Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ type Config struct {
|
|||
|
||||
// Router 把解密后的 RPC 请求按 semantic method 路由到 typed handler(tlprofile.Dispatcher)。
|
||||
//
|
||||
// handler 输入输出均为 iamxvbaba/td/tg 类型,各业务域的 handler
|
||||
// handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler
|
||||
// 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳:
|
||||
// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。
|
||||
type Router struct {
|
||||
|
|
@ -237,7 +237,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
|
|||
if instanceID == "" {
|
||||
instanceID = fmt.Sprintf("%016x", randomNonZeroInt64())
|
||||
}
|
||||
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
|
||||
r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID}
|
||||
r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer)
|
||||
r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer)
|
||||
r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ type outgoingSend struct {
|
|||
sendAs *domain.Peer
|
||||
sendAsReady bool
|
||||
clearDraft bool
|
||||
// replyMarkup 是 bot inline keyboard(已解析+校验;非 bot 恒 nil)。
|
||||
// replyMarkup 是 bot reply/inline keyboard(已解析+校验;非 bot 恒 nil)。
|
||||
replyMarkup *domain.MessageReplyMarkup
|
||||
viaBotID int64
|
||||
// richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。
|
||||
|
|
@ -382,13 +382,17 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
|
|||
if media == nil {
|
||||
return nil, mediaInvalidErr()
|
||||
}
|
||||
// reply_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。
|
||||
// reply_markup:bot 可发送 inline keyboard 与普通 reply keyboard/hide/force;
|
||||
// 非 bot 静默丢弃。
|
||||
var replyMarkup *domain.MessageReplyMarkup
|
||||
if req.ReplyMarkup != nil {
|
||||
replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
replyMarkup, err = domainOutgoingReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID))
|
||||
if err != nil {
|
||||
return nil, replyMarkupErr(err)
|
||||
}
|
||||
if err := r.validateReplyMarkupForPeer(ctx, userID, peer, replyMarkup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) {
|
||||
return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
|
|||
addDomainPeerRef(peer, 0, userIDs, channelIDs)
|
||||
}
|
||||
collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs)
|
||||
if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
|
||||
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
|
||||
}
|
||||
removeKnownChannelRefs(channelIDs, out[i].Channels)
|
||||
refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs}
|
||||
for id := range userIDs {
|
||||
|
|
@ -220,6 +223,11 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
|
|||
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||
}
|
||||
if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.RequestedPeer != nil {
|
||||
for _, peer := range msg.Media.ServiceAction.RequestedPeer.Peers {
|
||||
addDomainPeerRef(peer, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
collectPollMediaUserRefs(msg.Media, userIDs)
|
||||
collectTodoMediaUserRefs(msg.Media, userIDs)
|
||||
if msg.Reactions != nil {
|
||||
|
|
|
|||
36
internal/store/bot_callback.go
Normal file
36
internal/store/bot_callback.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotCallbackPending is the short-lived, protocol-neutral ownership record for
|
||||
// one messages.getBotCallbackAnswer request. It is deliberately ephemeral: the
|
||||
// durable Bot API update remains in BotAPIUpdateStore, while this record only
|
||||
// coordinates the synchronous client answer across server instances.
|
||||
type BotCallbackPending struct {
|
||||
QueryID int64
|
||||
BotUserID int64
|
||||
UserID int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type BotCallbackAnswerPush struct {
|
||||
QueryID int64
|
||||
BotUserID int64
|
||||
Answer domain.BotCallbackAnswer
|
||||
}
|
||||
|
||||
// BotCallbackRegistryStore coordinates callback waiters across processes.
|
||||
// Implementations must make Put and Resolve atomic: query ids cannot be
|
||||
// overwritten and at most one answer may win for the owning bot.
|
||||
type BotCallbackRegistryStore interface {
|
||||
PutBotCallbackPending(ctx context.Context, pending BotCallbackPending, ttl time.Duration) (bool, error)
|
||||
ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error)
|
||||
GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error)
|
||||
DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error
|
||||
SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, BotCallbackAnswerPush)) error
|
||||
}
|
||||
|
|
@ -2,14 +2,40 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotAPIPollLeaseStore serializes getUpdates across all HTTP gateway
|
||||
// instances. owner is an opaque per-request token; Release must be compare-and-
|
||||
// delete so a stale request cannot release a successor's lease.
|
||||
type BotAPIPollLeaseStore interface {
|
||||
AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error)
|
||||
ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error
|
||||
}
|
||||
|
||||
// BotAPIWebhookStore coordinates durable webhook configuration and delivery
|
||||
// leases. Delivery itself stays at the HTTP edge; this store only owns state.
|
||||
type BotAPIWebhookStore interface {
|
||||
SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error
|
||||
DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error
|
||||
BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error)
|
||||
ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error)
|
||||
AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error)
|
||||
ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error
|
||||
RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error
|
||||
RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error
|
||||
}
|
||||
|
||||
// BotAPIUpdateStore persists update_id based Bot API delivery queues.
|
||||
type BotAPIUpdateStore interface {
|
||||
EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error)
|
||||
ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error)
|
||||
ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error)
|
||||
ConfirmBotAPIUpdates(ctx context.Context, botUserID, confirmedUpdateID int64) error
|
||||
ConfirmedBotAPIUpdateID(ctx context.Context, botUserID int64) (int64, bool, error)
|
||||
SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error
|
||||
DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error
|
||||
PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,29 +3,228 @@ package memory
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotAPIUpdateStore is an in-memory implementation of store.BotAPIUpdateStore.
|
||||
type BotAPIUpdateStore struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
rows []domain.BotAPIUpdate
|
||||
state map[int64]int64
|
||||
byKey map[string]int64
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
rows []domain.BotAPIUpdate
|
||||
state map[int64]int64
|
||||
cursorInitialized map[int64]bool
|
||||
allowed map[int64]map[domain.BotAPIUpdateKind]struct{}
|
||||
byKey map[string]int64
|
||||
pollLeases map[int64]botAPIPollLease
|
||||
webhooks map[int64]domain.BotAPIWebhook
|
||||
webhookLeases map[int64]botAPIPollLease
|
||||
}
|
||||
|
||||
type botAPIPollLease struct {
|
||||
owner string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewBotAPIUpdateStore creates an in-memory Bot API update queue.
|
||||
func NewBotAPIUpdateStore() *BotAPIUpdateStore {
|
||||
return &BotAPIUpdateStore{
|
||||
nextID: 1,
|
||||
state: make(map[int64]int64),
|
||||
byKey: make(map[string]int64),
|
||||
nextID: 1,
|
||||
state: make(map[int64]int64),
|
||||
cursorInitialized: make(map[int64]bool),
|
||||
allowed: make(map[int64]map[domain.BotAPIUpdateKind]struct{}),
|
||||
byKey: make(map[string]int64),
|
||||
pollLeases: make(map[int64]botAPIPollLease),
|
||||
webhooks: make(map[int64]domain.BotAPIWebhook),
|
||||
webhookLeases: make(map[int64]botAPIPollLease),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIWebhook(_ context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
|
||||
return fmt.Errorf("invalid bot api webhook")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !config.AllowedUpdatesSet {
|
||||
config.AllowedUpdates = allowedUpdateKinds(s.allowed[config.BotUserID])
|
||||
}
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
|
||||
config.NextAttemptAt = time.Now()
|
||||
s.webhooks[config.BotUserID] = config
|
||||
if config.AllowedUpdates == nil {
|
||||
delete(s.allowed, config.BotUserID)
|
||||
} else {
|
||||
allowed := make(map[domain.BotAPIUpdateKind]struct{}, len(config.AllowedUpdates))
|
||||
for _, kind := range config.AllowedUpdates {
|
||||
allowed[kind] = struct{}{}
|
||||
}
|
||||
s.allowed[config.BotUserID] = allowed
|
||||
}
|
||||
if dropPending {
|
||||
s.dropPendingLocked(config.BotUserID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allowedUpdateKinds(items map[domain.BotAPIUpdateKind]struct{}) []domain.BotAPIUpdateKind {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]domain.BotAPIUpdateKind, 0, len(items))
|
||||
for kind := range items {
|
||||
out = append(out, kind)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(_ context.Context, botUserID int64, dropPending bool) error {
|
||||
s.mu.Lock()
|
||||
delete(s.webhooks, botUserID)
|
||||
delete(s.webhookLeases, botUserID)
|
||||
if dropPending {
|
||||
s.dropPendingLocked(botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) BotAPIWebhook(_ context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
s.mu.RLock()
|
||||
config, found := s.webhooks[botUserID]
|
||||
s.mu.RUnlock()
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
return config, found, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(_ context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.RLock()
|
||||
out := make([]domain.BotAPIWebhook, 0, min(limit, len(s.webhooks)))
|
||||
for botID, config := range s.webhooks {
|
||||
lease := s.webhookLeases[botID]
|
||||
if config.NextAttemptAt.After(now) || (lease.owner != "" && lease.expiresAt.After(now)) {
|
||||
continue
|
||||
}
|
||||
config.AllowedUpdates = append([]domain.BotAPIUpdateKind(nil), config.AllowedUpdates...)
|
||||
out = append(out, config)
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api webhook lease")
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, found := s.webhooks[botUserID]; !found {
|
||||
return false, nil
|
||||
}
|
||||
current := s.webhookLeases[botUserID]
|
||||
if current.owner != "" && current.owner != owner && current.expiresAt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
s.webhookLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(_ context.Context, botUserID int64, owner string) error {
|
||||
s.mu.Lock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner == owner {
|
||||
delete(s.webhookLeases, botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(_ context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner != owner {
|
||||
return nil
|
||||
}
|
||||
config, found := s.webhooks[botUserID]
|
||||
if !found {
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
config.FailureCount++
|
||||
config.LastErrorDate = int(time.Now().Unix())
|
||||
if len(message) > 512 {
|
||||
message = message[:512]
|
||||
}
|
||||
config.LastErrorMessage = message
|
||||
config.NextAttemptAt = nextAttempt
|
||||
s.webhooks[botUserID] = config
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(_ context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if current := s.webhookLeases[botUserID]; current.owner != owner {
|
||||
return nil
|
||||
}
|
||||
if config, found := s.webhooks[botUserID]; found {
|
||||
config.FailureCount, config.LastErrorDate, config.LastErrorMessage = 0, 0, ""
|
||||
config.NextAttemptAt = nextAttempt
|
||||
s.webhooks[botUserID] = config
|
||||
}
|
||||
delete(s.webhookLeases, botUserID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) dropPendingLocked(botUserID int64) {
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
|
||||
s.state[botUserID] = row.ID
|
||||
}
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(_ context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api poll lease")
|
||||
}
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, found := s.pollLeases[botUserID]
|
||||
if found && current.owner != owner && current.expiresAt.After(now) {
|
||||
return false, nil
|
||||
}
|
||||
s.pollLeases[botUserID] = botAPIPollLease{owner: owner, expiresAt: now.Add(ttl)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(_ context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if current, found := s.pollLeases[botUserID]; found && current.owner == owner {
|
||||
delete(s.pollLeases, botUserID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
|
||||
if err := validateBotAPIUpdateRequest(req); err != nil {
|
||||
return domain.BotAPIUpdate{}, false, err
|
||||
|
|
@ -33,6 +232,11 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
|
|||
key := botAPIUpdateKey(req)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if allowed, configured := s.allowed[req.BotUserID]; configured {
|
||||
if _, ok := allowed[req.Kind]; !ok {
|
||||
return domain.BotAPIUpdate{}, false, nil
|
||||
}
|
||||
}
|
||||
if existingID, ok := s.byKey[key]; ok {
|
||||
for _, row := range s.rows {
|
||||
if row.ID == existingID {
|
||||
|
|
@ -48,13 +252,56 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
|
|||
MessageID: req.MessageID,
|
||||
SourcePts: req.SourcePts,
|
||||
Date: req.Date,
|
||||
Callback: cloneBotAPICallback(req.Callback),
|
||||
}
|
||||
s.nextID++
|
||||
s.rows = append(s.rows, row)
|
||||
s.byKey[key] = row.ID
|
||||
if config, found := s.webhooks[req.BotUserID]; found {
|
||||
config.NextAttemptAt = time.Now()
|
||||
s.webhooks[req.BotUserID] = config
|
||||
}
|
||||
return cloneBotAPIUpdate(row), true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(_ context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 || tail <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
confirmed := s.state[botUserID]
|
||||
matching := make([]domain.BotAPIUpdate, 0, min(tail, limit))
|
||||
start := 0
|
||||
count := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > confirmed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > tail {
|
||||
start = count - tail
|
||||
}
|
||||
seen := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID != botUserID || row.ID <= confirmed {
|
||||
continue
|
||||
}
|
||||
if seen < start {
|
||||
seen++
|
||||
continue
|
||||
}
|
||||
matching = append(matching, cloneBotAPIUpdate(row))
|
||||
if len(matching) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return matching, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListBotAPIUpdates(_ context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -85,13 +332,78 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(_ context.Context, botUserID, c
|
|||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
maxExisting := int64(0)
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > maxExisting {
|
||||
maxExisting = row.ID
|
||||
}
|
||||
}
|
||||
if confirmedUpdateID > maxExisting && s.cursorInitialized[botUserID] {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if confirmedUpdateID > maxExisting {
|
||||
confirmedUpdateID = maxExisting
|
||||
}
|
||||
if confirmedUpdateID > s.state[botUserID] {
|
||||
s.state[botUserID] = confirmedUpdateID
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(_ context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if len(allowed) == 0 {
|
||||
delete(s.allowed, botUserID)
|
||||
} else {
|
||||
set := make(map[domain.BotAPIUpdateKind]struct{}, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
if kind != "" {
|
||||
set[kind] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.allowed[botUserID] = set
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > s.state[botUserID] {
|
||||
s.state[botUserID] = row.ID
|
||||
}
|
||||
}
|
||||
s.cursorInitialized[botUserID] = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(_ context.Context, botUserID int64) (int, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
confirmed := s.state[botUserID]
|
||||
count := 0
|
||||
for _, row := range s.rows {
|
||||
if row.BotUserID == botUserID && row.ID > confirmed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID int64) (int64, bool, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, false, nil
|
||||
|
|
@ -103,27 +415,65 @@ func (s *BotAPIUpdateStore) ConfirmedBotAPIUpdateID(_ context.Context, botUserID
|
|||
}
|
||||
|
||||
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
||||
if req.BotUserID == 0 || req.MessageID <= 0 {
|
||||
if req.BotUserID == 0 {
|
||||
return fmt.Errorf("invalid bot api update")
|
||||
}
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
|
||||
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
|
||||
}
|
||||
switch req.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if req.Peer.ID <= 0 {
|
||||
if req.Peer.ID <= 0 || req.MessageID <= 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
case "":
|
||||
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
|
||||
}
|
||||
if req.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
cb := req.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
|
||||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
|
||||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api callback query")
|
||||
}
|
||||
inline := cb.InlineMessage
|
||||
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
|
||||
return fmt.Errorf("invalid bot api inline callback query")
|
||||
}
|
||||
if req.MessageID > 0 && inline != nil {
|
||||
return fmt.Errorf("ambiguous bot api callback query")
|
||||
}
|
||||
} else if req.Callback != nil {
|
||||
return fmt.Errorf("unexpected bot api callback query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func botAPIUpdateKey(req domain.EnqueueBotAPIUpdateRequest) string {
|
||||
if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil {
|
||||
return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID)
|
||||
}
|
||||
return fmt.Sprintf("%d:%s:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.SourcePts)
|
||||
}
|
||||
|
||||
func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate {
|
||||
row.Callback = cloneBotAPICallback(row.Callback)
|
||||
return row
|
||||
}
|
||||
|
||||
func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.Data = append([]byte(nil), in.Data...)
|
||||
if in.InlineMessage != nil {
|
||||
inline := *in.InlineMessage
|
||||
out.InlineMessage = &inline
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
|
|
|||
191
internal/store/memory/botapi_update_test.go
Normal file
191
internal/store/memory/botapi_update_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func botAPIMessageRequest(botID int64, kind domain.BotAPIUpdateKind, messageID int) domain.EnqueueBotAPIUpdateRequest {
|
||||
return domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: botID,
|
||||
Kind: kind,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2001},
|
||||
MessageID: messageID,
|
||||
SourcePts: messageID,
|
||||
Date: 1700000000 + messageID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollLeaseCompareOwnerAndExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "one", 20*time.Millisecond); err != nil || !acquired {
|
||||
t.Fatalf("first acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || acquired {
|
||||
t.Fatalf("competing acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := store.ReleaseBotAPIPollLease(ctx, 1001, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); acquired {
|
||||
t.Fatal("stale release removed active owner")
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
if acquired, err := store.AcquireBotAPIPollLease(ctx, 1001, "two", time.Second); err != nil || !acquired {
|
||||
t.Fatalf("expired acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIWebhookLeaseWakeAndAtomicDrop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || !created {
|
||||
t.Fatalf("enqueue initial created=%v err=%v", created, err)
|
||||
}
|
||||
config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/hook", MaxConnections: 8}
|
||||
if err := store.SetBotAPIWebhook(ctx, config, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, _ := store.PendingBotAPIUpdateCount(ctx, 1001); count != 0 {
|
||||
t.Fatalf("pending after atomic drop=%d", count)
|
||||
}
|
||||
if acquired, err := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-1", time.Second); err != nil || !acquired {
|
||||
t.Fatalf("lease acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, _ := store.AcquireBotAPIWebhookLease(ctx, 1001, "worker-2", time.Second); acquired {
|
||||
t.Fatal("second webhook worker acquired active lease")
|
||||
}
|
||||
if err := store.RecordBotAPIWebhookSuccess(ctx, 1001, "worker-1", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
|
||||
t.Fatalf("idle due=%#v err=%v", due, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created {
|
||||
t.Fatalf("enqueue wake created=%v err=%v", created, err)
|
||||
}
|
||||
if due, err := store.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != 1001 {
|
||||
t.Fatalf("woken due=%#v err=%v", due, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIWebhookAllowedUpdatesOmissionPreservesPolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := domain.BotAPIWebhook{BotUserID: 1001, URL: "https://example.test/one", MaxConnections: 8}
|
||||
if err := store.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err := store.BotAPIWebhook(ctx, 1001)
|
||||
if err != nil || !found || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
|
||||
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1)); err != nil || created || row.ID != 0 {
|
||||
t.Fatalf("message bypassed preserved policy: row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
config.URL = "https://example.test/two"
|
||||
config.AllowedUpdatesSet = true // Explicit empty resets to the default/all policy.
|
||||
if err := store.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err = store.BotAPIWebhook(ctx, 1001)
|
||||
if err != nil || !found || stored.AllowedUpdates != nil {
|
||||
t.Fatalf("explicit empty webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || !created {
|
||||
t.Fatalf("message after explicit reset created=%v err=%v", created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIUpdateCursorClampDropAndTail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
for id := 1; id <= 5; id++ {
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, id)); err != nil || !created {
|
||||
t.Fatalf("enqueue %d: created=%v err=%v", id, created, err)
|
||||
}
|
||||
}
|
||||
tail, err := store.ListTailBotAPIUpdates(ctx, 1001, 2, 100)
|
||||
if err != nil || len(tail) != 2 || tail[0].MessageID != 4 || tail[1].MessageID != 5 {
|
||||
t.Fatalf("tail = %#v err=%v", tail, err)
|
||||
}
|
||||
if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil {
|
||||
t.Fatalf("confirm huge offset: %v", err)
|
||||
}
|
||||
confirmed, found, err := store.ConfirmedBotAPIUpdateID(ctx, 1001)
|
||||
if err != nil || !found || confirmed != 5 {
|
||||
t.Fatalf("confirmed = %d found=%v err=%v, want 5", confirmed, found, err)
|
||||
}
|
||||
row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 6))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue after huge offset: row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
if err := store.ConfirmBotAPIUpdates(ctx, 1001, 1<<60); err != nil {
|
||||
t.Fatalf("repeat foreign offset: %v", err)
|
||||
}
|
||||
if confirmed, _, _ := store.ConfirmedBotAPIUpdateID(ctx, 1001); confirmed != 5 {
|
||||
t.Fatalf("repeat foreign offset advanced cursor to %d, want 5", confirmed)
|
||||
}
|
||||
pending, err := store.ListBotAPIUpdates(ctx, 1001, confirmed+1, 100)
|
||||
if err != nil || len(pending) != 1 || pending[0].MessageID != 6 {
|
||||
t.Fatalf("pending after huge offset = %#v err=%v", pending, err)
|
||||
}
|
||||
if err := store.DropPendingBotAPIUpdates(ctx, 1001); err != nil {
|
||||
t.Fatalf("drop pending: %v", err)
|
||||
}
|
||||
count, err := store.PendingBotAPIUpdateCount(ctx, 1001)
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("pending count = %d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIAllowedUpdatesOnlyAffectsFutureEnqueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 1))
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue pre-policy: %#v created=%v err=%v", first, created, err)
|
||||
}
|
||||
if err := store.SetBotAPIAllowedUpdates(ctx, 1001, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
|
||||
t.Fatalf("set policy: %v", err)
|
||||
}
|
||||
if row, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateMessage, 2)); err != nil || created || row.ID != 0 {
|
||||
t.Fatalf("filtered message = %#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
if _, created, err := store.EnqueueBotAPIUpdate(ctx, botAPIMessageRequest(1001, domain.BotAPIUpdateEditedMessage, 3)); err != nil || !created {
|
||||
t.Fatalf("allowed edit created=%v err=%v", created, err)
|
||||
}
|
||||
rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100)
|
||||
if err != nil || len(rows) != 2 || rows[0].ID != first.ID || rows[1].Kind != domain.BotAPIUpdateEditedMessage {
|
||||
t.Fatalf("rows = %#v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewBotAPIUpdateStore()
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 77, BotUserID: 1001, UserID: 2001, ChatInstance: 99, Data: []byte("tap"),
|
||||
InlineMessage: &domain.BotInlineMessageID{DCID: 2, OwnerID: 2001, ID: 15, AccessHash: 1234},
|
||||
}
|
||||
row, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
|
||||
})
|
||||
if err != nil || !created || row.MessageID != 0 || row.Peer != (domain.Peer{}) || row.Callback == nil ||
|
||||
row.Callback.InlineMessage == nil || *row.Callback.InlineMessage != *callback.InlineMessage {
|
||||
t.Fatalf("inline callback row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
callback.Data[0] = 'X'
|
||||
callback.InlineMessage.ID = 99
|
||||
rows, err := store.ListBotAPIUpdates(ctx, 1001, 1, 100)
|
||||
if err != nil || len(rows) != 1 || string(rows[0].Callback.Data) != "tap" || rows[0].Callback.InlineMessage.ID != 15 {
|
||||
t.Fatalf("inline callback rows=%#v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ func normalizeMemoryMessageIDs(ids []int) []int {
|
|||
|
||||
func cloneMessage(msg domain.Message) domain.Message {
|
||||
msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...)
|
||||
msg.Media = cloneRequestedPeerMedia(msg.Media)
|
||||
msg.ReplyTo = cloneMessageReply(msg.ReplyTo)
|
||||
msg.Forward = cloneMessageForward(msg.Forward)
|
||||
msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions)
|
||||
|
|
@ -99,13 +100,36 @@ func cloneMessage(msg domain.Message) domain.Message {
|
|||
return msg
|
||||
}
|
||||
|
||||
// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐
|
||||
// cloneRequestedPeerMedia isolates the immutable disclosure snapshot carried by
|
||||
// messageActionRequestedPeer. Other media payloads retain their established
|
||||
// copy behavior; this helper only deep-copies the newly mutable peer/photo slices.
|
||||
func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
|
||||
if media == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *media
|
||||
if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
|
||||
return &clone
|
||||
}
|
||||
action := *media.ServiceAction
|
||||
requested := *media.ServiceAction.RequestedPeer
|
||||
requested.Peers = append([]domain.Peer(nil), requested.Peers...)
|
||||
requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...)
|
||||
for i := range requested.Details {
|
||||
requested.Details[i].Photo = domain.ClonePhotoPtr(requested.Details[i].Photo)
|
||||
}
|
||||
action.RequestedPeer = &requested
|
||||
clone.ServiceAction = &action
|
||||
return &clone
|
||||
}
|
||||
|
||||
// cloneReplyMarkup 深拷 reply markup 快照:与 postgres 每盒独立 decode 对齐
|
||||
// (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。
|
||||
func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
clone := domain.MessageReplyMarkup{}
|
||||
clone := *m
|
||||
if m.Inline != nil {
|
||||
clone.Inline = make([][]domain.MarkupButton, len(m.Inline))
|
||||
for i, row := range m.Inline {
|
||||
|
|
@ -117,6 +141,12 @@ func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup {
|
|||
clone.Inline[i] = cloneRow
|
||||
}
|
||||
}
|
||||
if m.Keyboard != nil {
|
||||
clone.Keyboard = make([][]domain.MarkupButton, len(m.Keyboard))
|
||||
for i, row := range m.Keyboard {
|
||||
clone.Keyboard[i] = append([]domain.MarkupButton(nil), row...)
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,33 @@ func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (dom
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// GetByUID resolves one owner's box row by the shared private message id. Callback delivery
|
||||
// uses it to translate the clicker's box id to the bot's box id without scanning history.
|
||||
func (s *MessageStore) GetByUID(_ context.Context, userID, uid int64) (domain.Message, bool, error) {
|
||||
if userID == 0 || uid == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
var found domain.Message
|
||||
for _, msg := range s.m[userID] {
|
||||
if msg.UID == uid {
|
||||
found = cloneMessage(msg)
|
||||
reactions := s.privateMessageReactionsForMessageLocked(found)
|
||||
if len(reactions.Results) > 0 || len(reactions.Recent) > 0 {
|
||||
found.Reactions = cloneChannelMessageReactionsPtr(&reactions)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if found.ID == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
items := []domain.Message{found}
|
||||
s.enrichPrivateMessagePolls(items, int(time.Now().Unix()))
|
||||
return items[0], true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
s.mu.RLock()
|
||||
messages := cloneMessages(s.m[userID])
|
||||
|
|
|
|||
43
internal/store/memory/message_markup_test.go
Normal file
43
internal/store/memory/message_markup_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestPrivateReplyKeyboardSurvivesBothBoxesAndHistory(t *testing.T) {
|
||||
store := NewMessageStore(NewDialogStore())
|
||||
markup := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
SingleUse: true,
|
||||
Persistent: true,
|
||||
Placeholder: "Choose",
|
||||
}
|
||||
res, err := store.SendPrivateText(context.Background(), domain.SendPrivateTextRequest{
|
||||
SenderUserID: 10, RecipientUserID: 20, RandomID: 30,
|
||||
Message: "pick", Date: 40, ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertReplyKeyboard := func(name string, got *domain.MessageReplyMarkup) {
|
||||
t.Helper()
|
||||
if got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard || len(got.Keyboard) != 1 ||
|
||||
len(got.Keyboard[0]) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize ||
|
||||
!got.SingleUse || !got.Persistent || got.Placeholder != "Choose" {
|
||||
t.Fatalf("%s = %#v", name, got)
|
||||
}
|
||||
}
|
||||
assertReplyKeyboard("sender", res.SenderMessage.ReplyMarkup)
|
||||
assertReplyKeyboard("recipient", res.RecipientMessage.ReplyMarkup)
|
||||
markup.Keyboard[0][0].Text = "mutated"
|
||||
list, err := store.GetByIDs(context.Background(), 20, []int{res.RecipientMessage.ID})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
t.Fatalf("GetByIDs = %+v, %v", list, err)
|
||||
}
|
||||
assertReplyKeyboard("recipient history", list.Messages[0].ReplyMarkup)
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
NoForwards: req.NoForwards,
|
||||
Body: req.Message,
|
||||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
Media: cloneRequestedPeerMedia(req.Media),
|
||||
ViaBotID: req.ViaBotID,
|
||||
GroupedID: req.GroupedID,
|
||||
Effect: req.Effect,
|
||||
|
|
@ -110,6 +110,7 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
|
|||
recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}
|
||||
recipient.Out = false
|
||||
recipient.ReplyTo = cloneMessageReply(recipientReply)
|
||||
recipient.Media = cloneRequestedPeerMedia(sender.Media)
|
||||
// recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷
|
||||
// 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。
|
||||
recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup)
|
||||
|
|
|
|||
|
|
@ -222,6 +222,65 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
assertWebViewData("recipient history", recipientHistory.Messages[0])
|
||||
}
|
||||
|
||||
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
photo := domain.Photo{ID: 8101, Sizes: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
|
||||
}}}
|
||||
requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003}
|
||||
req := domain.SendPrivateTextRequest{
|
||||
SenderUserID: 1000000001, RecipientUserID: 1000000002, RandomID: 200, Date: 1700000121,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 77, Peers: []domain.Peer{requestedPeer},
|
||||
Details: []domain.MessageRequestedPeerDetails{{
|
||||
Peer: requestedPeer, FirstName: "Shared", Username: "shared_user", Photo: &photo,
|
||||
}},
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
got, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertSnapshot := func(name string, msg domain.Message) {
|
||||
t.Helper()
|
||||
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
|
||||
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
|
||||
}
|
||||
action := msg.Media.ServiceAction.RequestedPeer
|
||||
if action.ButtonID != 77 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
|
||||
len(action.Details) != 1 || action.Details[0].FirstName != "Shared" ||
|
||||
action.Details[0].Username != "shared_user" || action.Details[0].Photo == nil ||
|
||||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
|
||||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
|
||||
t.Fatalf("%s requested-peer=%+v", name, action)
|
||||
}
|
||||
}
|
||||
assertSnapshot("sender", got.SenderMessage)
|
||||
assertSnapshot("recipient", got.RecipientMessage)
|
||||
|
||||
// Mutating either the request or one returned box must not alter the other
|
||||
// box or the immutable store snapshot.
|
||||
req.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-request"
|
||||
req.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 1
|
||||
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].FirstName = "mutated-result"
|
||||
got.SenderMessage.Media.ServiceAction.RequestedPeer.Details[0].Photo.Sizes[0].W = 2
|
||||
assertSnapshot("isolated recipient result", got.RecipientMessage)
|
||||
|
||||
for _, owner := range []int64{req.SenderUserID, req.RecipientUserID} {
|
||||
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{Limit: 10})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("owner %d history=%+v err=%v", owner, history, err)
|
||||
}
|
||||
assertSnapshot("stored history", history.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
messages := NewMessageStore()
|
||||
|
|
|
|||
|
|
@ -183,6 +183,22 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
|
|||
if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory {
|
||||
t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory)
|
||||
}
|
||||
requestedButton := domain.BotRequestedWebViewButton{
|
||||
WebAppReqID: fmt.Sprintf("pg-requested-%d", suffix), BotUserID: bot1.ID, UserID: owner.ID,
|
||||
ButtonID: 45, Text: "Share", PeerType: "user", MaxQuantity: 2,
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = bots.DeleteRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
|
||||
})
|
||||
if err := bots.SaveRequestedWebViewButton(ctx, requestedButton); err != nil {
|
||||
t.Fatalf("save requested button: %v", err)
|
||||
}
|
||||
storedButton, found, err := bots.GetRequestedWebViewButton(ctx, bot1.ID, owner.ID, requestedButton.WebAppReqID)
|
||||
if err != nil || !found || !storedButton.NameRequested || !storedButton.UsernameRequested || !storedButton.PhotoRequested {
|
||||
t.Fatalf("requested button=%#v found=%v err=%v", storedButton, found, err)
|
||||
}
|
||||
if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can {
|
||||
t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,16 +416,29 @@ func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain
|
|||
if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() {
|
||||
return domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
peerFilter, err := json.Marshal(button.PeerFilter)
|
||||
if err != nil {
|
||||
return domain.ErrBotRequestedButtonInvalid
|
||||
}
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO webview_requested_buttons (
|
||||
webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
|
||||
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
ON CONFLICT (webapp_req_id) DO UPDATE SET
|
||||
button_id=EXCLUDED.button_id,
|
||||
text=EXCLUDED.text,
|
||||
peer_type=EXCLUDED.peer_type,
|
||||
max_quantity=EXCLUDED.max_quantity,
|
||||
peer_filter=EXCLUDED.peer_filter,
|
||||
name_requested=EXCLUDED.name_requested,
|
||||
username_requested=EXCLUDED.username_requested,
|
||||
photo_requested=EXCLUDED.photo_requested,
|
||||
expires_at=EXCLUDED.expires_at`,
|
||||
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt)
|
||||
button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text,
|
||||
button.PeerType, button.MaxQuantity, peerFilter, button.NameRequested,
|
||||
button.UsernameRequested, button.PhotoRequested, button.CreatedAt, button.ExpiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save requested webview button: %w", err)
|
||||
}
|
||||
|
|
@ -435,18 +448,28 @@ ON CONFLICT (webapp_req_id) DO UPDATE SET
|
|||
func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) {
|
||||
_, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`)
|
||||
var button domain.BotRequestedWebViewButton
|
||||
var peerFilter []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at
|
||||
SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity,
|
||||
peer_filter, name_requested, username_requested, photo_requested, created_at, expires_at
|
||||
FROM webview_requested_buttons
|
||||
WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`,
|
||||
botUserID, userID, webAppReqID).
|
||||
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt)
|
||||
Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID,
|
||||
&button.Text, &button.PeerType, &button.MaxQuantity, &peerFilter,
|
||||
&button.NameRequested, &button.UsernameRequested, &button.PhotoRequested,
|
||||
&button.CreatedAt, &button.ExpiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.BotRequestedWebViewButton{}, false, nil
|
||||
}
|
||||
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err)
|
||||
}
|
||||
if string(peerFilter) != "{}" && string(peerFilter) != "null" {
|
||||
if err := json.Unmarshal(peerFilter, &button.PeerFilter); err != nil {
|
||||
return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("decode requested webview button filter: %w", err)
|
||||
}
|
||||
}
|
||||
return button, true, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,17 +20,320 @@ func NewBotAPIUpdateStore(db sqlcgen.DBTX) *BotAPIUpdateStore {
|
|||
return &BotAPIUpdateStore{db: db}
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIWebhook(ctx context.Context, config domain.BotAPIWebhook, dropPending bool) error {
|
||||
if config.BotUserID <= 0 || config.URL == "" || config.MaxConnections < 1 || config.MaxConnections > 100 {
|
||||
return fmt.Errorf("invalid bot api webhook")
|
||||
}
|
||||
var allowed []string
|
||||
if len(config.AllowedUpdates) > 0 {
|
||||
allowed = make([]string, 0, len(config.AllowedUpdates))
|
||||
for _, kind := range config.AllowedUpdates {
|
||||
if kind != "" {
|
||||
allowed = append(allowed, string(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH policy AS (
|
||||
SELECT CASE WHEN $6::boolean THEN $5::text[]
|
||||
ELSE (SELECT allowed_updates FROM bot_api_update_states WHERE bot_user_id = $1)
|
||||
END AS allowed_updates
|
||||
), configured AS (
|
||||
INSERT INTO bot_api_webhooks (
|
||||
bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at,
|
||||
delivery_owner, delivery_expires_at, updated_at
|
||||
)
|
||||
SELECT $1, $2, $3, $4, allowed_updates, 0, 0, '', now(), '', NULL, now()
|
||||
FROM policy
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET url = EXCLUDED.url,
|
||||
secret_token = EXCLUDED.secret_token,
|
||||
max_connections = EXCLUDED.max_connections,
|
||||
allowed_updates = EXCLUDED.allowed_updates,
|
||||
failure_count = 0,
|
||||
last_error_date = 0,
|
||||
last_error_message = '',
|
||||
next_attempt_at = now(),
|
||||
delivery_owner = '',
|
||||
delivery_expires_at = NULL,
|
||||
updated_at = now()
|
||||
RETURNING bot_user_id
|
||||
), boundary AS (
|
||||
SELECT CASE WHEN $7::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (
|
||||
bot_user_id, confirmed_update_id, allowed_updates, cursor_initialized
|
||||
)
|
||||
SELECT $1, confirmed_update_id, policy.allowed_updates, $7::boolean
|
||||
FROM boundary, configured, policy
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = CASE WHEN $7::boolean
|
||||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||||
ELSE bot_api_update_states.confirmed_update_id
|
||||
END,
|
||||
allowed_updates = EXCLUDED.allowed_updates,
|
||||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||||
updated_at = now()
|
||||
`, config.BotUserID, config.URL, config.SecretToken, config.MaxConnections, allowed,
|
||||
config.AllowedUpdatesSet, dropPending); err != nil {
|
||||
return fmt.Errorf("set bot api webhook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DeleteBotAPIWebhook(ctx context.Context, botUserID int64, dropPending bool) error {
|
||||
if botUserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH deleted AS (
|
||||
DELETE FROM bot_api_webhooks WHERE bot_user_id = $1 RETURNING bot_user_id
|
||||
), boundary AS (
|
||||
SELECT CASE WHEN $2::boolean THEN COALESCE(MAX(id), 0) ELSE 0 END AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, confirmed_update_id, $2::boolean
|
||||
FROM boundary
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = CASE WHEN $2::boolean
|
||||
THEN GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id)
|
||||
ELSE bot_api_update_states.confirmed_update_id
|
||||
END,
|
||||
cursor_initialized = bot_api_update_states.cursor_initialized OR EXCLUDED.cursor_initialized,
|
||||
updated_at = now()
|
||||
`, botUserID, dropPending); err != nil {
|
||||
return fmt.Errorf("delete bot api webhook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) BotAPIWebhook(ctx context.Context, botUserID int64) (domain.BotAPIWebhook, bool, error) {
|
||||
config, err := scanBotAPIWebhook(s.db.QueryRow(ctx, `
|
||||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||||
FROM bot_api_webhooks
|
||||
WHERE bot_user_id = $1
|
||||
`, botUserID))
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.BotAPIWebhook{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.BotAPIWebhook{}, false, fmt.Errorf("get bot api webhook: %w", err)
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListDueBotAPIWebhooks(ctx context.Context, limit int) ([]domain.BotAPIWebhook, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT bot_user_id, url, secret_token, max_connections, allowed_updates,
|
||||
failure_count, last_error_date, last_error_message, next_attempt_at
|
||||
FROM bot_api_webhooks
|
||||
WHERE next_attempt_at <= now()
|
||||
AND (delivery_owner = '' OR delivery_expires_at <= now())
|
||||
ORDER BY next_attempt_at, bot_user_id
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due bot api webhooks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.BotAPIWebhook, 0, limit)
|
||||
for rows.Next() {
|
||||
config, err := scanBotAPIWebhook(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, config)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list due bot api webhook rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api webhook lease")
|
||||
}
|
||||
var acquiredOwner string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET delivery_owner = $2,
|
||||
delivery_expires_at = now() + make_interval(secs => $3),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = $1
|
||||
AND (delivery_owner = $2 OR delivery_owner = '' OR delivery_expires_at <= now())
|
||||
RETURNING delivery_owner
|
||||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("acquire bot api webhook lease: %w", err)
|
||||
}
|
||||
return acquiredOwner == owner, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIWebhookLease(ctx context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner); err != nil {
|
||||
return fmt.Errorf("release bot api webhook lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookFailure(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time, message string) error {
|
||||
if len(message) > 512 {
|
||||
message = message[:512]
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET failure_count = failure_count + 1,
|
||||
last_error_date = EXTRACT(EPOCH FROM now())::integer,
|
||||
last_error_message = $3,
|
||||
next_attempt_at = $4,
|
||||
delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner, message, nextAttempt); err != nil {
|
||||
return fmt.Errorf("record bot api webhook failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) RecordBotAPIWebhookSuccess(ctx context.Context, botUserID int64, owner string, nextAttempt time.Time) error {
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_webhooks
|
||||
SET failure_count = 0, last_error_date = 0, last_error_message = '',
|
||||
next_attempt_at = $3, delivery_owner = '', delivery_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND delivery_owner = $2
|
||||
`, botUserID, owner, nextAttempt); err != nil {
|
||||
return fmt.Errorf("record bot api webhook success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanBotAPIWebhook(row botAPIUpdateScanner) (domain.BotAPIWebhook, error) {
|
||||
var config domain.BotAPIWebhook
|
||||
var allowed []string
|
||||
if err := row.Scan(&config.BotUserID, &config.URL, &config.SecretToken, &config.MaxConnections, &allowed,
|
||||
&config.FailureCount, &config.LastErrorDate, &config.LastErrorMessage, &config.NextAttemptAt); err != nil {
|
||||
return domain.BotAPIWebhook{}, err
|
||||
}
|
||||
if allowed != nil {
|
||||
config.AllowedUpdates = make([]domain.BotAPIUpdateKind, 0, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
config.AllowedUpdates = append(config.AllowedUpdates, domain.BotAPIUpdateKind(kind))
|
||||
}
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) AcquireBotAPIPollLease(ctx context.Context, botUserID int64, owner string, ttl time.Duration) (bool, error) {
|
||||
if botUserID <= 0 || owner == "" || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot api poll lease")
|
||||
}
|
||||
var acquiredOwner string
|
||||
err := s.db.QueryRow(ctx, `
|
||||
INSERT INTO bot_api_update_states (
|
||||
bot_user_id, confirmed_update_id, poll_owner, poll_expires_at
|
||||
) VALUES ($1, 0, $2, now() + make_interval(secs => $3))
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET poll_owner = EXCLUDED.poll_owner,
|
||||
poll_expires_at = EXCLUDED.poll_expires_at,
|
||||
updated_at = now()
|
||||
WHERE bot_api_update_states.poll_owner = EXCLUDED.poll_owner
|
||||
OR bot_api_update_states.poll_expires_at IS NULL
|
||||
OR bot_api_update_states.poll_expires_at <= now()
|
||||
RETURNING poll_owner
|
||||
`, botUserID, owner, int64((ttl+time.Second-1)/time.Second)).Scan(&acquiredOwner)
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("acquire bot api poll lease: %w", err)
|
||||
}
|
||||
return acquiredOwner == owner, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ReleaseBotAPIPollLease(ctx context.Context, botUserID int64, owner string) error {
|
||||
if botUserID <= 0 || owner == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE bot_api_update_states
|
||||
SET poll_owner = '', poll_expires_at = NULL, updated_at = now()
|
||||
WHERE bot_user_id = $1 AND poll_owner = $2
|
||||
`, botUserID, owner); err != nil {
|
||||
return fmt.Errorf("release bot api poll lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.EnqueueBotAPIUpdateRequest) (domain.BotAPIUpdate, bool, error) {
|
||||
if err := validateBotAPIUpdateRequest(req); err != nil {
|
||||
return domain.BotAPIUpdate{}, false, err
|
||||
}
|
||||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
if req.Callback != nil {
|
||||
callbackQueryID = req.Callback.ID
|
||||
callbackUserID = req.Callback.UserID
|
||||
callbackChatInstance = req.Callback.ChatInstance
|
||||
callbackData = req.Callback.Data
|
||||
if req.Callback.InlineMessage != nil {
|
||||
callbackInlineDCID = req.Callback.InlineMessage.DCID
|
||||
callbackInlineOwnerID = req.Callback.InlineMessage.OwnerID
|
||||
callbackInlineMessageID = req.Callback.InlineMessage.ID
|
||||
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
|
||||
}
|
||||
}
|
||||
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
INSERT INTO bot_api_updates (
|
||||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) DO NOTHING
|
||||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date))
|
||||
WITH inserted AS (
|
||||
INSERT INTO bot_api_updates (
|
||||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM bot_api_update_states
|
||||
WHERE bot_user_id = $1
|
||||
AND allowed_updates IS NOT NULL
|
||||
AND NOT ($2::text = ANY(allowed_updates))
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
), wake_webhook AS (
|
||||
UPDATE bot_api_webhooks
|
||||
SET next_attempt_at = now(), updated_at = now()
|
||||
WHERE bot_user_id = $1 AND EXISTS (SELECT 1 FROM inserted)
|
||||
RETURNING bot_user_id
|
||||
)
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
FROM inserted
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
|
||||
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
|
||||
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash))
|
||||
if err == nil {
|
||||
return row, true, nil
|
||||
}
|
||||
|
|
@ -38,21 +341,69 @@ RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_p
|
|||
return domain.BotAPIUpdate{}, false, fmt.Errorf("insert bot api update: %w", err)
|
||||
}
|
||||
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND update_kind = $2
|
||||
AND peer_type = $3
|
||||
AND peer_id = $4
|
||||
AND message_id = $5
|
||||
AND source_pts = $6
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts))
|
||||
AND (
|
||||
(update_kind = 'callback_query' AND callback_query_id = $7)
|
||||
OR
|
||||
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND source_pts = $6)
|
||||
)
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.BotAPIUpdate{}, false, nil
|
||||
}
|
||||
return domain.BotAPIUpdate{}, false, fmt.Errorf("select existing bot api update: %w", err)
|
||||
}
|
||||
return row, false, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID int64, tail, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 || tail <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
FROM (
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
) AS tail_updates
|
||||
ORDER BY id
|
||||
LIMIT $3
|
||||
`, botUserID, tail, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list bot api tail updates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.BotAPIUpdate, 0, limit)
|
||||
for rows.Next() {
|
||||
item, err := scanBotAPIUpdateRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list bot api tail update rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fromUpdateID int64, limit int) ([]domain.BotAPIUpdate, error) {
|
||||
if botUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -64,7 +415,9 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
|
|||
limit = 100
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1 AND id >= $2
|
||||
ORDER BY id
|
||||
|
|
@ -93,23 +446,98 @@ func (s *BotAPIUpdateStore) ConfirmBotAPIUpdates(ctx context.Context, botUserID,
|
|||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id)
|
||||
VALUES ($1, $2)
|
||||
WITH bounded AS (
|
||||
SELECT COALESCE(MAX(id), 0) AS max_update_id,
|
||||
LEAST($2::bigint, COALESCE(MAX(id), 0)) AS confirmed_update_id
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
)
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, confirmed_update_id, true
|
||||
FROM bounded
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
|
||||
SET confirmed_update_id = GREATEST(
|
||||
bot_api_update_states.confirmed_update_id,
|
||||
CASE
|
||||
WHEN $2::bigint > (SELECT max_update_id FROM bounded)
|
||||
AND bot_api_update_states.cursor_initialized
|
||||
THEN bot_api_update_states.confirmed_update_id
|
||||
ELSE EXCLUDED.confirmed_update_id
|
||||
END
|
||||
),
|
||||
cursor_initialized = true,
|
||||
updated_at = now()
|
||||
WHERE bot_api_update_states.confirmed_update_id < EXCLUDED.confirmed_update_id
|
||||
`, botUserID, confirmedUpdateID); err != nil {
|
||||
return fmt.Errorf("confirm bot api updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) SetBotAPIAllowedUpdates(ctx context.Context, botUserID int64, allowed []domain.BotAPIUpdateKind) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if len(allowed) > 0 {
|
||||
values = make([]string, 0, len(allowed))
|
||||
for _, kind := range allowed {
|
||||
if kind != "" {
|
||||
values = append(values, string(kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, allowed_updates)
|
||||
VALUES ($1, 0, $2::text[])
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET allowed_updates = EXCLUDED.allowed_updates,
|
||||
updated_at = now()
|
||||
`, botUserID, values); err != nil {
|
||||
return fmt.Errorf("set bot api allowed updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) DropPendingBotAPIUpdates(ctx context.Context, botUserID int64) error {
|
||||
if botUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO bot_api_update_states (bot_user_id, confirmed_update_id, cursor_initialized)
|
||||
SELECT $1, COALESCE(MAX(id), 0), true
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
ON CONFLICT (bot_user_id) DO UPDATE
|
||||
SET confirmed_update_id = GREATEST(bot_api_update_states.confirmed_update_id, EXCLUDED.confirmed_update_id),
|
||||
cursor_initialized = true,
|
||||
updated_at = now()
|
||||
`, botUserID); err != nil {
|
||||
return fmt.Errorf("drop pending bot api updates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotAPIUpdateStore) PendingBotAPIUpdateCount(ctx context.Context, botUserID int64) (int, error) {
|
||||
if botUserID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||||
`, botUserID).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count pending bot api updates: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// DeleteDeliveredOrExpired 回收 Bot API 投递队列的死行(性能审计 H1):
|
||||
// 1. 已确认(id <= bot_api_update_states.confirmed_update_id)且入队超过 confirmedGrace 的行——
|
||||
// 官方 Bot API 语义下确认即弃,getUpdates 的 fromID 恒 > confirmed,删除不影响任何读路径;
|
||||
// 宽限仅防御 offset 回拨调试场景。
|
||||
// 2. 按消息 date 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」
|
||||
// 2. 按队列 created_at 超过 maxAge 的行(无论确认与否)——对齐官方「updates 服务器最多保留 24 小时」
|
||||
// 语义,同时封顶 MTProto-only bot(从不调 getUpdates、无 state 行)成员身份带来的无界增长。
|
||||
//
|
||||
// 与 user_update_events 的「永久保留」约束无关:那是 TDesktop 账号级 differenceTooLong 缺陷所迫,
|
||||
|
|
@ -139,15 +567,15 @@ WHERE id IN (
|
|||
total += int(tag.RowsAffected())
|
||||
}
|
||||
if maxAge > 0 {
|
||||
cutoff := time.Now().Add(-maxAge).Unix()
|
||||
// 走 bot_api_updates_retention_idx(date, id)。
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
// 走 bot_api_updates_created_retention_idx(created_at, id)。
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
DELETE FROM bot_api_updates
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM bot_api_updates
|
||||
WHERE date < $1
|
||||
ORDER BY date, id
|
||||
WHERE created_at < $1
|
||||
ORDER BY created_at, id
|
||||
LIMIT $2
|
||||
)`, cutoff, limit)
|
||||
if err != nil {
|
||||
|
|
@ -187,28 +615,69 @@ type botAPIUpdateScanner interface {
|
|||
func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) {
|
||||
var item domain.BotAPIUpdate
|
||||
var kind, peerType string
|
||||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date); err != nil {
|
||||
var callbackQueryID, callbackUserID, callbackChatInstance int64
|
||||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
|
||||
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
|
||||
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil {
|
||||
return domain.BotAPIUpdate{}, err
|
||||
}
|
||||
item.Kind = domain.BotAPIUpdateKind(kind)
|
||||
item.Peer.Type = domain.PeerType(peerType)
|
||||
if item.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
item.Callback = &domain.BotCallbackQuery{
|
||||
ID: callbackQueryID,
|
||||
BotUserID: item.BotUserID,
|
||||
UserID: callbackUserID,
|
||||
Peer: item.Peer,
|
||||
MessageID: item.MessageID,
|
||||
ChatInstance: callbackChatInstance,
|
||||
Data: append([]byte(nil), callbackData...),
|
||||
}
|
||||
if callbackInlineMessageID > 0 {
|
||||
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
||||
if req.BotUserID == 0 || req.MessageID <= 0 {
|
||||
if req.BotUserID == 0 {
|
||||
return fmt.Errorf("invalid bot api update")
|
||||
}
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage {
|
||||
if req.Kind != domain.BotAPIUpdateMessage && req.Kind != domain.BotAPIUpdateEditedMessage && req.Kind != domain.BotAPIUpdateCallbackQuery {
|
||||
return fmt.Errorf("invalid bot api update kind %q", req.Kind)
|
||||
}
|
||||
switch req.Peer.Type {
|
||||
case domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
if req.Peer.ID <= 0 {
|
||||
if req.Peer.ID <= 0 || req.MessageID <= 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
case "":
|
||||
if req.Kind != domain.BotAPIUpdateCallbackQuery || req.Peer.ID != 0 || req.MessageID != 0 {
|
||||
return fmt.Errorf("invalid bot api update peer")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
|
||||
}
|
||||
if req.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
cb := req.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
|
||||
cb.Peer != req.Peer || cb.MessageID != req.MessageID || cb.ChatInstance == 0 ||
|
||||
len(cb.Data) > domain.MaxCallbackDataLen || req.SourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api callback query")
|
||||
}
|
||||
inline := cb.InlineMessage
|
||||
if req.MessageID == 0 && (inline == nil || inline.DCID <= 0 || inline.OwnerID <= 0 || inline.ID <= 0 || inline.AccessHash == 0) {
|
||||
return fmt.Errorf("invalid bot api inline callback query")
|
||||
}
|
||||
if req.MessageID > 0 && inline != nil {
|
||||
return fmt.Errorf("ambiguous bot api callback query")
|
||||
}
|
||||
} else if req.Callback != nil {
|
||||
return fmt.Errorf("unexpected bot api callback query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -8,10 +9,292 @@ import (
|
|||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestBotAPICallbackQueryQueueRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 921, Phone: "+1921" + suffix + "01", FirstName: "CallbackQueueBot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot user: %v", err)
|
||||
}
|
||||
clicker, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 922, Phone: "+1922" + suffix + "02", FirstName: "CallbackClicker",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create callback user: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO bots (bot_user_id, owner_user_id, token_secret)
|
||||
VALUES ($1, $1, 'callback-queue-secret')`, bot.ID); err != nil {
|
||||
t.Fatalf("seed bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 880011, BotUserID: bot.ID, UserID: clicker.ID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: clicker.ID}, MessageID: 17,
|
||||
ChatInstance: 990022, Data: []byte{0, 1, 0xff, 'x'},
|
||||
}
|
||||
req := domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery,
|
||||
Peer: callback.Peer, MessageID: callback.MessageID, Date: int(time.Now().Unix()), Callback: callback,
|
||||
}
|
||||
store := NewBotAPIUpdateStore(pool)
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, req)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue callback: row=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
again, created, err := store.EnqueueBotAPIUpdate(ctx, req)
|
||||
if err != nil || created || again.ID != first.ID {
|
||||
t.Fatalf("dedupe callback: row=%+v created=%v err=%v", again, created, err)
|
||||
}
|
||||
items, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("list callback = %+v, %v", items, err)
|
||||
}
|
||||
got := items[0].Callback
|
||||
if got == nil || got.ID != callback.ID || got.BotUserID != bot.ID || got.UserID != clicker.ID ||
|
||||
got.Peer != callback.Peer || got.MessageID != callback.MessageID || got.ChatInstance != callback.ChatInstance ||
|
||||
!bytes.Equal(got.Data, callback.Data) {
|
||||
t.Fatalf("callback round trip = %+v, want %+v", got, callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIInlineCallbackAndWebhookStateRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 931, Phone: "+1931" + suffix + "01", FirstName: "WebhookBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clicker, err := users.Create(ctx, domain.User{AccessHash: 932, Phone: "+1932" + suffix + "02", FirstName: "InlineClicker"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'webhook-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_webhooks WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
inline := &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 17, AccessHash: 445566}
|
||||
callback := &domain.BotCallbackQuery{
|
||||
ID: 9911, BotUserID: bot.ID, UserID: clicker.ID, ChatInstance: 8811,
|
||||
Data: []byte{0, 1, 0xff}, InlineMessage: inline,
|
||||
}
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: callback,
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("enqueue inline callback row=%#v created=%v err=%v", row, created, err)
|
||||
}
|
||||
items, err := s.ListBotAPIUpdates(ctx, bot.ID, row.ID, 100)
|
||||
if err != nil || len(items) != 1 || items[0].Peer != (domain.Peer{}) || items[0].MessageID != 0 ||
|
||||
items[0].Callback == nil || items[0].Callback.InlineMessage == nil || *items[0].Callback.InlineMessage != *inline ||
|
||||
!bytes.Equal(items[0].Callback.Data, callback.Data) {
|
||||
t.Fatalf("inline callback items=%#v err=%v", items, err)
|
||||
}
|
||||
|
||||
config := domain.BotAPIWebhook{
|
||||
BotUserID: bot.ID, URL: "https://example.test/hook", SecretToken: "safe_secret",
|
||||
MaxConnections: 8, AllowedUpdates: []domain.BotAPIUpdateKind{domain.BotAPIUpdateCallbackQuery}, AllowedUpdatesSet: true,
|
||||
}
|
||||
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err := s.BotAPIWebhook(ctx, bot.ID)
|
||||
if err != nil || !found || stored.URL != config.URL || stored.SecretToken != config.SecretToken ||
|
||||
stored.MaxConnections != 8 || len(stored.AllowedUpdates) != 1 {
|
||||
t.Fatalf("webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
config.URL = "https://example.test/reconfigured"
|
||||
config.AllowedUpdates = nil
|
||||
config.AllowedUpdatesSet = false
|
||||
if err := s.SetBotAPIWebhook(ctx, config, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, found, err = s.BotAPIWebhook(ctx, bot.ID)
|
||||
if err != nil || !found || stored.URL != config.URL || len(stored.AllowedUpdates) != 1 || stored.AllowedUpdates[0] != domain.BotAPIUpdateCallbackQuery {
|
||||
t.Fatalf("preserved webhook=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("first lease=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
|
||||
t.Fatalf("second lease=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := s.ReleaseBotAPIWebhookLease(ctx, bot.ID, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := s.AcquireBotAPIWebhookLease(ctx, bot.ID, "two", time.Minute); acquired {
|
||||
t.Fatal("stale webhook release removed active lease")
|
||||
}
|
||||
next := time.Now().Add(time.Hour)
|
||||
if err := s.RecordBotAPIWebhookSuccess(ctx, bot.ID, "one", next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 0 {
|
||||
t.Fatalf("idle due=%#v err=%v", due, err)
|
||||
}
|
||||
// A newly inserted allowed callback wakes the idle webhook in the same SQL statement.
|
||||
callback2 := *callback
|
||||
callback2.ID++
|
||||
callback2.InlineMessage = &domain.BotInlineMessageID{DCID: 2, OwnerID: clicker.ID, ID: 18, AccessHash: 556677}
|
||||
if _, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateCallbackQuery, Date: int(time.Now().Unix()), Callback: &callback2,
|
||||
}); err != nil || !created {
|
||||
t.Fatalf("enqueue wake created=%v err=%v", created, err)
|
||||
}
|
||||
if due, err := s.ListDueBotAPIWebhooks(ctx, 10); err != nil || len(due) != 1 || due[0].BotUserID != bot.ID {
|
||||
t.Fatalf("woken due=%#v err=%v", due, err)
|
||||
}
|
||||
if err := s.DeleteBotAPIWebhook(ctx, bot.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found, err := s.BotAPIWebhook(ctx, bot.ID); err != nil || found {
|
||||
t.Fatalf("webhook after delete found=%v err=%v", found, err)
|
||||
}
|
||||
if pending, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || pending != 0 {
|
||||
t.Fatalf("pending after delete/drop=%d err=%v", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollLeaseCrossStoreInstance(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 933, Phone: "+1933" + suffix + "01", FirstName: "PollLeaseBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-lease-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
a, b := NewBotAPIUpdateStore(pool), NewBotAPIUpdateStore(pool)
|
||||
if acquired, err := a.AcquireBotAPIPollLease(ctx, bot.ID, "one", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("first acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || acquired {
|
||||
t.Fatalf("cross-instance acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
if err := b.ReleaseBotAPIPollLease(ctx, bot.ID, "stale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, _ := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); acquired {
|
||||
t.Fatal("stale release removed active poll lease")
|
||||
}
|
||||
if err := a.ReleaseBotAPIPollLease(ctx, bot.ID, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired, err := b.AcquireBotAPIPollLease(ctx, bot.ID, "two", time.Minute); err != nil || !acquired {
|
||||
t.Fatalf("successor acquire=%v err=%v", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIPollingStateClampFilterTailAndDrop(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
bot, err := users.Create(ctx, domain.User{
|
||||
AccessHash: 923, Phone: "+1923" + suffix + "01", FirstName: "PollingStateBot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create bot user: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'poll-state-secret')`, bot.ID); err != nil {
|
||||
t.Fatalf("seed bot: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
enqueue := func(kind domain.BotAPIUpdateKind, messageID int) (domain.BotAPIUpdate, bool) {
|
||||
t.Helper()
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: kind,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bot.ID + 1},
|
||||
MessageID: messageID, SourcePts: messageID, Date: int(time.Now().Unix()),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue %s/%d: %v", kind, messageID, err)
|
||||
}
|
||||
return row, created
|
||||
}
|
||||
for id := 1; id <= 3; id++ {
|
||||
if _, created := enqueue(domain.BotAPIUpdateMessage, id); !created {
|
||||
t.Fatalf("initial message %d was not created", id)
|
||||
}
|
||||
}
|
||||
if err := s.SetBotAPIAllowedUpdates(ctx, bot.ID, []domain.BotAPIUpdateKind{domain.BotAPIUpdateEditedMessage}); err != nil {
|
||||
t.Fatalf("set allowed updates: %v", err)
|
||||
}
|
||||
if row, created := enqueue(domain.BotAPIUpdateMessage, 4); created || row.ID != 0 {
|
||||
t.Fatalf("filtered row=%+v created=%v", row, created)
|
||||
}
|
||||
lastBeforeBaseline, created := enqueue(domain.BotAPIUpdateEditedMessage, 5)
|
||||
if !created {
|
||||
t.Fatal("allowed edit was filtered")
|
||||
}
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
|
||||
t.Fatalf("initialize external cursor: %v", err)
|
||||
}
|
||||
confirmed, found, err := s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
|
||||
if err != nil || !found || confirmed != lastBeforeBaseline.ID {
|
||||
t.Fatalf("baseline confirmed=%d found=%v err=%v want=%d", confirmed, found, err, lastBeforeBaseline.ID)
|
||||
}
|
||||
pendingRow, created := enqueue(domain.BotAPIUpdateEditedMessage, 6)
|
||||
if !created {
|
||||
t.Fatal("post-baseline edit was filtered")
|
||||
}
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, bot.ID, 1<<60); err != nil {
|
||||
t.Fatalf("repeat external cursor: %v", err)
|
||||
}
|
||||
confirmed, _, _ = s.ConfirmedBotAPIUpdateID(ctx, bot.ID)
|
||||
if confirmed != lastBeforeBaseline.ID {
|
||||
t.Fatalf("repeat external cursor advanced to %d, want %d", confirmed, lastBeforeBaseline.ID)
|
||||
}
|
||||
tail, err := s.ListTailBotAPIUpdates(ctx, bot.ID, 1, 100)
|
||||
if err != nil || len(tail) != 1 || tail[0].ID != pendingRow.ID {
|
||||
t.Fatalf("tail=%+v err=%v want=%d", tail, err, pendingRow.ID)
|
||||
}
|
||||
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 1 {
|
||||
t.Fatalf("pending count=%d err=%v", count, err)
|
||||
}
|
||||
if err := s.DropPendingBotAPIUpdates(ctx, bot.ID); err != nil {
|
||||
t.Fatalf("drop pending: %v", err)
|
||||
}
|
||||
if count, err := s.PendingBotAPIUpdateCount(ctx, bot.ID); err != nil || count != 0 {
|
||||
t.Fatalf("pending after drop=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotAPIUpdateRetention 锁定 H1 场景矩阵:
|
||||
// - 已确认 + 超宽限 → 删;已确认 + 宽限内 → 留;
|
||||
// - 未确认 + date 超保留期 → 删(含无 state 行的 MTProto-only bot);
|
||||
// - 未确认 + date 在保留期内 → 留;
|
||||
// - 未确认 + created_at 超保留期 → 删(含无 state 行的 MTProto-only bot);
|
||||
// - 未确认 + created_at 在保留期内 → 留;
|
||||
// - 删除后 getUpdates 读路径(fromID > confirmed)不受影响。
|
||||
func TestBotAPIUpdateRetention(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
|
|
@ -47,7 +330,6 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
|
||||
s := NewBotAPIUpdateStore(pool)
|
||||
now := time.Now().Unix()
|
||||
stale := now - int64((48 * time.Hour).Seconds())
|
||||
enqueue := func(botID int64, messageID int, date int64) domain.BotAPIUpdate {
|
||||
t.Helper()
|
||||
row, created, err := s.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{
|
||||
|
|
@ -67,7 +349,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
confirmedOld := enqueue(confirmedBot, 1, now) // 已确认 + created_at 回拨超宽限 → 删
|
||||
confirmedFresh := enqueue(confirmedBot, 2, now) // 已确认 + 宽限内 → 留
|
||||
unconfirmedFresh := enqueue(confirmedBot, 3, now)
|
||||
expiredNoState := enqueue(mtprotoOnlyBot, 4, stale) // 无 state 行 + date 超保留期 → 删
|
||||
expiredNoState := enqueue(mtprotoOnlyBot, 4, now) // 无 state 行 + created_at 超保留期 → 删
|
||||
freshNoState := enqueue(mtprotoOnlyBot, 5, now)
|
||||
|
||||
if err := s.ConfirmBotAPIUpdates(ctx, confirmedBot, confirmedFresh.ID); err != nil {
|
||||
|
|
@ -77,6 +359,10 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
"UPDATE bot_api_updates SET created_at = now() - interval '1 hour' WHERE id = $1", confirmedOld.ID); err != nil {
|
||||
t.Fatalf("backdate confirmed row: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
"UPDATE bot_api_updates SET created_at = now() - interval '48 hours' WHERE id = $1", expiredNoState.ID); err != nil {
|
||||
t.Fatalf("backdate expired row: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteDeliveredOrExpired(ctx, 15*time.Minute, 24*time.Hour, 1000)
|
||||
if err != nil {
|
||||
|
|
@ -85,7 +371,7 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
// 共享测试库可能有其它历史行同被回收,只要求至少删掉本测试的 2 行;
|
||||
// 精确归属由下方 remaining 断言保证。
|
||||
if deleted < 2 {
|
||||
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, date expired)", deleted)
|
||||
t.Fatalf("deleted = %d, want >= 2 (confirmed+grace expired, created_at expired)", deleted)
|
||||
}
|
||||
|
||||
remaining := map[int64]bool{}
|
||||
|
|
|
|||
|
|
@ -550,6 +550,56 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
|
|||
return photo, true, nil
|
||||
}
|
||||
|
||||
// GetPhotos resolves a bounded set of immutable photo metadata with one indexed
|
||||
// ANY query. Missing ids are omitted and the result follows first-seen caller order.
|
||||
func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
unique := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text
|
||||
FROM photos
|
||||
WHERE id = ANY($1::bigint[])
|
||||
`, unique)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[int64]domain.Photo, len(unique))
|
||||
for rows.Next() {
|
||||
photo, err := scanPhotoRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID[photo.ID] = photo
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Photo, 0, len(byID))
|
||||
for _, id := range unique {
|
||||
if photo, ok := byID[id]; ok {
|
||||
out = append(out, photo)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type photoScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -44,9 +45,12 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) {
|
|||
return &m, nil
|
||||
}
|
||||
|
||||
// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。
|
||||
// encodeReplyMarkup 把 reply/inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。
|
||||
// callback data 是 []byte,json.Marshal 自动 base64(保证经 JSONB 字节级 round-trip)。
|
||||
func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) {
|
||||
if err := domain.ValidateReplyMarkup(m); err != nil {
|
||||
return nil, fmt.Errorf("encode reply markup: %w", err)
|
||||
}
|
||||
if m.IsZero() {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
|
@ -63,6 +67,9 @@ func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) {
|
|||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := domain.ValidateReplyMarkup(&m); err != nil {
|
||||
return nil, fmt.Errorf("decode reply markup: %w", err)
|
||||
}
|
||||
if m.IsZero() {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ func TestMediaStoreRoundTrip(t *testing.T) {
|
|||
if err != nil || !ok || len(gotPhoto.Sizes) != 1 || gotPhoto.Sizes[0].Type != "x" {
|
||||
t.Fatalf("get photo mismatch: ok=%v err=%v photo=%+v", ok, err, gotPhoto)
|
||||
}
|
||||
photos, err := s.GetPhotos(ctx, []int64{photoID, 0, photoID, photoID + 99})
|
||||
if err != nil || len(photos) != 1 || photos[0].ID != photoID || len(photos[0].Sizes) != 1 {
|
||||
t.Fatalf("get photos mismatch: photos=%+v err=%v", photos, err)
|
||||
}
|
||||
|
||||
// ---- sticker set ----
|
||||
set := domain.StickerSet{
|
||||
|
|
|
|||
|
|
@ -51,6 +51,27 @@ func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (d
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// GetByUID resolves one owner's box row by the indexed shared private_message_id.
|
||||
func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.Message, bool, error) {
|
||||
if userID == 0 || uid == 0 {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
row, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
||||
OwnerUserID: userID,
|
||||
PrivateMessageID: uid,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Message{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Message{}, false, fmt.Errorf("get message by uid: %w", err)
|
||||
}
|
||||
if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil {
|
||||
return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err)
|
||||
}
|
||||
return messageFromGetBoxRow(row), true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
|
|
|
|||
42
internal/store/postgres/message_markup_codec_test.go
Normal file
42
internal/store/postgres/message_markup_codec_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestReplyMarkupCodecValidatesTaggedUnion(t *testing.T) {
|
||||
keyboard := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupKeyboard,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "Help"}}},
|
||||
Resize: true,
|
||||
Placeholder: "Choose",
|
||||
}
|
||||
raw, err := encodeReplyMarkup(keyboard)
|
||||
if err != nil {
|
||||
t.Fatalf("encode reply keyboard: %v", err)
|
||||
}
|
||||
got, err := decodeReplyMarkup(string(raw))
|
||||
if err != nil || got == nil || got.Kind() != domain.MessageReplyMarkupKeyboard ||
|
||||
len(got.Keyboard) != 1 || got.Keyboard[0][0].Text != "Help" || !got.Resize || got.Placeholder != "Choose" {
|
||||
t.Fatalf("decoded reply keyboard = %#v, err=%v", got, err)
|
||||
}
|
||||
|
||||
// Pre-union inline snapshots intentionally remain readable.
|
||||
legacy, err := decodeReplyMarkup(`{"inline":[[{"type":"callback","text":"OK","data":"b2s="}]]}`)
|
||||
if err != nil || legacy == nil || legacy.Kind() != domain.MessageReplyMarkupInline {
|
||||
t.Fatalf("legacy inline markup = %#v, err=%v", legacy, err)
|
||||
}
|
||||
|
||||
malformed := &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupInline,
|
||||
Keyboard: [][]domain.MarkupButton{{{Type: domain.MarkupButtonText, Text: "wrong"}}},
|
||||
}
|
||||
if _, err := encodeReplyMarkup(malformed); err == nil {
|
||||
t.Fatal("malformed union must fail at the write boundary")
|
||||
}
|
||||
if _, err := decodeReplyMarkup(`{"type":"inline","keyboard":[[{"type":"text","text":"wrong"}]]}`); err == nil {
|
||||
t.Fatal("malformed stored union must fail at the read boundary")
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +117,7 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
// reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。
|
||||
// reply_markup(bot reply/inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。
|
||||
replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
|
|
|
|||
|
|
@ -205,6 +205,70 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
|
|||
assertWebViewData("recipient event", events[0].Message)
|
||||
}
|
||||
|
||||
func TestMessageStoreRequestedPeerDisclosureSnapshotRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
||||
users := NewUserStore(pool)
|
||||
sender := createTestUser(t, ctx, users, "+1666"+suffix+"33", "RequestedSender", "")
|
||||
recipient := createTestUser(t, ctx, users, "+1666"+suffix+"34", "RequestedRecipient", "")
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID})
|
||||
})
|
||||
|
||||
requestedPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 5501}
|
||||
photo := domain.Photo{ID: 8201, Sizes: []domain.PhotoSize{{
|
||||
Kind: domain.PhotoSizeKindDefault, Type: "m", W: 320, H: 320, Size: 4096,
|
||||
}}}
|
||||
messages := NewMessageStore(pool)
|
||||
got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
|
||||
SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 9002, Date: 1700000212,
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindService, ServiceAction: &domain.MessageServiceAction{
|
||||
Kind: domain.MessageServiceActionRequestedPeer,
|
||||
RequestedPeer: &domain.MessageRequestedPeerAction{
|
||||
ButtonID: 88, Peers: []domain.Peer{requestedPeer},
|
||||
Details: []domain.MessageRequestedPeerDetails{{
|
||||
Peer: requestedPeer, Title: "Shared Chat", Username: "shared_chat", Photo: &photo,
|
||||
}},
|
||||
NameRequested: true, UsernameRequested: true, PhotoRequested: true,
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendPrivateText: %v", err)
|
||||
}
|
||||
assertSnapshot := func(name string, msg domain.Message) {
|
||||
t.Helper()
|
||||
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.RequestedPeer == nil {
|
||||
t.Fatalf("%s media=%+v, want requested-peer action", name, msg.Media)
|
||||
}
|
||||
action := msg.Media.ServiceAction.RequestedPeer
|
||||
if action.ButtonID != 88 || len(action.Peers) != 1 || action.Peers[0] != requestedPeer ||
|
||||
len(action.Details) != 1 || action.Details[0].Title != "Shared Chat" ||
|
||||
action.Details[0].Username != "shared_chat" || action.Details[0].Photo == nil ||
|
||||
len(action.Details[0].Photo.Sizes) != 1 || action.Details[0].Photo.Sizes[0].W != 320 ||
|
||||
!action.NameRequested || !action.UsernameRequested || !action.PhotoRequested {
|
||||
t.Fatalf("%s requested-peer=%+v", name, action)
|
||||
}
|
||||
}
|
||||
assertSnapshot("sender", got.SenderMessage)
|
||||
assertSnapshot("recipient", got.RecipientMessage)
|
||||
|
||||
history, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{
|
||||
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10,
|
||||
})
|
||||
if err != nil || len(history.Messages) != 1 {
|
||||
t.Fatalf("recipient history=%+v err=%v", history, err)
|
||||
}
|
||||
assertSnapshot("recipient history", history.Messages[0])
|
||||
events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("recipient events=%+v err=%v", events, err)
|
||||
}
|
||||
assertSnapshot("recipient event", events[0].Message)
|
||||
}
|
||||
|
||||
func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -294,21 +294,33 @@ type Bot struct {
|
|||
}
|
||||
|
||||
type BotApiUpdate struct {
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UpdateKind string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageID int32
|
||||
SourcePts int32
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ID int64
|
||||
BotUserID int64
|
||||
UpdateKind string
|
||||
PeerType string
|
||||
PeerID int64
|
||||
MessageID int32
|
||||
SourcePts int32
|
||||
Date int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
CallbackQueryID int64
|
||||
CallbackUserID int64
|
||||
CallbackChatInstance int64
|
||||
CallbackData []byte
|
||||
CallbackInlineDcID int32
|
||||
CallbackInlineOwnerID int64
|
||||
CallbackInlineMessageID int32
|
||||
CallbackInlineAccessHash int64
|
||||
}
|
||||
|
||||
type BotApiUpdateState struct {
|
||||
BotUserID int64
|
||||
ConfirmedUpdateID int64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
AllowedUpdates []string
|
||||
CursorInitialized bool
|
||||
PollOwner string
|
||||
PollExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BotApp struct {
|
||||
|
|
|
|||
150
internal/store/redisstore/bot_callback.go
Normal file
150
internal/store/redisstore/bot_callback.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const botCallbackAnswerChannel = "telesrv:bot_callback:answers"
|
||||
|
||||
type BotCallbackRegistryStore struct {
|
||||
c redis.UniversalClient
|
||||
}
|
||||
|
||||
func NewBotCallbackRegistryStore(c redis.UniversalClient) *BotCallbackRegistryStore {
|
||||
return &BotCallbackRegistryStore{c: c}
|
||||
}
|
||||
|
||||
func botCallbackKey(queryID int64) string {
|
||||
return fmt.Sprintf("telesrv:bot_callback:%d", queryID)
|
||||
}
|
||||
|
||||
var putBotCallbackScript = redis.NewScript(`
|
||||
if redis.call('EXISTS', KEYS[1]) ~= 0 then
|
||||
return 0
|
||||
end
|
||||
redis.call('HSET', KEYS[1],
|
||||
'bot_user_id', ARGV[1],
|
||||
'user_id', ARGV[2],
|
||||
'created_at_unix_nano', ARGV[3])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[4])
|
||||
return 1
|
||||
`)
|
||||
|
||||
func (s *BotCallbackRegistryStore) PutBotCallbackPending(ctx context.Context, pending store.BotCallbackPending, ttl time.Duration) (bool, error) {
|
||||
if s == nil || s.c == nil || pending.QueryID == 0 || pending.BotUserID <= 0 || pending.UserID <= 0 || ttl <= 0 {
|
||||
return false, fmt.Errorf("invalid bot callback pending")
|
||||
}
|
||||
createdAt := pending.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now()
|
||||
}
|
||||
result, err := putBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(pending.QueryID)},
|
||||
pending.BotUserID, pending.UserID, createdAt.UnixNano(), ttl.Milliseconds()).Int64()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("put bot callback pending: %w", err)
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
var resolveBotCallbackScript = redis.NewScript(`
|
||||
if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
if redis.call('HEXISTS', KEYS[1], 'answer') ~= 0 then
|
||||
return 0
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'answer', ARGV[2])
|
||||
redis.call('PUBLISH', ARGV[3], ARGV[4])
|
||||
return 1
|
||||
`)
|
||||
|
||||
func (s *BotCallbackRegistryStore) ResolveBotCallback(ctx context.Context, botUserID, queryID int64, answer domain.BotCallbackAnswer) (bool, error) {
|
||||
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
answerJSON, err := json.Marshal(answer)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal bot callback answer: %w", err)
|
||||
}
|
||||
pushJSON, err := json.Marshal(store.BotCallbackAnswerPush{QueryID: queryID, BotUserID: botUserID, Answer: answer})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal bot callback answer push: %w", err)
|
||||
}
|
||||
result, err := resolveBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)},
|
||||
strconv.FormatInt(botUserID, 10), answerJSON, botCallbackAnswerChannel, pushJSON).Int64()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve bot callback: %w", err)
|
||||
}
|
||||
return result == 1, nil
|
||||
}
|
||||
|
||||
func (s *BotCallbackRegistryStore) GetBotCallbackAnswer(ctx context.Context, botUserID, queryID int64) (domain.BotCallbackAnswer, bool, error) {
|
||||
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
|
||||
return domain.BotCallbackAnswer{}, false, nil
|
||||
}
|
||||
values, err := s.c.HMGet(ctx, botCallbackKey(queryID), "bot_user_id", "answer").Result()
|
||||
if err != nil {
|
||||
return domain.BotCallbackAnswer{}, false, fmt.Errorf("get bot callback answer: %w", err)
|
||||
}
|
||||
if len(values) != 2 || values[0] == nil || values[1] == nil || fmt.Sprint(values[0]) != strconv.FormatInt(botUserID, 10) {
|
||||
return domain.BotCallbackAnswer{}, false, nil
|
||||
}
|
||||
var answer domain.BotCallbackAnswer
|
||||
if err := json.Unmarshal([]byte(fmt.Sprint(values[1])), &answer); err != nil {
|
||||
return domain.BotCallbackAnswer{}, false, fmt.Errorf("decode bot callback answer: %w", err)
|
||||
}
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
var deleteBotCallbackScript = redis.NewScript(`
|
||||
if redis.call('HGET', KEYS[1], 'bot_user_id') ~= ARGV[1] then
|
||||
return 0
|
||||
end
|
||||
return redis.call('DEL', KEYS[1])
|
||||
`)
|
||||
|
||||
func (s *BotCallbackRegistryStore) DeleteBotCallbackPending(ctx context.Context, botUserID, queryID int64) error {
|
||||
if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := deleteBotCallbackScript.Run(ctx, s.c, []string{botCallbackKey(queryID)}, strconv.FormatInt(botUserID, 10)).Result(); err != nil && err != redis.Nil {
|
||||
return fmt.Errorf("delete bot callback pending: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BotCallbackRegistryStore) SubscribeBotCallbackAnswers(ctx context.Context, handle func(context.Context, store.BotCallbackAnswerPush)) error {
|
||||
if s == nil || s.c == nil || handle == nil {
|
||||
return nil
|
||||
}
|
||||
pubsub := s.c.Subscribe(ctx, botCallbackAnswerChannel)
|
||||
defer pubsub.Close()
|
||||
if _, err := pubsub.Receive(ctx); err != nil {
|
||||
return fmt.Errorf("subscribe bot callback answers: %w", err)
|
||||
}
|
||||
channel := pubsub.Channel(redis.WithChannelSize(256))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case message, ok := <-channel:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var push store.BotCallbackAnswerPush
|
||||
if err := json.Unmarshal([]byte(message.Payload), &push); err != nil || push.QueryID == 0 || push.BotUserID <= 0 {
|
||||
continue
|
||||
}
|
||||
handle(ctx, push)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/store/redisstore/bot_callback_integration_test.go
Normal file
76
internal/store/redisstore/bot_callback_integration_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package redisstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestRedisBotCallbackRegistryCrossInstanceCASAndPubSub(t *testing.T) {
|
||||
addr := os.Getenv("TELESRV_TEST_REDIS_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
clientA, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer clientA.Close()
|
||||
clientB, err := Open(ctx, addr, "", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer clientB.Close()
|
||||
a, b := NewBotCallbackRegistryStore(clientA), NewBotCallbackRegistryStore(clientB)
|
||||
queryID := time.Now().UnixNano()
|
||||
defer a.DeleteBotCallbackPending(context.Background(), 1001, queryID)
|
||||
pushes := make(chan store.BotCallbackAnswerPush, 1)
|
||||
subscribed := make(chan struct{})
|
||||
go func() {
|
||||
_ = b.SubscribeBotCallbackAnswers(ctx, func(_ context.Context, push store.BotCallbackAnswerPush) {
|
||||
select {
|
||||
case pushes <- push:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}()
|
||||
// Subscribe uses Redis' acknowledgement before consuming Channel. Give that
|
||||
// acknowledgement one bounded scheduling turn before publishing.
|
||||
time.AfterFunc(50*time.Millisecond, func() { close(subscribed) })
|
||||
<-subscribed
|
||||
created, err := a.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2001}, time.Second)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("put created=%v err=%v", created, err)
|
||||
}
|
||||
if duplicate, err := b.PutBotCallbackPending(ctx, store.BotCallbackPending{QueryID: queryID, BotUserID: 1001, UserID: 2002}, time.Second); err != nil || duplicate {
|
||||
t.Fatalf("duplicate=%v err=%v", duplicate, err)
|
||||
}
|
||||
answer := domain.BotCallbackAnswer{Message: "done", CacheTime: 3}
|
||||
if resolved, err := b.ResolveBotCallback(ctx, 9999, queryID, answer); err != nil || resolved {
|
||||
t.Fatalf("foreign resolve=%v err=%v", resolved, err)
|
||||
}
|
||||
if resolved, err := b.ResolveBotCallback(ctx, 1001, queryID, answer); err != nil || !resolved {
|
||||
t.Fatalf("owner resolve=%v err=%v", resolved, err)
|
||||
}
|
||||
if second, err := a.ResolveBotCallback(ctx, 1001, queryID, domain.BotCallbackAnswer{Message: "second"}); err != nil || second {
|
||||
t.Fatalf("second resolve=%v err=%v", second, err)
|
||||
}
|
||||
stored, found, err := a.GetBotCallbackAnswer(ctx, 1001, queryID)
|
||||
if err != nil || !found || stored.Message != "done" {
|
||||
t.Fatalf("stored=%#v found=%v err=%v", stored, found, err)
|
||||
}
|
||||
select {
|
||||
case push := <-pushes:
|
||||
if push.QueryID != queryID || push.BotUserID != 1001 || push.Answer.Message != "done" {
|
||||
t.Fatalf("push=%#v", push)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("missing cross-instance callback pubsub")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue