feat: sync ephemeral transient messages
Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
|
|
@ -27,7 +27,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
|
|||
|
||||
before, _, _ := users.ByID(ctx, bot.ID)
|
||||
v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{
|
||||
{Command: "/Start", Description: "begin"},
|
||||
{Command: "/Start", Description: "begin", Ephemeral: true},
|
||||
{Command: "help", Description: "show help"},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -40,7 +40,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("get commands: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" {
|
||||
if len(got) != 2 || got[0].Command != "start" || !got[0].Ephemeral || got[1].Command != "help" || got[1].Ephemeral {
|
||||
t.Fatalf("commands = %+v, want normalized [start,help]", got)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -497,7 +497,7 @@ func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands
|
|||
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
|
||||
return 0, domain.ErrBotCommandInvalid
|
||||
}
|
||||
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc})
|
||||
clean = append(clean, domain.BotCommand{Command: cmd, Description: desc, Ephemeral: c.Ephemeral})
|
||||
}
|
||||
// 同值短路:bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
|
||||
// bot_info_version bump(驱动全体客户端多打一轮 getFullUser)与多余推送。
|
||||
|
|
@ -528,7 +528,7 @@ func botCommandsEqual(a, b []domain.BotCommand) bool {
|
|||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Command != b[i].Command || a[i].Description != b[i].Description {
|
||||
if a[i].Command != b[i].Command || a[i].Description != b[i].Description || a[i].Ephemeral != b[i].Ephemeral {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
617
internal/app/ephemeral/service.go
Normal file
617
internal/app/ephemeral/service.go
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
package ephemeral
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type ChannelAccess interface {
|
||||
ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
|
||||
GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
|
||||
}
|
||||
|
||||
type UserDirectory interface {
|
||||
ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error)
|
||||
}
|
||||
|
||||
type BotCommands interface {
|
||||
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(s *Service) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithIDGenerator(next func() (int, error)) Option {
|
||||
return func(s *Service) {
|
||||
if next != nil {
|
||||
s.nextID = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
messages store.EphemeralMessageStore
|
||||
channels ChannelAccess
|
||||
users UserDirectory
|
||||
bots BotCommands
|
||||
now func() time.Time
|
||||
nextID func() (int, error)
|
||||
}
|
||||
|
||||
func NewService(messages store.EphemeralMessageStore, channels ChannelAccess, users UserDirectory, bots BotCommands, options ...Option) *Service {
|
||||
s := &Service{
|
||||
messages: messages,
|
||||
channels: channels,
|
||||
users: users,
|
||||
bots: bots,
|
||||
now: time.Now,
|
||||
nextID: randomEphemeralID,
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) {
|
||||
if s == nil || s.messages == nil || s.channels == nil || s.users == nil || s.bots == nil {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
if request.SenderUserID <= 0 || request.ReceiverBotID <= 0 || request.SenderUserID == request.ReceiverBotID ||
|
||||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 || request.RandomID == 0 ||
|
||||
request.OriginDevice.UserID != request.SenderUserID || request.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
|
||||
request.OriginDevice.SessionID == 0 || !validContent(request.Content) {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
view, err := s.requireActiveGroupPair(ctx, request.SenderUserID, request.ReceiverBotID, request.Peer.ID)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
receiver, found, err := s.users.ByID(ctx, request.SenderUserID, request.ReceiverBotID)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || !receiver.Bot || receiver.Deleted {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
|
||||
}
|
||||
var replyTarget *domain.EphemeralMessage
|
||||
if request.ReplyToEphemeralID != 0 {
|
||||
target, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, s.now())
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || target.Deleted || target.SenderUserID != request.ReceiverBotID || target.ReceiverUserID != request.SenderUserID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||
}
|
||||
if target.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && target.OriginDevice.BusinessAuthKeyID != request.OriginDevice.BusinessAuthKeyID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||
}
|
||||
if request.TopMessageID != 0 && request.TopMessageID != target.TopMessageID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
request.TopMessageID = target.TopMessageID
|
||||
replyTarget = &target
|
||||
} else {
|
||||
allowed, err := s.isEphemeralCommand(ctx, receiver, request.Content.Message)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !allowed {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralCommandInvalid
|
||||
}
|
||||
}
|
||||
if err := s.validateForumTopic(ctx, request.SenderUserID, view, request.TopMessageID); err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
|
||||
Peer: request.Peer,
|
||||
SenderUserID: request.SenderUserID,
|
||||
ReceiverUserID: request.ReceiverBotID,
|
||||
RandomID: request.RandomID,
|
||||
TopMessageID: request.TopMessageID,
|
||||
ReplyToEphemeralID: request.ReplyToEphemeralID,
|
||||
Content: request.Content,
|
||||
OriginDevice: request.OriginDevice,
|
||||
PayloadHash: clientPayloadHash(request),
|
||||
})
|
||||
if err == nil && replyTarget != nil {
|
||||
message.BotAPIReply = replyTarget
|
||||
}
|
||||
return message, fresh, err
|
||||
}
|
||||
|
||||
func (s *Service) SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) {
|
||||
return s.sendFromBot(ctx, request, func(context.Context) (domain.EphemeralContent, error) {
|
||||
return request.Content, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SendFromBotLazy authorizes the bot, receiver, chat and eligible action before
|
||||
// materializing content. The RPC edge uses it for URL/upload media so an
|
||||
// unauthorized target cannot consume file storage, network or decoder work.
|
||||
func (s *Service) SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
|
||||
if build == nil {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
return s.sendFromBot(ctx, request, build)
|
||||
}
|
||||
|
||||
func (s *Service) sendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
|
||||
if s == nil || s.messages == nil || s.channels == nil || s.users == nil {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
if request.BotUserID <= 0 || request.ReceiverUserID <= 0 || request.BotUserID == request.ReceiverUserID ||
|
||||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
view, err := s.requireActiveGroupPair(ctx, request.BotUserID, request.ReceiverUserID, request.Peer.ID)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
bot, found, err := s.users.ByID(ctx, request.BotUserID, request.BotUserID)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || !bot.Bot || bot.Deleted {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralSenderInvalid
|
||||
}
|
||||
receiver, found, err := s.users.ByID(ctx, request.BotUserID, request.ReceiverUserID)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || receiver.Bot || receiver.Deleted {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
|
||||
}
|
||||
now := s.now()
|
||||
var targetDevice domain.EphemeralDevice
|
||||
var replyTarget *domain.EphemeralMessage
|
||||
if request.ActionMessageID != 0 && request.CallbackQueryID != 0 {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
if request.CallbackQueryID != 0 {
|
||||
action, found, err := s.messages.GetEphemeralCallbackAction(ctx, request.BotUserID, request.CallbackQueryID, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || action.UserID != request.ReceiverUserID || action.Peer != request.Peer || !now.Before(action.ExpiresAt) {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||
}
|
||||
targetDevice = action.Device
|
||||
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
request.TopMessageID = action.TopMessageID
|
||||
} else if request.ActionMessageID != 0 {
|
||||
action, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ActionMessageID, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found || action.Deleted || action.SenderUserID != request.ReceiverUserID || action.ReceiverUserID != request.BotUserID ||
|
||||
now.Sub(action.CreatedAt) < 0 || now.Sub(action.CreatedAt) > domain.EphemeralReplyWindow {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||
}
|
||||
targetDevice = action.OriginDevice
|
||||
replyTarget = &action
|
||||
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
request.TopMessageID = action.TopMessageID
|
||||
if request.ReplyToEphemeralID == 0 {
|
||||
request.ReplyToEphemeralID = action.ID
|
||||
}
|
||||
} else {
|
||||
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||
}
|
||||
}
|
||||
if request.ReplyToEphemeralID != 0 {
|
||||
var reply domain.EphemeralMessage
|
||||
found := false
|
||||
if replyTarget != nil && replyTarget.ID == request.ReplyToEphemeralID {
|
||||
reply, found = *replyTarget, true
|
||||
} else {
|
||||
var err error
|
||||
reply, found, err = s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
}
|
||||
if !found || reply.Deleted || !sameEphemeralParticipants(reply, request.BotUserID, request.ReceiverUserID) {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||
}
|
||||
if targetDevice.BusinessAuthKeyID != ([8]byte{}) && reply.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
|
||||
targetDevice.BusinessAuthKeyID != reply.OriginDevice.BusinessAuthKeyID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||
}
|
||||
if request.TopMessageID != 0 && request.TopMessageID != reply.TopMessageID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
request.TopMessageID = reply.TopMessageID
|
||||
replyTarget = &reply
|
||||
}
|
||||
if err := s.validateForumTopic(ctx, request.BotUserID, view, request.TopMessageID); err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
content, err := build(ctx)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !validContent(content) {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
request.Content = content
|
||||
if request.RandomID == 0 {
|
||||
request.RandomID, err = randomEphemeralRandomID()
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
}
|
||||
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
|
||||
Peer: request.Peer,
|
||||
SenderUserID: request.BotUserID,
|
||||
ReceiverUserID: request.ReceiverUserID,
|
||||
RandomID: request.RandomID,
|
||||
TopMessageID: request.TopMessageID,
|
||||
ReplyToEphemeralID: request.ReplyToEphemeralID,
|
||||
Content: request.Content,
|
||||
OriginDevice: targetDevice,
|
||||
PayloadHash: botPayloadHash(request),
|
||||
})
|
||||
if err == nil && replyTarget != nil {
|
||||
message.BotAPIReply = replyTarget
|
||||
}
|
||||
return message, fresh, err
|
||||
}
|
||||
|
||||
func (s *Service) EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) {
|
||||
now := s.now()
|
||||
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if message.SenderUserID != botUserID {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||
}
|
||||
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
|
||||
}
|
||||
|
||||
func (s *Service) EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) {
|
||||
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, func(context.Context) (domain.EditEphemeralFields, error) {
|
||||
return fields, nil
|
||||
})
|
||||
}
|
||||
|
||||
// EditFieldsFromBotLazy performs the identity/ownership lookup before building
|
||||
// replacement media. This keeps invalid edit requests off the remote-fetch and
|
||||
// blob-materialization paths while preserving a single CAS write on success.
|
||||
func (s *Service) EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
|
||||
if build == nil {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, build)
|
||||
}
|
||||
|
||||
func (s *Service) editFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
|
||||
now := s.now()
|
||||
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if message.SenderUserID != botUserID || message.ReceiverUserID != receiverUserID {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||
}
|
||||
fields, err := build(ctx)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
switch mode {
|
||||
case domain.EphemeralEditText:
|
||||
if message.Content.Media != nil || !message.Content.RichMessage.IsZero() || !fields.SetMessage {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
case domain.EphemeralEditCaption:
|
||||
if message.Content.Media == nil || !fields.SetMessage {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
case domain.EphemeralEditMedia:
|
||||
if message.Content.Media == nil || !fields.SetMedia {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
case domain.EphemeralEditReplyMarkup:
|
||||
if !fields.SetReplyMarkup || fields.SetMessage || fields.SetMedia {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
default:
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
content := message.Content
|
||||
if fields.SetMessage {
|
||||
content.Message = fields.Message
|
||||
content.Entities = append([]domain.MessageEntity(nil), fields.Entities...)
|
||||
}
|
||||
if fields.SetMedia {
|
||||
content.Media = fields.Media
|
||||
}
|
||||
if fields.SetReplyMarkup {
|
||||
content.ReplyMarkup = fields.ReplyMarkup
|
||||
}
|
||||
if !validContent(content) {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||
}
|
||||
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||
return s.delete(ctx, actorUserID, receiverUserID, nil, peer, id)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||
if device.UserID != actorUserID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||
}
|
||||
return s.delete(ctx, actorUserID, receiverUserID, &device, peer, id)
|
||||
}
|
||||
|
||||
func (s *Service) delete(ctx context.Context, actorUserID, receiverUserID int64, device *domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||
now := s.now()
|
||||
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if message.ReceiverUserID != receiverUserID || (actorUserID != message.SenderUserID && actorUserID != message.ReceiverUserID) {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||
}
|
||||
if device != nil && message.OriginDevice.UserID == actorUserID && message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
|
||||
message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||
}
|
||||
return s.messages.DeleteEphemeralMessage(ctx, peer, id, message.Version, now)
|
||||
}
|
||||
|
||||
func (s *Service) Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) {
|
||||
if len(data) > domain.MaxEphemeralCallbackDataBytes || userID <= 0 || device.UserID != userID ||
|
||||
device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||
}
|
||||
now := s.now()
|
||||
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||
if err != nil {
|
||||
return domain.EphemeralCallback{}, err
|
||||
}
|
||||
if !found || message.Deleted || message.ReceiverUserID != userID {
|
||||
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||
}
|
||||
if !ephemeralMarkupContainsCallback(message.Content.ReplyMarkup, data) {
|
||||
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||
}
|
||||
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||
return domain.EphemeralCallback{}, domain.ErrEphemeralDeviceMismatch
|
||||
}
|
||||
return domain.EphemeralCallback{
|
||||
Message: message,
|
||||
BotUserID: message.SenderUserID,
|
||||
UserID: userID,
|
||||
Peer: peer,
|
||||
Data: append([]byte(nil), data...),
|
||||
Device: device,
|
||||
OccurredAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) {
|
||||
if s == nil || s.messages == nil {
|
||||
return false, domain.ErrEphemeralInvalid
|
||||
}
|
||||
return s.messages.PutEphemeralCallbackAction(ctx, action)
|
||||
}
|
||||
|
||||
func (s *Service) ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
|
||||
if userID <= 0 || device.UserID != userID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||
}
|
||||
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, s.now())
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, err
|
||||
}
|
||||
if !found || message.Deleted || message.ReceiverUserID != userID {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||
}
|
||||
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||
return domain.EphemeralMessage{}, domain.ErrEphemeralDeviceMismatch
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func ephemeralMarkupContainsCallback(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
|
||||
}
|
||||
|
||||
func sameEphemeralParticipants(message domain.EphemeralMessage, first, second int64) bool {
|
||||
return (message.SenderUserID == first && message.ReceiverUserID == second) ||
|
||||
(message.SenderUserID == second && message.ReceiverUserID == first)
|
||||
}
|
||||
|
||||
func (s *Service) create(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
|
||||
now := s.now()
|
||||
message.Date = int(now.Unix())
|
||||
message.CreatedAt = now
|
||||
message.ExpiresAt = now.Add(domain.EphemeralMessageRetention)
|
||||
message.Version = 1
|
||||
for attempt := 0; attempt < domain.MaxEphemeralCreateAttempts; attempt++ {
|
||||
id, err := s.nextID()
|
||||
if err != nil {
|
||||
return domain.EphemeralMessage{}, false, err
|
||||
}
|
||||
message.ID = id
|
||||
created, fresh, err := s.messages.CreateEphemeralMessage(ctx, message)
|
||||
if !errors.Is(err, domain.ErrEphemeralIDCollision) {
|
||||
return created, fresh, err
|
||||
}
|
||||
}
|
||||
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
|
||||
}
|
||||
|
||||
func (s *Service) requireActiveGroupPair(ctx context.Context, viewerUserID, otherUserID, channelID int64) (domain.ChannelView, error) {
|
||||
view, err := s.channels.ResolveChannel(ctx, viewerUserID, channelID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
if view.Channel.Deleted || view.Channel.Broadcast || view.Channel.Monoforum || view.Self.Status != domain.ChannelMemberActive {
|
||||
return domain.ChannelView{}, domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
other, err := s.channels.GetParticipant(ctx, viewerUserID, channelID, otherUserID)
|
||||
if err != nil {
|
||||
return domain.ChannelView{}, err
|
||||
}
|
||||
if other.Status != domain.ChannelMemberActive {
|
||||
return domain.ChannelView{}, domain.ErrEphemeralReceiverInvalid
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateForumTopic(ctx context.Context, userID int64, view domain.ChannelView, topMessageID int) error {
|
||||
if topMessageID == 0 {
|
||||
return nil
|
||||
}
|
||||
if !view.Channel.Forum || topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
|
||||
return domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
topics, err := s.channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, []int{topMessageID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(topics.Topics) != 1 || topics.Topics[0].TopicID != topMessageID || topics.Topics[0].Hidden {
|
||||
return domain.ErrEphemeralPeerInvalid
|
||||
}
|
||||
if topics.Topics[0].Closed && view.Self.Role != domain.ChannelRoleAdmin && view.Self.Role != domain.ChannelRoleCreator {
|
||||
return domain.ErrEphemeralForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) isEphemeralCommand(ctx context.Context, bot domain.User, message string) (bool, error) {
|
||||
command, username, ok := parseCommand(message)
|
||||
if !ok || (username != "" && !strings.EqualFold(username, bot.Username)) {
|
||||
return false, nil
|
||||
}
|
||||
commands, err := s.bots.GetBotCommands(ctx, bot.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, candidate := range commands {
|
||||
if candidate.Ephemeral && strings.EqualFold(candidate.Command, command) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func parseCommand(message string) (command, username string, ok bool) {
|
||||
fields := strings.Fields(strings.TrimSpace(message))
|
||||
if len(fields) == 0 || len(fields[0]) < 2 || fields[0][0] != '/' {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(fields[0][1:], "@", 2)
|
||||
command = strings.ToLower(parts[0])
|
||||
if command == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
username = strings.TrimPrefix(strings.ToLower(parts[1]), "@")
|
||||
if username == "" {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
return command, username, true
|
||||
}
|
||||
|
||||
func validContent(content domain.EphemeralContent) bool {
|
||||
return domain.ValidateEphemeralContent(content) == nil
|
||||
}
|
||||
|
||||
func clientPayloadHash(request domain.SendClientEphemeralRequest) [32]byte {
|
||||
return payloadHash(struct {
|
||||
SenderUserID, ReceiverBotID int64
|
||||
Peer domain.Peer
|
||||
QueryID, RandomID int64
|
||||
TopMessageID, ReplyID int
|
||||
Content domain.EphemeralContent
|
||||
Device domain.EphemeralDevice
|
||||
}{request.SenderUserID, request.ReceiverBotID, request.Peer, request.QueryID, request.RandomID,
|
||||
request.TopMessageID, request.ReplyToEphemeralID, request.Content, request.OriginDevice})
|
||||
}
|
||||
|
||||
func botPayloadHash(request domain.SendBotEphemeralRequest) [32]byte {
|
||||
return payloadHash(request)
|
||||
}
|
||||
|
||||
func payloadHash(value any) [32]byte {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return sha256.Sum256([]byte("invalid-ephemeral-payload"))
|
||||
}
|
||||
return sha256.Sum256(raw)
|
||||
}
|
||||
|
||||
func randomEphemeralID() (int, error) {
|
||||
var raw [4]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := binary.LittleEndian.Uint32(raw[:]) & 0x7fffffff
|
||||
if value == 0 {
|
||||
value = 1
|
||||
}
|
||||
return int(value), nil
|
||||
}
|
||||
|
||||
func randomEphemeralRandomID() (int64, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := int64(binary.LittleEndian.Uint64(raw[:]))
|
||||
if value == 0 {
|
||||
value = 1
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
385
internal/app/ephemeral/service_test.go
Normal file
385
internal/app/ephemeral/service_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package ephemeral
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
const (
|
||||
testHumanID int64 = 1001
|
||||
testBotID int64 = 2001
|
||||
testChannel int64 = 3001
|
||||
testSession int64 = 4001
|
||||
)
|
||||
|
||||
var testDeviceKey = [8]byte{1, 2, 3, 4}
|
||||
|
||||
type testChannels struct {
|
||||
roles map[int64]domain.ChannelMemberRole
|
||||
status map[int64]domain.ChannelMemberStatus
|
||||
channel domain.Channel
|
||||
}
|
||||
|
||||
func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{
|
||||
ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID],
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelMember{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil
|
||||
}
|
||||
|
||||
func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||
if channelID != c.channel.ID {
|
||||
return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
|
||||
}
|
||||
out := domain.ChannelForumTopicList{Channel: c.channel}
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type testUsers map[int64]domain.User
|
||||
|
||||
func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) {
|
||||
user, found := u[userID]
|
||||
return user, found, nil
|
||||
}
|
||||
|
||||
type testBots map[int64][]domain.BotCommand
|
||||
|
||||
func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) {
|
||||
return append([]domain.BotCommand(nil), b[botUserID]...), nil
|
||||
}
|
||||
|
||||
type serviceFixture struct {
|
||||
service *Service
|
||||
store *memory.EphemeralMessageStore
|
||||
now time.Time
|
||||
nextID int
|
||||
channels *testChannels
|
||||
}
|
||||
|
||||
func newServiceFixture() *serviceFixture {
|
||||
f := &serviceFixture{
|
||||
store: memory.NewEphemeralMessageStore(),
|
||||
now: time.Unix(1_900_000_000, 0),
|
||||
nextID: 10,
|
||||
channels: &testChannels{
|
||||
roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember},
|
||||
status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive},
|
||||
channel: domain.Channel{ID: testChannel, Megagroup: true},
|
||||
},
|
||||
}
|
||||
f.service = NewService(f.store, f.channels, testUsers{
|
||||
testHumanID: {ID: testHumanID, Username: "alice"},
|
||||
testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1},
|
||||
}, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}},
|
||||
WithClock(func() time.Time { return f.now }),
|
||||
WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil }))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest {
|
||||
return domain.SendClientEphemeralRequest{
|
||||
SenderUserID: testHumanID, ReceiverBotID: testBotID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||
if err != nil || !fresh {
|
||||
t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err)
|
||||
}
|
||||
if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||
t.Fatalf("message = %+v", message)
|
||||
}
|
||||
request := f.clientRequest()
|
||||
request.RandomID++
|
||||
request.Content.Message = "/public"
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) {
|
||||
t.Fatalf("ordinary command err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
request := f.clientRequest()
|
||||
message, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || !fresh {
|
||||
t.Fatalf("create fresh=%v err=%v", fresh, err)
|
||||
}
|
||||
device := request.OriginDevice
|
||||
if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed {
|
||||
t.Fatalf("delete changed=%v err=%v", changed, err)
|
||||
}
|
||||
replayed, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 {
|
||||
t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReplyMustMatchTargetDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "reply"
|
||||
request.ReplyToEphemeralID = incoming.ID
|
||||
reply, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID {
|
||||
t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
request.RandomID++
|
||||
request.OriginDevice.BusinessAuthKeyID = [8]byte{9}
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device reply err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotReplyWindowAndAdminBroadcast(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.now = f.now.Add(14 * time.Second)
|
||||
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID,
|
||||
Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID,
|
||||
})
|
||||
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID ||
|
||||
reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID {
|
||||
t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
f.now = f.now.Add(2 * time.Second)
|
||||
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||
RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID,
|
||||
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||
t.Fatalf("late bot reply err=%v", err)
|
||||
}
|
||||
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||
broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||
RandomID: 94, Content: domain.EphemeralContent{Message: "admin"},
|
||||
})
|
||||
if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) {
|
||||
t.Fatalf("admin broadcast = %+v err=%v", broadcast, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||
if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" {
|
||||
t.Fatalf("callback = %+v err=%v", callback, err)
|
||||
}
|
||||
device.BusinessAuthKeyID = [8]byte{7}
|
||||
if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device callback err=%v", err)
|
||||
}
|
||||
if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||
t.Fatalf("other device delete err=%v", err)
|
||||
}
|
||||
device.BusinessAuthKeyID = testDeviceKey
|
||||
deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID)
|
||||
if err != nil || !changed || !deleted.Deleted {
|
||||
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const queryID = int64(777)
|
||||
created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{
|
||||
QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer,
|
||||
MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now,
|
||||
ExpiresAt: f.now.Add(domain.EphemeralReplyWindow),
|
||||
})
|
||||
if err != nil || !created {
|
||||
t.Fatalf("put callback action created=%v err=%v", created, err)
|
||||
}
|
||||
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"},
|
||||
})
|
||||
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||
t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||
}
|
||||
f.now = f.now.Add(domain.EphemeralReplyWindow)
|
||||
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"},
|
||||
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||
t.Fatalf("expired callback action err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
f.channels.channel.Forum = true
|
||||
incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42)
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "topic reply"
|
||||
request.ReplyToEphemeralID = incoming.ID
|
||||
reply, _, err := f.service.SendFromClient(context.Background(), request)
|
||||
if err != nil || reply.TopMessageID != 42 {
|
||||
t.Fatalf("topic reply=%+v err=%v", reply, err)
|
||||
}
|
||||
|
||||
f = newServiceFixture()
|
||||
request = f.clientRequest()
|
||||
request.TopMessageID = 42
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) {
|
||||
t.Fatalf("non-forum topic err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
request := f.clientRequest()
|
||||
request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private "))
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil {
|
||||
t.Fatalf("4096 Unicode characters rejected: %v", err)
|
||||
}
|
||||
request.RandomID++
|
||||
request.Content.Message += "界"
|
||||
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("overlong Unicode text err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
textMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil {
|
||||
t.Fatalf("text edit: %v", err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("caption edit on text err=%v", err)
|
||||
}
|
||||
|
||||
mediaMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||
mediaContent := domain.EphemeralContent{
|
||||
Message: "caption",
|
||||
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}},
|
||||
}
|
||||
mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil {
|
||||
t.Fatalf("media caption edit: %v", err)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||
t.Fatalf("text edit on media err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) {
|
||||
f := newServiceFixture()
|
||||
builds := 0
|
||||
buildText := func(context.Context) (domain.EphemeralContent, error) {
|
||||
builds++
|
||||
return domain.EphemeralContent{Message: "authorized"}, nil
|
||||
}
|
||||
request := domain.SendBotEphemeralRequest{
|
||||
BotUserID: testBotID, ReceiverUserID: testHumanID + 99,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
}
|
||||
if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil {
|
||||
t.Fatal("unknown receiver was accepted")
|
||||
}
|
||||
if builds != 0 {
|
||||
t.Fatalf("unauthorized send materialized content %d times", builds)
|
||||
}
|
||||
|
||||
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||
request.ReceiverUserID = testHumanID
|
||||
if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh {
|
||||
t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err)
|
||||
}
|
||||
if builds != 1 {
|
||||
t.Fatalf("authorized send materialized content %d times", builds)
|
||||
}
|
||||
|
||||
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||
editBuilds := 0
|
||||
buildEdit := func(context.Context) (domain.EditEphemeralFields, error) {
|
||||
editBuilds++
|
||||
return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID,
|
||||
domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) {
|
||||
t.Fatalf("unauthorized lazy edit err=%v", err)
|
||||
}
|
||||
if editBuilds != 0 {
|
||||
t.Fatalf("unauthorized edit materialized content %d times", editBuilds)
|
||||
}
|
||||
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID,
|
||||
domain.EphemeralEditText, buildEdit); err != nil {
|
||||
t.Fatalf("authorized lazy edit: %v", err)
|
||||
}
|
||||
if editBuilds != 1 {
|
||||
t.Fatalf("authorized edit materialized content %d times", editBuilds)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage {
|
||||
return f.putIncomingInTopic(t, deviceKey, createdAt, 0)
|
||||
}
|
||||
|
||||
func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage {
|
||||
t.Helper()
|
||||
f.nextID++
|
||||
message := domain.EphemeralMessage{
|
||||
ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||
SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID),
|
||||
TopMessageID: topMessageID,
|
||||
Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{
|
||||
Type: domain.MessageReplyMarkupInline,
|
||||
Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}},
|
||||
}},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession},
|
||||
PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1,
|
||||
CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stored
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue