feat: sync bot keyboards and callbacks

Sync telesrv b96f2dd (feat(bot): complete keyboards callbacks and durable delivery).

Skipped private docs and preserved public README files per sync rules; normalized the appearance seed log label for public naming.
This commit is contained in:
A 2026-07-19 20:38:48 +08:00
parent 0c99ae0a9d
commit bf965f610c
80 changed files with 7212 additions and 349 deletions

View file

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

View file

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

View file

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

View file

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