Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
617 lines
23 KiB
Go
617 lines
23 KiB
Go
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
|
|
}
|