diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index a6da58eb..b4f75444 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -32,6 +32,7 @@ import ( chatlistsapp "telesrv/internal/app/chatlists" "telesrv/internal/app/contacts" "telesrv/internal/app/dialogs" + ephemeralapp "telesrv/internal/app/ephemeral" filesapp "telesrv/internal/app/files" groupcallsapp "telesrv/internal/app/groupcalls" "telesrv/internal/app/help" @@ -363,6 +364,8 @@ func run(logger *zap.Logger) error { bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool) botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb) + ephemeralStore := redisstore.NewEphemeralMessageStore(rdb) + ephemeralReportStore := postgres.NewEphemeralReportStore(pool) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) @@ -713,6 +716,7 @@ func run(logger *zap.Logger) error { channelapp.WithReadModelVersions(readModelVersionStore), channelapp.WithSendPermissionChecker(adminService), ) + ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService) chatlistsService := chatlistsapp.NewService( chatlistStore, dialogStore, @@ -789,6 +793,9 @@ func run(logger *zap.Logger) error { Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithAccountFreezeProvider(adminService)), AccountFreeze: adminService, AICompose: aiComposeService, + Ephemeral: ephemeralService, + EphemeralPush: ephemeralStore, + EphemeralReports: ephemeralReportStore, Users: usersService, Updates: updatesService, BootstrapUpdates: bootstrapUpdateStore, @@ -903,6 +910,7 @@ func run(logger *zap.Logger) error { }() go router.RunInlineBotPushSubscriber(ctx) go router.RunBotCallbackAnswerSubscriber(ctx) + go router.RunEphemeralPushSubscriber(ctx) if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { return fmt.Errorf("start bot api: %w", err) } diff --git a/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql b/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql new file mode 100644 index 00000000..aecbacb8 --- /dev/null +++ b/deploy/migrations/0120_bot_api_ephemeral_payload.down.sql @@ -0,0 +1,13 @@ +DELETE FROM public.bot_api_updates +WHERE ephemeral_payload IS NOT NULL; + +DROP INDEX IF EXISTS public.bot_api_updates_ephemeral_version_unique; +DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique; + +ALTER TABLE public.bot_api_updates + DROP CONSTRAINT bot_api_updates_ephemeral_shape_check, + DROP COLUMN ephemeral_payload; + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message'); diff --git a/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql b/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql new file mode 100644 index 00000000..c3650047 --- /dev/null +++ b/deploy/migrations/0120_bot_api_ephemeral_payload.up.sql @@ -0,0 +1,61 @@ +ALTER TABLE public.bot_api_updates + ADD COLUMN ephemeral_payload jsonb; + +ALTER TABLE public.bot_api_updates + ADD CONSTRAINT bot_api_updates_ephemeral_shape_check CHECK ( + ephemeral_payload IS NULL + OR ( + peer_type = 'channel' + AND peer_id > 0 + AND message_id > 0 + AND source_pts = 0 + AND jsonb_typeof(ephemeral_payload) = 'object' + AND jsonb_typeof(ephemeral_payload -> 'Message') = 'object' + AND (ephemeral_payload #>> '{Message,ID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Peer,Type}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Peer,ID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,SenderUserID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,ReceiverUserID}') IS NOT NULL + AND (ephemeral_payload #>> '{Message,Version}') IS NOT NULL + AND NOT ((ephemeral_payload -> 'Message') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ]) + AND ( + NOT (ephemeral_payload ? 'ReplyTo') + OR ( + jsonb_typeof(ephemeral_payload -> 'ReplyTo') = 'object' + AND NOT ((ephemeral_payload -> 'ReplyTo') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ]) + ) + ) + AND (ephemeral_payload #>> '{Message,ID}')::integer = message_id + AND (ephemeral_payload #>> '{Message,Peer,Type}') = peer_type + AND (ephemeral_payload #>> '{Message,Peer,ID}')::bigint = peer_id + AND ( + (update_kind = 'callback_query' + AND (ephemeral_payload #>> '{Message,SenderUserID}')::bigint = bot_user_id) + OR + (update_kind IN ('message', 'edited_message') + AND (ephemeral_payload #>> '{Message,ReceiverUserID}')::bigint = bot_user_id) + ) + AND (ephemeral_payload #>> '{Message,Version}')::bigint > 0 + ) + ); + +DROP INDEX public.bot_api_updates_message_source_unique; + +CREATE UNIQUE INDEX bot_api_updates_message_source_unique + ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts) + WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NULL; + +CREATE UNIQUE INDEX bot_api_updates_ephemeral_version_unique + ON public.bot_api_updates ( + bot_user_id, + update_kind, + peer_type, + peer_id, + message_id, + ((ephemeral_payload #>> '{Message,Version}')::bigint) + ) + WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NOT NULL; diff --git a/deploy/migrations/0121_ephemeral_abuse_reports.down.sql b/deploy/migrations/0121_ephemeral_abuse_reports.down.sql new file mode 100644 index 00000000..9c775698 --- /dev/null +++ b/deploy/migrations/0121_ephemeral_abuse_reports.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.ephemeral_abuse_reports; diff --git a/deploy/migrations/0121_ephemeral_abuse_reports.up.sql b/deploy/migrations/0121_ephemeral_abuse_reports.up.sql new file mode 100644 index 00000000..9dc4e73c --- /dev/null +++ b/deploy/migrations/0121_ephemeral_abuse_reports.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE public.ephemeral_abuse_reports ( + id bigserial PRIMARY KEY, + reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0), + channel_id bigint NOT NULL CHECK (channel_id > 0), + ephemeral_message_id integer NOT NULL CHECK (ephemeral_message_id > 0), + sender_user_id bigint NOT NULL CHECK (sender_user_id > 0), + receiver_user_id bigint NOT NULL CHECK (receiver_user_id = reporter_user_id), + report_option text NOT NULL CHECK (length(report_option) BETWEEN 1 AND 64), + report_comment text NOT NULL DEFAULT '' CHECK (length(report_comment) <= 4096), + comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32), + payload_hash bytea NOT NULL CHECK (octet_length(payload_hash) = 32), + evidence jsonb NOT NULL CHECK (jsonb_typeof(evidence) = 'object'), + created_at timestamptz NOT NULL, + CONSTRAINT ephemeral_abuse_reports_idempotency UNIQUE ( + reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash + ) +); + +CREATE INDEX ephemeral_abuse_reports_created_at_idx + ON public.ephemeral_abuse_reports (created_at DESC, id DESC); + +CREATE INDEX ephemeral_abuse_reports_sender_created_idx + ON public.ephemeral_abuse_reports (sender_user_id, created_at DESC, id DESC); diff --git a/go.mod b/go.mod index 5cfbe945..e4f3b162 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/gotd/ige v0.2.2 github.com/gotd/log/logzap v0.1.1 - github.com/iamxvbaba/td v1.1.0 + github.com/iamxvbaba/td v1.1.1 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.9.2 github.com/pion/datachannel v1.6.2 diff --git a/go.sum b/go.sum index 1be70761..52050a59 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/iamxvbaba/td v1.1.0 h1:6Ddxi8sOuxOioGs3vyDGWlC6q53j4AQ2hFJ4AirJvFk= github.com/iamxvbaba/td v1.1.0/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04= +github.com/iamxvbaba/td v1.1.1 h1:KPHWqtxnEbcW3/eBWVfD6/GMWwO8/iPeq5yZbbM6WJI= +github.com/iamxvbaba/td v1.1.1/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/app/bots/manage_test.go b/internal/app/bots/manage_test.go index 39be659d..ca664613 100644 --- a/internal/app/bots/manage_test.go +++ b/internal/app/bots/manage_test.go @@ -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) } diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go index b24f15ce..b715acfc 100644 --- a/internal/app/bots/service.go +++ b/internal/app/bots/service.go @@ -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 } } diff --git a/internal/app/ephemeral/service.go b/internal/app/ephemeral/service.go new file mode 100644 index 00000000..ef330864 --- /dev/null +++ b/internal/app/ephemeral/service.go @@ -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 +} diff --git a/internal/app/ephemeral/service_test.go b/internal/app/ephemeral/service_test.go new file mode 100644 index 00000000..c475b4c5 --- /dev/null +++ b/internal/app/ephemeral/service_test.go @@ -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 +} diff --git a/internal/botapi/bot_commands.go b/internal/botapi/bot_commands.go new file mode 100644 index 00000000..34e8472e --- /dev/null +++ b/internal/botapi/bot_commands.go @@ -0,0 +1,104 @@ +package botapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "telesrv/internal/domain" +) + +const maxBotAPICommands = 100 + +func validateDefaultBotCommandScope(values map[string]string) error { + if strings.TrimSpace(values["language_code"]) != "" { + return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED") + } + raw := strings.TrimSpace(values["scope"]) + if raw == "" { + return nil + } + var scope struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(raw), &scope) != nil || scope.Type != "default" { + return errors.New("BOT_COMMAND_SCOPE_UNSUPPORTED") + } + return nil +} + +func (h *handler) setMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + var input []struct { + Command string `json:"command"` + Description string `json:"description"` + IsEphemeral bool `json:"is_ephemeral"` + } + if json.Unmarshal([]byte(values["commands"]), &input) != nil || len(input) > maxBotAPICommands { + writeAPIError(w, http.StatusBadRequest, "BOT_COMMAND_INVALID") + return + } + commands := make([]domain.BotCommand, 0, len(input)) + for _, command := range input { + commands = append(commands, domain.BotCommand{ + Command: command.Command, Description: command.Description, Ephemeral: command.IsEphemeral, + }) + } + if _, err := h.bots.SetBotCommands(r.Context(), botID, commands); err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, true) +} + +func (h *handler) deleteMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if _, err := h.bots.SetBotCommands(r.Context(), botID, nil); err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, true) +} + +func (h *handler) getMyCommands(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil || h.bots == nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + if err := validateDefaultBotCommandScope(values); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + commands, err := h.bots.GetBotCommands(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + out := make([]map[string]any, 0, len(commands)) + for _, command := range commands { + item := map[string]any{"command": command.Command, "description": command.Description} + if command.Ephemeral { + item["is_ephemeral"] = true + } + out = append(out, item) + } + writeAPIOK(w, out) +} diff --git a/internal/botapi/ephemeral.go b/internal/botapi/ephemeral.go new file mode 100644 index 00000000..1d8471b7 --- /dev/null +++ b/internal/botapi/ephemeral.go @@ -0,0 +1,375 @@ +package botapi + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" +) + +type ephemeralSendTarget struct { + receiverUserID int64 + callbackQueryID int64 + replyToEphemeralID int + topMessageID int +} + +func parseEphemeralSendTarget(values map[string]string) (ephemeralSendTarget, bool, error) { + var result ephemeralSendTarget + receiverRaw := strings.TrimSpace(values["receiver_user_id"]) + callbackRaw := strings.TrimSpace(values["callback_query_id"]) + var reply struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + } + if raw := strings.TrimSpace(values["reply_parameters"]); raw != "" { + if json.Unmarshal([]byte(raw), &reply) != nil || reply.MessageID < 0 || reply.EphemeralMessageID < 0 || + (reply.MessageID != 0 && reply.EphemeralMessageID != 0) { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + } + if receiverRaw == "" { + if callbackRaw != "" || reply.EphemeralMessageID != 0 { + return result, false, errors.New("USER_ID_INVALID") + } + return result, false, nil + } + receiver, err := strconv.ParseInt(receiverRaw, 10, 64) + if err != nil || receiver <= 0 { + return result, false, errors.New("USER_ID_INVALID") + } + result.receiverUserID = receiver + result.replyToEphemeralID = reply.EphemeralMessageID + if reply.MessageID != 0 { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + if callbackRaw != "" { + result.callbackQueryID, err = strconv.ParseInt(callbackRaw, 10, 64) + if err != nil || result.callbackQueryID == 0 { + return result, false, errors.New("QUERY_ID_INVALID") + } + } + if result.callbackQueryID != 0 && result.replyToEphemeralID != 0 { + return result, false, errors.New("REPLY_PARAMETERS_INVALID") + } + if raw := strings.TrimSpace(values["message_thread_id"]); raw != "" { + result.topMessageID, err = strconv.Atoi(raw) + if err != nil || result.topMessageID <= 0 || result.topMessageID > domain.MaxMessageBoxID { + return result, false, errors.New("MESSAGE_THREAD_ID_INVALID") + } + } + return result, true, nil +} + +func botAPIFileInput(raw string, files map[string]uploadedFile, field string, values map[string]string) (domain.BotAPIFileInput, bool) { + locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(raw, files, field) + if !ok { + return domain.BotAPIFileInput{}, false + } + return domain.BotAPIFileInput{ + LocationKey: locationKey, RemoteURL: remoteURL, FileName: fileName, MimeType: mimeType, Bytes: fileBytes, + Width: apiInt(values["width"], 0), Height: apiInt(values["height"], 0), Duration: apiInt(values["duration"], 0), + Title: values["title"], Performer: values["performer"], Emoji: values["emoji"], + }, true +} + +func (h *handler) writeEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, message domain.EphemeralMessage) { + users := make([]domain.User, 0, 1) + if self, err := h.gateway.BotAPISelf(r.Context(), botID); err == nil && self.ID != 0 { + users = append(users, self) + } + projected, ok := apiEphemeralMessage(message, users, nil) + if !ok { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, projected) +} + +func (h *handler) sendEphemeralContact(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + target, ephemeral, err := parseEphemeralSendTarget(values) + if err != nil || !ephemeral { + if err == nil { + err = errors.New("EPHEMERAL_TARGET_REQUIRED") + } + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + if !ok || strings.TrimSpace(values["phone_number"]) == "" || strings.TrimSpace(values["first_name"]) == "" || len(values["vcard"]) > 2048 { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + markup, _, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID, + CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID, + Kind: "contact", ReplyMarkup: markup, DirectMedia: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{ + PhoneNumber: values["phone_number"], FirstName: values["first_name"], LastName: values["last_name"], Vcard: values["vcard"], + }}, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) +} + +func (h *handler) sendEphemeralLocation(w http.ResponseWriter, r *http.Request, botID int64, venue bool) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + target, ephemeral, err := parseEphemeralSendTarget(values) + if err != nil || !ephemeral { + if err == nil { + err = errors.New("EPHEMERAL_TARGET_REQUIRED") + } + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + latitude, latErr := strconv.ParseFloat(strings.TrimSpace(values["latitude"]), 64) + longitude, longErr := strconv.ParseFloat(strings.TrimSpace(values["longitude"]), 64) + accuracy, accuracyErr := strconv.ParseFloat(defaultString(values["horizontal_accuracy"], "0"), 64) + if !ok || latErr != nil || longErr != nil || accuracyErr != nil || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 || accuracy < 0 || accuracy > 1500 || apiInt(values["live_period"], 0) != 0 { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + markup, _, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + geo := domain.MessageGeoPoint{Lat: latitude, Long: longitude, AccuracyRadius: int(accuracy)} + media := &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: &geo} + if venue { + if strings.TrimSpace(values["title"]) == "" || strings.TrimSpace(values["address"]) == "" { + writeAPIError(w, http.StatusBadRequest, "MEDIA_INVALID") + return + } + provider, venueID, venueType := "", "", "" + if values["foursquare_id"] != "" || values["foursquare_type"] != "" { + provider, venueID, venueType = "foursquare", values["foursquare_id"], values["foursquare_type"] + } else if values["google_place_id"] != "" || values["google_place_type"] != "" { + provider, venueID, venueType = "gplaces", values["google_place_id"], values["google_place_type"] + } + media = &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: geo, Title: values["title"], Address: values["address"], Provider: provider, VenueID: venueID, VenueType: venueType, + }} + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: target.receiverUserID, + CallbackQueryID: target.callbackQueryID, ReplyToEphemeralID: target.replyToEphemeralID, TopMessageID: target.topMessageID, + Kind: "location", ReplyMarkup: markup, DirectMedia: media, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) +} + +func (h *handler) editEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64, mode string) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64) + messageID := apiInt(values["ephemeral_message_id"], 0) + if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID") + return + } + input := domain.BotAPIEphemeralEditInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: receiverID, MessageID: messageID, + Mode: domain.EphemeralEditMode(mode), + } + markup, markupSet, err := optionalInlineReplyMarkup(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + input.Fields.SetReplyMarkup, input.Fields.ReplyMarkup = markupSet, markup + switch mode { + case "text": + if strings.TrimSpace(values["parse_mode"]) != "" { + writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") + return + } + if values["text"] == "" { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY") + return + } + if !utf8.ValidString(values["text"]) || utf8.RuneCountInString(values["text"]) > domain.MaxMessageTextLength { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") + return + } + entities, err := botAPIMessageEntities(values["entities"]) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["text"], entities + case "caption": + if strings.TrimSpace(values["parse_mode"]) != "" { + writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") + return + } + entities, err := botAPIMessageEntities(values["caption_entities"]) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") + return + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, values["caption"], entities + case "reply_markup": + input.Fields.SetReplyMarkup = true + case "media": + if err := parseEphemeralEditMedia(values["media"], &input); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + default: + writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + result, err := gateway.BotAPIEditEphemeral(r.Context(), input) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, result) +} + +func parseEphemeralEditMedia(raw string, input *domain.BotAPIEphemeralEditInput) error { + var media struct { + Type string `json:"type"` + Media string `json:"media"` + Photo string `json:"photo"` + Caption string `json:"caption"` + ParseMode string `json:"parse_mode"` + CaptionEntities json.RawMessage `json:"caption_entities"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + Title string `json:"title"` + Performer string `json:"performer"` + } + if input == nil || json.Unmarshal([]byte(raw), &media) != nil || media.Type == "" || strings.TrimSpace(media.ParseMode) != "" { + return errors.New("MEDIA_INVALID") + } + allowed := map[string]bool{"animation": true, "audio": true, "document": true, "live_photo": true, "photo": true, "video": true} + if !allowed[media.Type] { + return errors.New("MEDIA_INVALID") + } + primaryRaw := media.Media + if media.Type == "live_photo" { + primaryRaw = media.Photo + } + primary, ok := botAPIFileInput(primaryRaw, nil, "", map[string]string{ + "width": strconv.Itoa(media.Width), "height": strconv.Itoa(media.Height), "duration": strconv.Itoa(media.Duration), + "title": media.Title, "performer": media.Performer, + }) + if !ok || len(primary.Bytes) != 0 { + return errors.New("FILE_ID_INVALID") + } + input.MediaKind, input.File = media.Type, primary + if media.Type == "live_photo" { + secondary, ok := botAPIFileInput(media.Media, nil, "", map[string]string{"duration": strconv.Itoa(media.Duration)}) + if !ok || len(secondary.Bytes) != 0 { + return errors.New("FILE_ID_INVALID") + } + input.SecondaryFile = secondary + } + entities, err := botAPIMessageEntities(string(media.CaptionEntities)) + if err != nil { + return err + } + if !utf8.ValidString(media.Caption) || utf8.RuneCountInString(media.Caption) > domain.MaxEphemeralCaptionLength { + return errors.New("MESSAGE_TOO_LONG") + } + input.Fields.SetMessage, input.Fields.Message, input.Fields.Entities = true, media.Caption, entities + return nil +} + +func (h *handler) deleteEphemeralMessage(w http.ResponseWriter, r *http.Request, botID int64) { + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + chatID, ok := parsePositiveOrNegativeID(values["chat_id"]) + receiverID, receiverErr := strconv.ParseInt(strings.TrimSpace(values["receiver_user_id"]), 10, 64) + messageID := apiInt(values["ephemeral_message_id"], 0) + if !ok || receiverErr != nil || receiverID <= 0 || messageID <= 0 { + writeAPIError(w, http.StatusBadRequest, "EPHEMERAL_MESSAGE_ID_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + result, err := gateway.BotAPIDeleteEphemeral(r.Context(), botID, chatID, receiverID, messageID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, result) +} + +func optionalInlineReplyMarkup(values map[string]string) (*domain.MessageReplyMarkup, bool, error) { + raw, exists := values["reply_markup"] + if !exists || strings.TrimSpace(raw) == "" { + return nil, exists, nil + } + markup, err := inlineReplyMarkupFromAPI(json.RawMessage(raw)) + return markup, true, err +} + +func parsePositiveOrNegativeID(raw string) (int64, bool) { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + return id, err == nil && id != 0 +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 707e9dfc..4bc08efe 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -85,24 +85,42 @@ func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any { } func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { - if event.Pts <= 0 { + updateID := event.BotAPIUpdateID + if updateID <= 0 { + updateID = int64(event.Pts) + } + if updateID <= 0 { return nil, "", false } switch event.Type { case domain.UpdateEventNewMessage: + if event.EphemeralMessage != nil { + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + return map[string]any{"update_id": updateID, "message": message}, "message", true + } if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ - "update_id": event.Pts, + "update_id": updateID, "message": apiMessage(event.Message, event.Users, event.Channels), }, "message", true case domain.UpdateEventEditMessage: + if event.EphemeralMessage != nil { + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + return map[string]any{"update_id": updateID, "edited_message": message}, "edited_message", true + } if !apiMessageProjectable(event.Message) { return nil, "", false } return map[string]any{ - "update_id": event.Pts, + "update_id": updateID, "edited_message": apiMessage(event.Message, event.Users, event.Channels), }, "edited_message", true case domain.UpdateEventBotCallbackQuery: @@ -132,6 +150,15 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { return nil, "", false } query["inline_message_id"] = inlineMessageID + } else if event.EphemeralMessage != nil { + if callback.MessageID <= 0 || event.EphemeralMessage.ID != callback.MessageID || event.EphemeralMessage.Peer != callback.Peer { + return nil, "", false + } + message, ok := apiEphemeralMessage(*event.EphemeralMessage, event.Users, event.Channels) + if !ok { + return nil, "", false + } + query["message"] = message } else { if callback.MessageID <= 0 || event.Message.ID != callback.MessageID { return nil, "", false @@ -139,7 +166,7 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { query["message"] = apiMessage(event.Message, event.Users, event.Channels) } return map[string]any{ - "update_id": event.Pts, + "update_id": updateID, "callback_query": query, }, "callback_query", true default: @@ -147,6 +174,46 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { } } +func apiEphemeralMessage(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel) (map[string]any, bool) { + return apiEphemeralMessageDepth(message, users, channels, 0) +} + +func apiEphemeralMessageDepth(message domain.EphemeralMessage, users []domain.User, channels []domain.Channel, depth int) (map[string]any, bool) { + if message.ID <= 0 || message.Peer.Type != domain.PeerTypeChannel || message.Peer.ID <= 0 || + message.SenderUserID <= 0 || message.ReceiverUserID <= 0 || message.Date <= 0 || message.Deleted { + return nil, false + } + if message.Content.Message == "" && (message.Content.Media == nil || message.Content.Media.IsZero()) { + return nil, false + } + projected := apiMessage(domain.Message{ + ID: 0, Peer: message.Peer, From: domain.Peer{Type: domain.PeerTypeUser, ID: message.SenderUserID}, + Date: message.Date, EditDate: message.EditDate, Body: message.Content.Message, + Entities: message.Content.Entities, Media: message.Content.Media, ReplyMarkup: message.Content.ReplyMarkup, + }, users, channels) + projected["message_id"] = 0 + projected["ephemeral_message_id"] = message.ID + receiver := domain.User{ID: message.ReceiverUserID} + for _, user := range users { + if user.ID == message.ReceiverUserID { + receiver = user + break + } + } + projected["receiver_user"] = apiUser(receiver) + if message.ReplyToEphemeralID > 0 { + if depth != 0 || message.BotAPIReply == nil || message.BotAPIReply.ID != message.ReplyToEphemeralID { + return nil, false + } + reply, ok := apiEphemeralMessageDepth(*message.BotAPIReply, users, channels, depth+1) + if !ok { + return nil, false + } + projected["reply_to_message"] = reply + } + return projected, true +} + const botAPIInlineMessageIDVersion byte = 1 // encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an @@ -236,9 +303,7 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai } media := apiMessageMedia(msg.Media, userByID, channelByID) if msg.Body != "" { - if _, photo := media["photo"]; photo { - out["caption"] = msg.Body - } else if _, document := media["document"]; document { + if apiMediaUsesCaption(media) { out["caption"] = msg.Body } else if poll, ok := media["poll"].(map[string]any); ok { poll["description"] = msg.Body @@ -247,9 +312,7 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai } } if entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 { - if _, photo := media["photo"]; photo { - out["caption_entities"] = entities - } else if _, document := media["document"]; document { + if apiMediaUsesCaption(media) { out["caption_entities"] = entities } else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" { poll["description_entities"] = entities @@ -278,6 +341,15 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai return out } +func apiMediaUsesCaption(media map[string]any) bool { + for _, key := range []string{"photo", "live_photo", "animation", "audio", "document", "video", "voice"} { + if _, ok := media[key]; ok { + return true + } + } + return false +} + func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any { switch peer.Type { case domain.PeerTypeUser: @@ -476,12 +548,23 @@ func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, ch if len(photos) == 0 { return nil } + if media.LivePhotoVideo != nil { + live := apiDocument(*media.LivePhotoVideo) + live["photo"] = photos + for _, attribute := range media.LivePhotoVideo.Attributes { + if attribute.Kind == domain.DocAttrVideo { + live["width"], live["height"], live["duration"] = attribute.W, attribute.H, int(attribute.Duration) + break + } + } + return map[string]any{"live_photo": live} + } return map[string]any{"photo": photos} case domain.MessageMediaKindDocument: if media.Document == nil { return nil } - return map[string]any{"document": apiDocument(*media.Document)} + return apiDocumentMedia(*media.Document) case domain.MessageMediaKindContact: if media.Contact == nil { return nil @@ -542,6 +625,67 @@ func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, ch } } +func apiDocumentMedia(document domain.Document) map[string]any { + base := apiDocument(document) + for _, attribute := range document.Attributes { + switch attribute.Kind { + case domain.DocAttrSticker: + sticker := cloneAPIMap(base) + sticker["type"], sticker["width"], sticker["height"] = "regular", attribute.W, attribute.H + sticker["is_animated"] = hasDocumentAttribute(document, domain.DocAttrAnimated) + sticker["is_video"] = hasDocumentAttribute(document, domain.DocAttrVideo) + if attribute.Alt != "" { + sticker["emoji"] = attribute.Alt + } + return map[string]any{"sticker": sticker} + case domain.DocAttrAudio: + audio := cloneAPIMap(base) + audio["duration"] = attribute.AudioDuration + if attribute.Voice { + return map[string]any{"voice": audio} + } + if attribute.Title != "" { + audio["title"] = attribute.Title + } + if attribute.Performer != "" { + audio["performer"] = attribute.Performer + } + return map[string]any{"audio": audio} + case domain.DocAttrVideo: + video := cloneAPIMap(base) + video["width"], video["height"], video["duration"] = attribute.W, attribute.H, int(attribute.Duration) + if attribute.RoundMessage { + video["length"] = attribute.W + delete(video, "width") + delete(video, "height") + return map[string]any{"video_note": video} + } + if hasDocumentAttribute(document, domain.DocAttrAnimated) { + return map[string]any{"animation": video, "document": base} + } + return map[string]any{"video": video} + } + } + return map[string]any{"document": base} +} + +func hasDocumentAttribute(document domain.Document, kind domain.DocumentAttributeKind) bool { + for _, attribute := range document.Attributes { + if attribute.Kind == kind { + return true + } + } + return false +} + +func cloneAPIMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)+4) + for key, value := range input { + out[key] = value + } + return out +} + 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 { diff --git a/internal/botapi/server.go b/internal/botapi/server.go index f4219fd9..49539b66 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "go.uber.org/zap" @@ -23,6 +24,8 @@ import ( type BotsService interface { BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) + SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) + GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) @@ -49,6 +52,12 @@ type GatewayService interface { BotAPIGetFile(ctx context.Context, botID int64, locationKey string, offset int64, limit int) (domain.FileChunk, bool, error) } +type EphemeralGatewayService interface { + BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) + BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) + BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) +} + type GatewayUpdateWaiter interface { BotAPIUpdateWaitVersion(botID int64) uint64 WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool @@ -186,18 +195,54 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) { switch strings.ToLower(method) { case "getme": h.getMe(w, r, botID) + case "setmycommands": + h.setMyCommands(w, r, botID) + case "deletemycommands": + h.deleteMyCommands(w, r, botID) + case "getmycommands": + h.getMyCommands(w, r, botID) case "getupdates": h.getUpdates(w, r, botID) case "sendmessage": h.sendMessage(w, r, botID) case "sendphoto": h.sendMedia(w, r, botID, "photo") + case "sendanimation": + h.sendMedia(w, r, botID, "animation") + case "sendaudio": + h.sendMedia(w, r, botID, "audio") case "senddocument": h.sendMedia(w, r, botID, "document") + case "sendlivephoto": + h.sendMedia(w, r, botID, "live_photo") + case "sendsticker": + h.sendMedia(w, r, botID, "sticker") + case "sendvideo": + h.sendMedia(w, r, botID, "video") + case "sendvideonote": + h.sendMedia(w, r, botID, "video_note") + case "sendvoice": + h.sendMedia(w, r, botID, "voice") + case "sendcontact": + h.sendEphemeralContact(w, r, botID) + case "sendlocation": + h.sendEphemeralLocation(w, r, botID, false) + case "sendvenue": + h.sendEphemeralLocation(w, r, botID, true) case "editmessagetext": h.editMessageText(w, r, botID) case "deletemessage": h.deleteMessage(w, r, botID) + case "editephemeralmessagetext": + h.editEphemeralMessage(w, r, botID, "text") + case "editephemeralmessagemedia": + h.editEphemeralMessage(w, r, botID, "media") + case "editephemeralmessagecaption": + h.editEphemeralMessage(w, r, botID, "caption") + case "editephemeralmessagereplymarkup": + h.editEphemeralMessage(w, r, botID, "reply_markup") + case "deleteephemeralmessage": + h.deleteEphemeralMessage(w, r, botID) case "answercallbackquery": h.answerCallbackQuery(w, r, botID) case "getfile": @@ -481,6 +526,10 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6 return } text := values["text"] + if text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > domain.MaxMessageTextLength { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_EMPTY") + return + } if strings.TrimSpace(values["parse_mode"]) != "" { writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") return @@ -498,6 +547,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6 return } } + ephemeral, isEphemeral, err := parseEphemeralSendTarget(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if isEphemeral { + if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline { + writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, + CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, + TopMessageID: ephemeral.topMessageID, Kind: "message", Text: text, Entities: entities, ReplyMarkup: markup, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) + return + } replyTo := apiInt(values["reply_to_message_id"], 0) msg, err := h.gateway.BotAPISendMessage(r.Context(), botID, chatID, text, entities, markup, apiBool(values["disable_web_page_preview"]), apiBool(values["disable_notification"]), replyTo) if err != nil { @@ -535,6 +611,10 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, writeAPIError(w, http.StatusBadRequest, err.Error()) return } + if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength { + writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG") + return + } var markup *domain.MessageReplyMarkup if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { markup, err = replyMarkupFromAPI(json.RawMessage(raw)) @@ -543,11 +623,56 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64, return } } - locationKey, remoteURL, fileName, mimeType, fileBytes, ok := mediaInput(values[kind], files, kind) + var file, secondary domain.BotAPIFileInput + var ok bool + if kind == "live_photo" { + file, ok = botAPIFileInput(values["photo"], files, "photo", values) + if ok { + secondary, ok = botAPIFileInput(values["live_photo"], files, "live_photo", values) + } + } else { + file, ok = botAPIFileInput(values[kind], files, kind, values) + } if !ok { writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID") return } + // The official Bot API does not accept HTTP URLs for the video part of a + // live photo or for video notes. Reject them before either the ordinary or + // ephemeral send path can fetch the remote resource. + if (kind == "live_photo" && secondary.RemoteURL != "") || (kind == "video_note" && file.RemoteURL != "") { + writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID") + return + } + ephemeral, isEphemeral, err := parseEphemeralSendTarget(values) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + if isEphemeral { + if markup != nil && !markup.IsZero() && markup.Kind() != domain.MessageReplyMarkupInline { + writeAPIError(w, http.StatusBadRequest, "BUTTON_TYPE_INVALID") + return + } + gateway, ok := h.gateway.(EphemeralGatewayService) + if !ok { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + message, err := gateway.BotAPISendEphemeral(r.Context(), domain.BotAPIEphemeralSendInput{ + BotUserID: botID, ChatID: chatID, ReceiverUserID: ephemeral.receiverUserID, + CallbackQueryID: ephemeral.callbackQueryID, ReplyToEphemeralID: ephemeral.replyToEphemeralID, + TopMessageID: ephemeral.topMessageID, Kind: kind, Text: values["caption"], Entities: entities, + ReplyMarkup: markup, File: file, SecondaryFile: secondary, + }) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + h.writeEphemeralMessage(w, r, botID, message) + return + } + locationKey, remoteURL, fileName, mimeType, fileBytes := file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes msg, err := h.gateway.BotAPISendMedia(r.Context(), botID, chatID, kind, locationKey, remoteURL, fileName, mimeType, fileBytes, values["caption"], entities, markup, apiBool(values["disable_notification"]), apiInt(values["reply_to_message_id"], 0)) if err != nil { writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) @@ -1252,6 +1377,9 @@ func apiErrorDescription(err error) string { "QUERY_ID_INVALID", "MESSAGE_ID_INVALID", "MESSAGE_NOT_MODIFIED", + "BOT_COMMAND_INVALID", + "EPHEMERAL_MESSAGE_ID_INVALID", + "EPHEMERAL_ACTION_EXPIRED", "CHAT_WRITE_FORBIDDEN", "CHAT_ADMIN_REQUIRED", "REPLY_MESSAGE_ID_INVALID", diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index b9d6ba7f..83f0206d 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -160,6 +160,62 @@ func TestGetMeUsesGateway(t *testing.T) { } } +func TestBotCommandsPreserveEphemeralFlag(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", `{ + "commands": [ + {"command":"private","description":"Private reply","is_ephemeral":true}, + {"command":"public","description":"Public reply"} + ] + }`) + if rec.Code != http.StatusOK || len(bots.commands) != 2 || !bots.commands[0].Ephemeral || bots.commands[1].Ephemeral { + t.Fatalf("setMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands) + } + + rec = performBotAPIRequest(t, h, bots.profile, "getMyCommands", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("getMyCommands status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + OK bool `json:"ok"` + Result []struct { + Command string `json:"command"` + Description string `json:"description"` + IsEphemeral bool `json:"is_ephemeral"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.OK || len(response.Result) != 2 || !response.Result[0].IsEphemeral || response.Result[1].IsEphemeral { + t.Fatalf("getMyCommands response=%s", rec.Body.String()) + } + + rec = performBotAPIRequest(t, h, bots.profile, "deleteMyCommands", `{}`) + if rec.Code != http.StatusOK || len(bots.commands) != 0 { + t.Fatalf("deleteMyCommands status=%d body=%s commands=%#v", rec.Code, rec.Body.String(), bots.commands) + } +} + +func TestBotCommandsRejectUnsupportedScopeAndLanguage(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots}).routes() + + for name, body := range map[string]string{ + "scope": `{"scope":{"type":"all_group_chats"},"commands":[]}`, + "language": `{"language_code":"en","commands":[]}`, + } { + t.Run(name, func(t *testing.T) { + rec := performBotAPIRequest(t, h, bots.profile, "setMyCommands", body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BOT_COMMAND_SCOPE_UNSUPPORTED") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } +} + func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -262,6 +318,221 @@ func TestGetUpdatesSkipsOutgoingBotMessage(t *testing.T) { } } +func TestGetUpdatesProjectsEphemeralMessageWithoutPts(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 2001, ReceiverUserID: 1001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "/private"}, + } + gateway := &fakeBotAPIGateway{updates: []domain.UpdateEvent{{ + Type: domain.UpdateEventNewMessage, BotAPIUpdateID: 901, EphemeralMessage: &message, + Users: []domain.User{{ID: 2001, FirstName: "Alice"}, {ID: 1001, FirstName: "Bot", Bot: true}}, + Channels: []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}}, + }}} + h := (&handler{bots: bots, gateway: gateway}).routes() + rec := performBotAPIRequest(t, h, bots.profile, "getUpdates", `{}`) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + OK bool `json:"ok"` + Result []struct { + UpdateID int64 `json:"update_id"` + Message struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + Text string `json:"text"` + ReceiverUser struct { + ID int64 `json:"id"` + } `json:"receiver_user"` + } `json:"message"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if !response.OK || len(response.Result) != 1 || response.Result[0].UpdateID != 901 || + response.Result[0].Message.MessageID != 0 || response.Result[0].Message.EphemeralMessageID != 77 || + response.Result[0].Message.ReceiverUser.ID != 1001 || response.Result[0].Message.Text != "/private" { + t.Fatalf("response=%s", rec.Body.String()) + } +} + +func TestEphemeralReplyProjectionContainsValidOneLevelTarget(t *testing.T) { + target := domain.EphemeralMessage{ + ID: 70, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "question"}, + } + message := domain.EphemeralMessage{ + ID: 71, Peer: target.Peer, SenderUserID: 2001, ReceiverUserID: 1001, + Date: 1_900_000_001, ReplyToEphemeralID: target.ID, + Content: domain.EphemeralContent{Message: "answer"}, BotAPIReply: &target, + } + projected, ok := apiEphemeralMessage(message, []domain.User{{ID: 1001, Bot: true}, {ID: 2001}}, []domain.Channel{{ID: 3001, Title: "Group", Megagroup: true}}) + if !ok { + t.Fatal("reply was not projectable") + } + reply, ok := projected["reply_to_message"].(map[string]any) + if !ok || reply["message_id"] != 0 || reply["ephemeral_message_id"] != target.ID || reply["date"] != target.Date || reply["text"] != "question" { + t.Fatalf("reply_to_message=%#v", projected["reply_to_message"]) + } +} + +func TestEphemeralSendMethodsRouteAllOfficialMediaKinds(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + ephemeralMessage: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "sent"}, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + chatID := int64(-1000000003001) + documentID := encodeBotAPIFileID("doc:7001") + photoID := encodeBotAPIFileID("photo:7002:m") + tests := []struct { + method string + kind string + body map[string]any + }{ + {"sendMessage", "message", map[string]any{"text": "hello", "message_thread_id": 42}}, + {"sendAnimation", "animation", map[string]any{"animation": documentID}}, + {"sendAudio", "audio", map[string]any{"audio": documentID}}, + {"sendDocument", "document", map[string]any{"document": documentID}}, + {"sendLivePhoto", "live_photo", map[string]any{"photo": photoID, "live_photo": documentID}}, + {"sendPhoto", "photo", map[string]any{"photo": photoID}}, + {"sendSticker", "sticker", map[string]any{"sticker": documentID}}, + {"sendVideo", "video", map[string]any{"video": documentID}}, + {"sendVideoNote", "video_note", map[string]any{"video_note": documentID}}, + {"sendVoice", "voice", map[string]any{"voice": documentID}}, + {"sendContact", "contact", map[string]any{"phone_number": "+100", "first_name": "Alice"}}, + {"sendLocation", "location", map[string]any{"latitude": 1.25, "longitude": 2.5}}, + {"sendVenue", "location", map[string]any{"latitude": 1.25, "longitude": 2.5, "title": "Place", "address": "Street"}}, + } + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + body := test.body + body["chat_id"] = chatID + body["receiver_user_id"] = int64(2001) + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + got := gateway.ephemeralSends[len(gateway.ephemeralSends)-1] + if got.Kind != test.kind || got.ChatID != chatID || got.ReceiverUserID != 2001 { + t.Fatalf("input=%+v", got) + } + var response struct { + OK bool `json:"ok"` + Result struct { + MessageID int `json:"message_id"` + EphemeralMessageID int `json:"ephemeral_message_id"` + ReceiverUser struct { + ID int64 `json:"id"` + } `json:"receiver_user"` + } `json:"result"` + } + if json.Unmarshal(rec.Body.Bytes(), &response) != nil || !response.OK || response.Result.MessageID != 0 || + response.Result.EphemeralMessageID != 77 || response.Result.ReceiverUser.ID != 2001 { + t.Fatalf("response=%s", rec.Body.String()) + } + }) + } + if gateway.ephemeralSends[0].TopMessageID != 42 { + t.Fatalf("message_thread_id=%d", gateway.ephemeralSends[0].TopMessageID) + } +} + +func TestEphemeralSendRejectsOfficiallyUnsupportedMediaURLs(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{} + h := (&handler{bots: bots, gateway: gateway}).routes() + photoID := encodeBotAPIFileID("photo:7002:m") + + tests := []struct { + method string + body map[string]any + }{ + {"sendVideoNote", map[string]any{"video_note": "https://example.com/note.mp4"}}, + {"sendLivePhoto", map[string]any{"photo": photoID, "live_photo": "https://example.com/live.mp4"}}, + } + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + test.body["chat_id"] = int64(-1000000003001) + test.body["receiver_user_id"] = int64(2001) + raw, _ := json.Marshal(test.body) + rec := performBotAPIRequest(t, h, bots.profile, test.method, string(raw)) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "FILE_ID_INVALID") { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + }) + } + if len(gateway.ephemeralSends) != 0 { + t.Fatalf("gateway was called: %+v", gateway.ephemeralSends) + } +} + +func TestEphemeralCallbackReplyEditAndDeleteContracts(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + self: domain.User{ID: 1001, FirstName: "Bot", Bot: true}, + ephemeralMessage: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: 1_900_000_000, + Content: domain.EphemeralContent{Message: "sent"}, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + chatID := int64(-1000000003001) + rec := performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"callback_query_id":"991","text":"answer"}`) + if rec.Code != http.StatusOK || len(gateway.ephemeralSends) != 1 || gateway.ephemeralSends[0].CallbackQueryID != 991 { + t.Fatalf("callback send status=%d body=%s inputs=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends) + } + rec = performBotAPIRequest(t, h, bots.profile, "sendMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"reply_parameters":{"ephemeral_message_id":66},"text":"reply"}`) + if rec.Code != http.StatusOK || gateway.ephemeralSends[1].ReplyToEphemeralID != 66 { + t.Fatalf("reply send status=%d body=%s input=%+v", rec.Code, rec.Body.String(), gateway.ephemeralSends[1]) + } + + photoID := encodeBotAPIFileID("photo:7002:m") + media, _ := json.Marshal(map[string]any{"type": "photo", "media": photoID, "caption": "new"}) + edits := []struct { + method string + body map[string]any + }{ + {"editEphemeralMessageText", map[string]any{"text": "edited"}}, + {"editEphemeralMessageMedia", map[string]any{"media": json.RawMessage(media)}}, + {"editEphemeralMessageCaption", map[string]any{"caption": "caption"}}, + {"editEphemeralMessageReplyMarkup", map[string]any{"reply_markup": map[string]any{"inline_keyboard": []any{}}}}, + } + for _, edit := range edits { + body := edit.body + body["chat_id"], body["receiver_user_id"], body["ephemeral_message_id"] = chatID, int64(2001), 77 + raw, _ := json.Marshal(body) + rec = performBotAPIRequest(t, h, bots.profile, edit.method, string(raw)) + if rec.Code != http.StatusOK { + t.Fatalf("%s status=%d body=%s", edit.method, rec.Code, rec.Body.String()) + } + } + if len(gateway.ephemeralEdits) != 4 || gateway.ephemeralEdits[0].Mode != domain.EphemeralEditText || + gateway.ephemeralEdits[1].Mode != domain.EphemeralEditMedia || gateway.ephemeralEdits[1].MediaKind != "photo" || + gateway.ephemeralEdits[2].Mode != domain.EphemeralEditCaption || + gateway.ephemeralEdits[3].Mode != domain.EphemeralEditReplyMarkup || !gateway.ephemeralEdits[3].Fields.SetReplyMarkup { + t.Fatalf("edits=%+v", gateway.ephemeralEdits) + } + rec = performBotAPIRequest(t, h, bots.profile, "deleteEphemeralMessage", `{"chat_id":-1000000003001,"receiver_user_id":2001,"ephemeral_message_id":77}`) + if rec.Code != http.StatusOK || !gateway.ephemeralDeleteCalled || gateway.ephemeralDeleteMessageID != 77 { + t.Fatalf("delete status=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} gateway := &fakeBotAPIGateway{ @@ -980,13 +1251,23 @@ type apiResponse struct { } type fakeBotAPIBots struct { - profile domain.BotProfile + profile domain.BotProfile + commands []domain.BotCommand } func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) { return f.profile, true, nil } +func (f *fakeBotAPIBots) SetBotCommands(_ context.Context, _ int64, commands []domain.BotCommand) (int, error) { + f.commands = append([]domain.BotCommand(nil), commands...) + return 1, nil +} + +func (f *fakeBotAPIBots) GetBotCommands(context.Context, int64) ([]domain.BotCommand, error) { + return append([]domain.BotCommand(nil), f.commands...), nil +} + func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) { return 0, nil } @@ -1038,41 +1319,46 @@ type fakeBotAPIGateway struct { updateBotID int64 updateOffset int64 - sendCalled bool - sendBotID int64 - sendChatID int64 - sendText string - sendEntities []domain.MessageEntity - sendMarkup *domain.MessageReplyMarkup - sendNoWebpage bool - sendSilent bool - sendReplyTo int - sendMessage domain.Message - sendMediaCalled bool - sendMediaKind string - sendMediaChatID int64 - sendMediaFileName string - sendMediaBytes []byte - sendMediaCaption string - sendMediaMessage domain.Message - 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 + sendCalled bool + sendBotID int64 + sendChatID int64 + sendText string + sendEntities []domain.MessageEntity + sendMarkup *domain.MessageReplyMarkup + sendNoWebpage bool + sendSilent bool + sendReplyTo int + sendMessage domain.Message + sendMediaCalled bool + sendMediaKind string + sendMediaChatID int64 + sendMediaFileName string + sendMediaBytes []byte + sendMediaCaption string + sendMediaMessage domain.Message + 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 + ephemeralMessage domain.EphemeralMessage + ephemeralSends []domain.BotAPIEphemeralSendInput + ephemeralEdits []domain.BotAPIEphemeralEditInput + ephemeralDeleteCalled bool + ephemeralDeleteMessageID int } func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, error) { @@ -1206,3 +1492,19 @@ func (f *fakeBotAPIGateway) BotAPIGetFile(_ context.Context, _ int64, locationKe out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...) return out, true, nil } + +func (f *fakeBotAPIGateway) BotAPISendEphemeral(_ context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) { + f.ephemeralSends = append(f.ephemeralSends, input) + return f.ephemeralMessage, nil +} + +func (f *fakeBotAPIGateway) BotAPIEditEphemeral(_ context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) { + f.ephemeralEdits = append(f.ephemeralEdits, input) + return true, nil +} + +func (f *fakeBotAPIGateway) BotAPIDeleteEphemeral(_ context.Context, _ int64, _ int64, _ int64, messageID int) (bool, error) { + f.ephemeralDeleteCalled = true + f.ephemeralDeleteMessageID = messageID + return true, nil +} diff --git a/internal/domain/bot.go b/internal/domain/bot.go index 86e010a3..2517ffc5 100644 --- a/internal/domain/bot.go +++ b/internal/domain/bot.go @@ -92,6 +92,7 @@ const ( type BotCommand struct { Command string `json:"command"` Description string `json:"description"` + Ephemeral bool `json:"ephemeral,omitempty"` } // BotMenuButtonType 标识菜单按钮类型。 diff --git a/internal/domain/botapi_update.go b/internal/domain/botapi_update.go index 50da0b98..9f2599c4 100644 --- a/internal/domain/botapi_update.go +++ b/internal/domain/botapi_update.go @@ -1,5 +1,7 @@ package domain +import "time" + // BotAPIUpdateKind is the Bot API delivery shape for a queued update. type BotAPIUpdateKind string @@ -22,6 +24,109 @@ type BotCallbackQuery struct { InlineMessage *BotInlineMessageID } +// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot. +// Ordinary queued messages are reloaded from their durable message tables; +// ephemeral messages have no such table and therefore travel in this explicit +// envelope instead of overloading SourcePts or an ordinary message id. The +// public shape deliberately cannot represent random IDs, payload hashes, +// auth-key/session identifiers, or the originating device. +type BotAPIEphemeralPayload struct { + Message BotAPIEphemeralMessage + ReplyTo *BotAPIEphemeralMessage `json:",omitempty"` +} + +type BotAPIEphemeralMessage struct { + ID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + Version uint64 + ExpiresAt time.Time +} + +func NewBotAPIEphemeralPayload(message EphemeralMessage) *BotAPIEphemeralPayload { + payload := &BotAPIEphemeralPayload{Message: publicBotAPIEphemeralMessage(message)} + if message.BotAPIReply != nil { + reply := publicBotAPIEphemeralMessage(*message.BotAPIReply) + payload.ReplyTo = &reply + } + return payload +} + +func publicBotAPIEphemeralMessage(message EphemeralMessage) BotAPIEphemeralMessage { + return BotAPIEphemeralMessage{ + ID: message.ID, Peer: message.Peer, + SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID, + Date: message.Date, EditDate: message.EditDate, + TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID, + Content: message.Content, Version: message.Version, ExpiresAt: message.ExpiresAt, + } +} + +func (m BotAPIEphemeralMessage) EphemeralMessage() EphemeralMessage { + return EphemeralMessage{ + ID: m.ID, Peer: m.Peer, + SenderUserID: m.SenderUserID, ReceiverUserID: m.ReceiverUserID, + Date: m.Date, EditDate: m.EditDate, + TopMessageID: m.TopMessageID, ReplyToEphemeralID: m.ReplyToEphemeralID, + Content: m.Content, Version: m.Version, ExpiresAt: m.ExpiresAt, + } +} + +func (p BotAPIEphemeralPayload) EphemeralMessage() EphemeralMessage { + message := p.Message.EphemeralMessage() + if p.ReplyTo != nil { + reply := p.ReplyTo.EphemeralMessage() + message.BotAPIReply = &reply + } + return message +} + +func (p BotAPIEphemeralPayload) Validate() error { + if err := p.Message.Validate(); err != nil { + return err + } + if p.Message.ReplyToEphemeralID == 0 { + if p.ReplyTo != nil { + return ErrEphemeralInvalid + } + return nil + } + if p.ReplyTo == nil || p.ReplyTo.Validate() != nil || p.ReplyTo.ID != p.Message.ReplyToEphemeralID || + p.ReplyTo.Peer != p.Message.Peer || p.ReplyTo.Date > p.Message.Date || + !sameEphemeralParticipantPair(p.Message.SenderUserID, p.Message.ReceiverUserID, p.ReplyTo.SenderUserID, p.ReplyTo.ReceiverUserID) { + return ErrEphemeralInvalid + } + return nil +} + +func sameEphemeralParticipantPair(firstSender, firstReceiver, secondSender, secondReceiver int64) bool { + return (firstSender == secondSender && firstReceiver == secondReceiver) || + (firstSender == secondReceiver && firstReceiver == secondSender) +} + +func (m BotAPIEphemeralMessage) Expired(now time.Time) bool { + return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt) +} + +func (m BotAPIEphemeralMessage) Validate() error { + date := time.Unix(int64(m.Date), 0) + if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 || + m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID || + m.Date <= 0 || m.Version == 0 || m.ExpiresAt.IsZero() || !m.ExpiresAt.After(date) || + m.ExpiresAt.Sub(date) > EphemeralMessageRetention+time.Second || + (m.EditDate != 0 && m.EditDate < m.Date) || m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID || + m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID { + return ErrEphemeralInvalid + } + return ValidateEphemeralContent(m.Content) +} + // 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. @@ -44,6 +149,7 @@ type BotAPIUpdate struct { SourcePts int Date int Callback *BotCallbackQuery + Ephemeral *BotAPIEphemeralPayload } // EnqueueBotAPIUpdateRequest describes a message-like update that should be @@ -56,4 +162,5 @@ type EnqueueBotAPIUpdateRequest struct { SourcePts int Date int Callback *BotCallbackQuery + Ephemeral *BotAPIEphemeralPayload } diff --git a/internal/domain/botapi_update_test.go b/internal/domain/botapi_update_test.go new file mode 100644 index 00000000..86d8eade --- /dev/null +++ b/internal/domain/botapi_update_test.go @@ -0,0 +1,41 @@ +package domain + +import ( + "bytes" + "encoding/json" + "testing" + "time" +) + +func TestBotAPIEphemeralPayloadCannotSerializePrivateRoutingState(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + reply := EphemeralMessage{ + ID: 16, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 3001, ReceiverUserID: 2001, Date: int(now.Unix()) - 1, + Content: EphemeralContent{Message: "prompt"}, Version: 1, ExpiresAt: now.Add(EphemeralMessageRetention), + } + payload := NewBotAPIEphemeralPayload(EphemeralMessage{ + ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), + RandomID: 99, ReplyToEphemeralID: reply.ID, Content: EphemeralContent{Message: "private"}, + OriginDevice: EphemeralDevice{UserID: 2001, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44}, + PayloadHash: [32]byte{5, 6, 7}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), BotAPIReply: &reply, + }) + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + for _, privateField := range [][]byte{ + []byte("RandomID"), []byte("OriginDevice"), []byte("BusinessAuthKeyID"), + []byte("SessionID"), []byte("PayloadHash"), []byte("CreatedAt"), + } { + if bytes.Contains(raw, privateField) { + t.Fatalf("durable Bot API envelope leaked %s: %s", privateField, raw) + } + } + if payload.Validate() != nil || payload.Message.ID != 17 || payload.Message.Content.Message != "private" || payload.Message.ExpiresAt.IsZero() || + payload.ReplyTo == nil || payload.ReplyTo.ID != reply.ID { + t.Fatalf("public payload=%+v", payload) + } +} diff --git a/internal/domain/ephemeral.go b/internal/domain/ephemeral.go new file mode 100644 index 00000000..8faa884e --- /dev/null +++ b/internal/domain/ephemeral.go @@ -0,0 +1,362 @@ +package domain + +import ( + "crypto/sha256" + "errors" + "time" + "unicode/utf8" +) + +const ( + // EphemeralMessageRetention matches TDesktop's in-memory upper bound. The + // server never replays these records; the retention only keeps callback, + // edit, delete and abuse-report lookups coherent across instances. + EphemeralMessageRetention = 48 * time.Hour + // EphemeralReplyWindow is the official Bot API eligible-action window. + EphemeralReplyWindow = 15 * time.Second + // MaxEphemeralCreateAttempts bounds random int32 ID collision retries. + MaxEphemeralCreateAttempts = 8 + // MaxEphemeralCallbackDataBytes is the Bot API callback_data wire limit. + MaxEphemeralCallbackDataBytes = 64 + // MaxEphemeralCaptionLength follows the Bot API media-caption contract. + MaxEphemeralCaptionLength = 1024 + // Rich messages are accepted at the domain boundary only within a bounded + // wire-sized snapshot. The current official client does not send this flag, + // but malformed callers must not be able to retain unbounded block vectors. + MaxEphemeralRichBlocksBytes = 1 << 20 + MaxEphemeralRichMediaRefs = 100 +) + +var ( + ErrEphemeralInvalid = errors.New("ephemeral message invalid") + ErrEphemeralNotFound = errors.New("ephemeral message not found") + ErrEphemeralExpired = errors.New("ephemeral message expired") + ErrEphemeralDeleted = errors.New("ephemeral message deleted") + ErrEphemeralIDCollision = errors.New("ephemeral message id collision") + ErrEphemeralRandomIDConflict = errors.New("ephemeral random id conflict") + ErrEphemeralVersionConflict = errors.New("ephemeral message version conflict") + ErrEphemeralReplyExpired = errors.New("ephemeral reply expired") + ErrEphemeralQueryInvalid = errors.New("ephemeral query invalid") + ErrEphemeralPeerInvalid = errors.New("ephemeral peer invalid") + ErrEphemeralSenderInvalid = errors.New("ephemeral sender invalid") + ErrEphemeralReceiverInvalid = errors.New("ephemeral receiver invalid") + ErrEphemeralCommandInvalid = errors.New("ephemeral command invalid") + ErrEphemeralForbidden = errors.New("ephemeral action forbidden") + ErrEphemeralDeviceMismatch = errors.New("ephemeral device mismatch") + ErrEphemeralCallbackInvalid = errors.New("ephemeral callback invalid") +) + +// EphemeralDevice identifies the exact client application that originated an +// eligible action. BusinessAuthKeyID is the durable device identity; SessionID +// is retained for binding checks and diagnostics, not used as a global key. +type EphemeralDevice struct { + UserID int64 + BusinessAuthKeyID [8]byte + SessionID int64 +} + +// EphemeralContent is the mutable presentation payload. Identity, routing and +// reply ancestry live on EphemeralMessage and never change during edits. +type EphemeralContent struct { + Message string + Entities []MessageEntity + Media *MessageMedia + ReplyMarkup *MessageReplyMarkup + RichMessage *MessageRichMessage +} + +// EphemeralMessage is a short-lived bot/member interaction. It deliberately +// has no ordinary message box ID, pts, qts, seq, unread or dialog fields. +type EphemeralMessage struct { + ID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + OriginDevice EphemeralDevice + PayloadHash [32]byte + Version uint64 + Deleted bool + CreatedAt time.Time + ExpiresAt time.Time + // BotAPIReply is a one-level, runtime-only reply snapshot. It is attached + // after the authoritative message has been written, excluded from Redis and + // broker JSON, and used only to project a valid Bot API reply_to_message. + BotAPIReply *EphemeralMessage `json:"-"` +} + +type SendClientEphemeralRequest struct { + SenderUserID int64 + ReceiverBotID int64 + Peer Peer + QueryID int64 + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + OriginDevice EphemeralDevice +} + +type SendBotEphemeralRequest struct { + BotUserID int64 + ReceiverUserID int64 + Peer Peer + RandomID int64 + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + // ActionMessageID authorizes the ordinary 15-second response path. When it + // is zero the bot must be an administrator and delivery targets every ready + // Layer 228 device of ReceiverUserID. + ActionMessageID int + // CallbackQueryID authorizes a response to a callback originating from a + // bot→user ephemeral message. The shared action record owns the target device. + CallbackQueryID int64 +} + +type EphemeralCallback struct { + Message EphemeralMessage + BotUserID int64 + UserID int64 + Peer Peer + Data []byte + Device EphemeralDevice + OccurredAt time.Time +} + +type EphemeralCallbackAction struct { + QueryID int64 + BotUserID int64 + UserID int64 + Peer Peer + MessageID int + TopMessageID int + Device EphemeralDevice + CreatedAt time.Time + ExpiresAt time.Time +} + +// EphemeralReportEvidence is the durable, device-identity-free snapshot kept +// for abuse review after the transient Redis record expires. It intentionally +// excludes OriginDevice, random IDs and session/auth-key identifiers. +type EphemeralReportEvidence struct { + MessageID int + Peer Peer + SenderUserID int64 + ReceiverUserID int64 + Date int + EditDate int + TopMessageID int + ReplyToEphemeralID int + Content EphemeralContent + PayloadHash [32]byte + Version uint64 +} + +// EphemeralAbuseReport is written only for a final report option. CommentHash +// makes retries idempotent without indexing potentially large user text. +type EphemeralAbuseReport struct { + ReporterUserID int64 + Option string + Comment string + CommentHash [32]byte + Evidence EphemeralReportEvidence + CreatedAt time.Time +} + +func NewEphemeralAbuseReport(reporterUserID int64, option, comment string, message EphemeralMessage, createdAt time.Time) EphemeralAbuseReport { + return EphemeralAbuseReport{ + ReporterUserID: reporterUserID, + Option: option, + Comment: comment, + CommentHash: sha256.Sum256([]byte(comment)), + Evidence: EphemeralReportEvidence{ + MessageID: message.ID, Peer: message.Peer, + SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID, + Date: message.Date, EditDate: message.EditDate, + TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID, + Content: message.Content, PayloadHash: message.PayloadHash, Version: message.Version, + }, + CreatedAt: createdAt, + } +} + +func (r EphemeralAbuseReport) Validate() error { + if r.ReporterUserID <= 0 || r.Option == "" || len(r.Option) > 64 || utf8.RuneCountInString(r.Comment) > 4096 || + r.Evidence.MessageID <= 0 || r.Evidence.MessageID > MaxMessageBoxID || + r.Evidence.Peer.Type != PeerTypeChannel || r.Evidence.Peer.ID <= 0 || + r.Evidence.SenderUserID <= 0 || r.Evidence.ReceiverUserID != r.ReporterUserID || + r.Evidence.SenderUserID == r.Evidence.ReceiverUserID || r.CreatedAt.IsZero() || + r.CommentHash != sha256.Sum256([]byte(r.Comment)) { + return ErrEphemeralInvalid + } + return nil +} + +type EditEphemeralFields struct { + SetMessage bool + Message string + Entities []MessageEntity + SetMedia bool + Media *MessageMedia + SetReplyMarkup bool + ReplyMarkup *MessageReplyMarkup +} + +type BotAPIFileInput struct { + LocationKey string + RemoteURL string + FileName string + MimeType string + Bytes []byte + Width int + Height int + Duration int + Title string + Performer string + Emoji string +} + +type BotAPIEphemeralSendInput struct { + BotUserID int64 + ChatID int64 + ReceiverUserID int64 + CallbackQueryID int64 + ReplyToEphemeralID int + TopMessageID int + Kind string + Text string + Entities []MessageEntity + ReplyMarkup *MessageReplyMarkup + File BotAPIFileInput + SecondaryFile BotAPIFileInput + DirectMedia *MessageMedia +} + +type BotAPIEphemeralEditInput struct { + BotUserID int64 + ChatID int64 + ReceiverUserID int64 + MessageID int + Mode EphemeralEditMode + Fields EditEphemeralFields + MediaKind string + File BotAPIFileInput + SecondaryFile BotAPIFileInput +} + +type EphemeralEditMode string + +const ( + EphemeralEditText EphemeralEditMode = "text" + EphemeralEditMedia EphemeralEditMode = "media" + EphemeralEditCaption EphemeralEditMode = "caption" + EphemeralEditReplyMarkup EphemeralEditMode = "reply_markup" +) + +func (m EphemeralMessage) ValidateForCreate(now time.Time) error { + if err := m.ValidateStored(); err != nil || m.Version != 1 || m.Deleted || !m.ExpiresAt.After(now) { + return ErrEphemeralInvalid + } + return nil +} + +func (m EphemeralMessage) ValidateStored() error { + if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 || + m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID || + m.RandomID == 0 || m.Date <= 0 || m.Version == 0 || m.CreatedAt.IsZero() || m.ExpiresAt.IsZero() || + !m.ExpiresAt.After(m.CreatedAt) || m.ExpiresAt.Sub(m.CreatedAt) > EphemeralMessageRetention || + m.Date != int(m.CreatedAt.Unix()) || (m.EditDate != 0 && m.EditDate < m.Date) || + m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID || + m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID || + m.PayloadHash == ([32]byte{}) { + return ErrEphemeralInvalid + } + zeroDevice := m.OriginDevice == (EphemeralDevice{}) + if !zeroDevice && (m.OriginDevice.UserID <= 0 || m.OriginDevice.BusinessAuthKeyID == ([8]byte{}) || + m.OriginDevice.SessionID == 0 || + (m.OriginDevice.UserID != m.SenderUserID && m.OriginDevice.UserID != m.ReceiverUserID)) { + return ErrEphemeralInvalid + } + if m.Deleted { + if m.Version < 2 || m.Content.Message != "" || len(m.Content.Entities) != 0 || m.Content.Media != nil || + m.Content.ReplyMarkup != nil || !m.Content.RichMessage.IsZero() { + return ErrEphemeralInvalid + } + return nil + } + return ValidateEphemeralContent(m.Content) +} + +func ValidateEphemeralContent(content EphemeralContent) error { + if !utf8.ValidString(content.Message) || utf8.RuneCountInString(content.Message) > MaxMessageTextLength || + len(content.Entities) > MaxMessageEntityCount || !validEphemeralEntityBounds(content.Message, content.Entities) { + return ErrEphemeralInvalid + } + if err := ValidateReplyMarkup(content.ReplyMarkup); err != nil { + return ErrEphemeralInvalid + } + if content.ReplyMarkup != nil && !content.ReplyMarkup.IsZero() && content.ReplyMarkup.Kind() != MessageReplyMarkupInline { + return ErrEphemeralInvalid + } + if content.Media != nil && !validEphemeralMedia(content.Media) { + return ErrEphemeralInvalid + } + if rich := content.RichMessage; !rich.IsZero() { + if len(rich.Blocks) == 0 || len(rich.Blocks) > MaxEphemeralRichBlocksBytes || + len(rich.Photos) > MaxEphemeralRichMediaRefs || len(rich.Documents) > MaxEphemeralRichMediaRefs { + return ErrEphemeralInvalid + } + } + if content.Message == "" && content.Media == nil && content.RichMessage.IsZero() { + return ErrEphemeralInvalid + } + return nil +} + +func validEphemeralEntityBounds(message string, entities []MessageEntity) bool { + utf16Length := 0 + for _, value := range message { + utf16Length++ + if value > 0xffff { + utf16Length++ + } + } + for _, entity := range entities { + if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || + entity.Length > utf16Length-entity.Offset { + return false + } + } + return true +} + +func validEphemeralMedia(media *MessageMedia) bool { + if media == nil || media.IsZero() || media.ServiceAction != nil || media.Dice != nil || media.Poll != nil || + media.GeoLive != nil || media.Todo != nil || media.Story != nil || media.WebPage != nil { + return false + } + switch media.Kind { + case MessageMediaKindPhoto: + return media.Photo != nil && media.Document == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindDocument: + return media.Document != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindContact: + return media.Contact != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Geo == nil && media.Venue == nil + case MessageMediaKindGeo: + return media.Geo != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Venue == nil + case MessageMediaKindVenue: + return media.Venue != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Geo == nil + default: + return false + } +} + +func (m EphemeralMessage) Expired(now time.Time) bool { + return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt) +} diff --git a/internal/domain/ephemeral_test.go b/internal/domain/ephemeral_test.go new file mode 100644 index 00000000..2c28cec5 --- /dev/null +++ b/internal/domain/ephemeral_test.go @@ -0,0 +1,64 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestValidateEphemeralContentBoundsAllRetainedVectors(t *testing.T) { + valid := EphemeralContent{ + Message: "hi 👋", + Entities: []MessageEntity{{Type: MessageEntityBold, Offset: 0, Length: 2}}, + ReplyMarkup: &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{ + Type: MarkupButtonCallback, Text: "OK", Data: []byte("ok"), + }}}}, + } + if err := ValidateEphemeralContent(valid); err != nil { + t.Fatalf("valid content: %v", err) + } + + badBounds := valid + badBounds.Entities = []MessageEntity{{Type: MessageEntityBold, Offset: 5, Length: 2}} + if err := ValidateEphemeralContent(badBounds); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("entity bounds err=%v", err) + } + badKeyboard := valid + badKeyboard.ReplyMarkup = &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{ + Type: MarkupButtonText, Text: "public keyboard", + }}}} + if err := ValidateEphemeralContent(badKeyboard); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("reply keyboard err=%v", err) + } + badRich := EphemeralContent{RichMessage: &MessageRichMessage{Blocks: make([]byte, MaxEphemeralRichBlocksBytes+1)}} + if err := ValidateEphemeralContent(badRich); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("rich bound err=%v", err) + } + badMedia := EphemeralContent{Media: &MessageMedia{Kind: MessageMediaKindPhoto}} + if err := ValidateEphemeralContent(badMedia); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("media shape err=%v", err) + } +} + +func TestEphemeralStoredStateRejectsPartialDeviceAndInvalidTombstone(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + message := EphemeralMessage{ + ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), RandomID: 9, + Content: EphemeralContent{Message: "private"}, OriginDevice: EphemeralDevice{UserID: 3001}, + PayloadHash: [32]byte{1}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), + } + if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("partial device err=%v", err) + } + message.OriginDevice = EphemeralDevice{} + message.Deleted = true + message.Content = EphemeralContent{} + if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) { + t.Fatalf("version-one tombstone err=%v", err) + } + message.Version = 2 + if err := message.ValidateStored(); err != nil { + t.Fatalf("valid tombstone err=%v", err) + } +} diff --git a/internal/domain/media.go b/internal/domain/media.go index bc9fc57e..8875ca39 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -716,25 +716,26 @@ type MessageStarGiftOfferDeclinedAction struct { // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 type MessageMedia struct { - Kind MessageMediaKind `json:"kind"` - Photo *Photo `json:"photo,omitempty"` - Document *Document `json:"document,omitempty"` - Contact *MessageContact `json:"contact,omitempty"` - ServiceAction *MessageServiceAction `json:"service_action,omitempty"` - Geo *MessageGeoPoint `json:"geo,omitempty"` - Venue *MessageVenue `json:"venue,omitempty"` - Dice *MessageDice `json:"dice,omitempty"` - Poll *MessagePoll `json:"poll,omitempty"` - GeoLive *MessageGeoLive `json:"geo_live,omitempty"` - Todo *MessageTodo `json:"todo,omitempty"` - Story *MessageStory `json:"story,omitempty"` - WebPage *MessageWebPage `json:"web_page,omitempty"` - Spoiler bool `json:"spoiler,omitempty"` - TTLSeconds int `json:"ttl_seconds,omitempty"` - Nopremium bool `json:"nopremium,omitempty"` - Voice bool `json:"voice,omitempty"` - Round bool `json:"round,omitempty"` - Video bool `json:"video,omitempty"` + Kind MessageMediaKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + LivePhotoVideo *Document `json:"live_photo_video,omitempty"` + Document *Document `json:"document,omitempty"` + Contact *MessageContact `json:"contact,omitempty"` + ServiceAction *MessageServiceAction `json:"service_action,omitempty"` + Geo *MessageGeoPoint `json:"geo,omitempty"` + Venue *MessageVenue `json:"venue,omitempty"` + Dice *MessageDice `json:"dice,omitempty"` + Poll *MessagePoll `json:"poll,omitempty"` + GeoLive *MessageGeoLive `json:"geo_live,omitempty"` + Todo *MessageTodo `json:"todo,omitempty"` + Story *MessageStory `json:"story,omitempty"` + WebPage *MessageWebPage `json:"web_page,omitempty"` + Spoiler bool `json:"spoiler,omitempty"` + TTLSeconds int `json:"ttl_seconds,omitempty"` + Nopremium bool `json:"nopremium,omitempty"` + Voice bool `json:"voice,omitempty"` + Round bool `json:"round,omitempty"` + Video bool `json:"video,omitempty"` // InvertMedia 映射 message.invert_media:媒体(典型为链接预览)渲染在文本上方。 // 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。 InvertMedia bool `json:"invert_media,omitempty"` diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index 890ab5ed..0c1aa7b8 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -114,6 +114,10 @@ type UpdateEvent struct { QuickReply QuickReply QuickReplyMessage QuickReplyMessage BotCallbackQuery *BotCallbackQuery + // BotAPIUpdateID is the HTTP Bot API update_id. It is intentionally separate + // from MTProto Pts: Bot API ephemeral envelopes never advance account state. + BotAPIUpdateID int64 + EphemeralMessage *EphemeralMessage } // LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有 diff --git a/internal/mtprotoedge/session_manager.go b/internal/mtprotoedge/session_manager.go index 735a4244..7ff5a463 100644 --- a/internal/mtprotoedge/session_manager.go +++ b/internal/mtprotoedge/session_manager.go @@ -1366,15 +1366,19 @@ func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, use func (m *SessionManager) PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass) (int, error) { // Secret-chat qts is the durable source of truth, so online delivery is an accelerator just // like account pts fan-out. Do not synchronously wait for every PFS/raw connection's socket. - return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, 2*time.Second) + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, 2*time.Second) } // PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transient(typing)best-effort 版本。 func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { - return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, t, msg, timeout) + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, 0, t, msg, timeout) } -func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { +func (m *SessionManager) PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + return m.pushToBusinessAuthKeyBestEffort(ctx, userID, businessAuthKeyID, minLayer, t, msg, timeout) +} + +func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { if ctx != nil && ctx.Err() != nil { return 0, ctx.Err() } @@ -1397,7 +1401,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us defer cancel() } getUpdates := onceLayerUpdatesFanout(sendCtx, msg) - return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, func(c *Conn) error { + return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, minLayer, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1420,7 +1424,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us }) } -func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, send func(*Conn) error) (int, error) { +func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, send func(*Conn) error) (int, error) { m.mu.Lock() candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID) conns := make([]*Conn, 0, len(candidates)) @@ -1432,6 +1436,9 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64 // 未就绪:密聊消息靠 getDifference 补,typing 直接丢——都不进 pending。 continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + continue + } conns = append(conns, c) } m.mu.Unlock() @@ -1476,7 +1483,7 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64 func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass) (int, error) { getUpdates := onceLayerUpdatesFanout(ctx, msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, true, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1499,7 +1506,25 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu // 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。 func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { getUpdates := onceLayerUpdatesFanout(ctx, msg) - return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, getUpdates, false, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, 0, t, getUpdates, false, func(c *Conn) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + updates, err := getUpdates() + if err != nil { + return err + } + encoded, err := updates.prepareForConn(ctx, c) + if err != nil { + return err + } + return c.SendBestEffortEncoded(ctx, t, encoded, timeout) + }) +} + +func (m *SessionManager) PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + getUpdates := onceLayerUpdatesFanout(ctx, msg) + return m.pushToUserWithSender(ctx, userID, nil, 0, minLayer, t, getUpdates, false, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1520,6 +1545,10 @@ func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Co } func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { + return m.pushToUserBestEffortAtLeastLayer(ctx, userID, excludeAuthKeyID, excludeSessionID, 0, t, msg, timeout) +} + +func (m *SessionManager) pushToUserBestEffortAtLeastLayer(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { if ctx != nil && ctx.Err() != nil { return 0, ctx.Err() } @@ -1545,7 +1574,7 @@ func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, defer cancel() } getUpdates := onceLayerUpdatesFanout(sendCtx, msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, getUpdates, true, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, minLayer, t, getUpdates, true, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -1592,7 +1621,7 @@ func onceLayerUpdatesFanout(ctx context.Context, msg tg.UpdatesClass) func() (*l } } -func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) { +func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, minLayer int, t proto.MessageType, getUpdates func() (*layerUpdatesFanout, error), queueWhenNotReady bool, send func(*Conn) error) (int, error) { // push fan-out 是连接层最热路径之一:debug 日志的字段构造(含 auth_key hex 格式化) // 在关闭 debug 时也会求值,先查级别一次、按需记日志。 debug := m.log.Core().Enabled(zapcore.DebugLevel) @@ -1612,6 +1641,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excluded++ continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + skipped++ + continue + } if !c.receivesUpdates.Load() { if !queueWhenNotReady { // transient(typing/presence):未就绪即丢,不进 pending。这些 update 不写 @@ -1640,6 +1673,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excluded++ continue } + if !sessionSupportsMinimumLayer(c, minLayer) { + skipped++ + continue + } if !c.receivesUpdates.Load() { if !queueWhenNotReady { skipped++ @@ -2478,6 +2515,17 @@ func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID i return c.authKeyID == *excludeAuthKeyID } +func sessionSupportsMinimumLayer(c *Conn, minLayer int) bool { + if minLayer <= 0 { + return true + } + if c == nil { + return false + } + state := c.LayerProfileState() + return state.Origin != LayerProfileUnknown && int(state.Profile) >= minLayer +} + func sessionKeyLog(id [8]byte) string { return fmt.Sprintf("%x", id) } diff --git a/internal/mtprotoedge/transient_push_test.go b/internal/mtprotoedge/transient_push_test.go index 4498fb8f..931f2a7d 100644 --- a/internal/mtprotoedge/transient_push_test.go +++ b/internal/mtprotoedge/transient_push_test.go @@ -3,11 +3,13 @@ package mtprotoedge import ( "context" "testing" + "time" "go.uber.org/zap/zaptest" "github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" ) // TestPushTransientSkipsNotReadySession 锁定不变量:transient 推送(typing/presence)对 @@ -51,3 +53,57 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) { t.Fatalf("durable push queued %d pending, want 1", n) } } + +// Layer-228-only transient constructors must be filtered before encoding. A +// Layer 227 or unknown session is skipped without disconnecting it or queuing +// an unreplayable update, while the ready Layer 228 session receives it. +func TestPushTransientAtLeastLayerSkipsOldAndUnknownProfiles(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + const userID = int64(101) + makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn { + c := &Conn{ + sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)}, + outbound: make(chan outboundOp, 2), outboundControl: make(chan outboundOp, 2), + outboundStop: make(chan struct{}), + } + c.userID.Store(userID) + c.userIDResolved.Store(true) + c.receivesUpdates.Store(true) + if known { + if err := c.FreezeLayerProfile(profile); err != nil { + t.Fatal(err) + } + } + if err := sm.Register(c); err != nil { + t.Fatal(err) + } + return c + } + old := makeConn(1, tlprofile.Profile227, true) + current := makeConn(2, tlprofile.Profile228, true) + unknown := makeConn(3, 0, false) + + message := tg.EphemeralMessage{ + ID: 7, FromID: &tg.PeerUser{UserID: 2001}, PeerID: &tg.PeerChannel{ChannelID: 3001}, + ReceiverID: userID, Date: 1_900_000_000, Message: "private", + } + updates := &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateNewEphemeralMessage{Message: message}}, Date: 1_900_000_000} + sent, err := sm.PushToUserTransientAtLeastLayer(context.Background(), userID, 228, proto.MessageFromServer, updates, time.Second) + if err != nil || sent != 1 { + t.Fatalf("sent=%d err=%v", sent, err) + } + if len(old.outbound) != 0 || len(unknown.outbound) != 0 || len(current.outbound) != 1 { + t.Fatalf("queues old=%d unknown=%d current=%d", len(old.outbound), len(unknown.outbound), len(current.outbound)) + } + if old.isRetired() || unknown.isRetired() { + t.Fatal("unsupported transient update retired an old/unknown session") + } + for _, c := range []*Conn{old, current, unknown} { + sm.mu.RLock() + pending := len(sm.pending[connSessionKey(c)]) + sm.mu.RUnlock() + if pending != 0 { + t.Fatalf("session %d queued %d transient updates", c.sessionID, pending) + } + } +} diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index df2732c7..faf232d2 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -287,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind, return res.SenderMessage, nil } +func (r *Router) BotAPISendEphemeral(ctx context.Context, input domain.BotAPIEphemeralSendInput) (domain.EphemeralMessage, error) { + if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 { + return domain.EphemeralMessage{}, errors.New("BOT_INVALID") + } + peer, ok := botAPIPeerFromChatID(input.ChatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return domain.EphemeralMessage{}, errors.New("CHAT_ID_INVALID") + } + if err := domain.ValidateReplyMarkup(input.ReplyMarkup); err != nil { + return domain.EphemeralMessage{}, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, input.ReplyMarkup); err != nil { + return domain.EphemeralMessage{}, err + } + baseContent := domain.EphemeralContent{ + Message: input.Text, Entities: append([]domain.MessageEntity(nil), input.Entities...), ReplyMarkup: input.ReplyMarkup, + } + if !utf8.ValidString(baseContent.Message) || utf8.RuneCountInString(baseContent.Message) > domain.MaxMessageTextLength || len(baseContent.Entities) > domain.MaxMessageEntityCount || + !validEphemeralEntityBounds(baseContent.Message, baseContent.Entities) { + return domain.EphemeralMessage{}, errors.New("ENTITY_BOUNDS_INVALID") + } + message, _, err := r.deps.Ephemeral.SendFromBotLazy(ctx, domain.SendBotEphemeralRequest{ + BotUserID: input.BotUserID, ReceiverUserID: input.ReceiverUserID, Peer: peer, + TopMessageID: input.TopMessageID, ReplyToEphemeralID: input.ReplyToEphemeralID, + ActionMessageID: input.ReplyToEphemeralID, CallbackQueryID: input.CallbackQueryID, + }, func(buildCtx context.Context) (domain.EphemeralContent, error) { + content := baseContent + if input.DirectMedia != nil { + content.Media = input.DirectMedia + if content.Media.Geo != nil && content.Media.Geo.AccessHash == 0 { + content.Media.Geo.AccessHash, _ = randomGeoAccessHash() + } + if content.Media.Venue != nil && content.Media.Venue.Geo.AccessHash == 0 { + content.Media.Venue.Geo.AccessHash, _ = randomGeoAccessHash() + } + } else if input.Kind != "message" { + media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.Kind, input.File, input.SecondaryFile) + if err != nil { + return domain.EphemeralContent{}, err + } + content.Media = media + } + return content, nil + }) + if err != nil { + return domain.EphemeralMessage{}, ephemeralBotAPIError(err) + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + return message, nil +} + +func (r *Router) BotAPIEditEphemeral(ctx context.Context, input domain.BotAPIEphemeralEditInput) (bool, error) { + if r == nil || r.deps.Ephemeral == nil || input.BotUserID <= 0 || input.ReceiverUserID <= 0 || input.MessageID <= 0 { + return false, errors.New("MESSAGE_ID_INVALID") + } + peer, ok := botAPIPeerFromChatID(input.ChatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return false, errors.New("CHAT_ID_INVALID") + } + fields := input.Fields + if fields.SetReplyMarkup { + if err := domain.ValidateReplyMarkup(fields.ReplyMarkup); err != nil { + return false, replyMarkupErr(err) + } + if err := r.validateReplyMarkupForPeer(ctx, input.BotUserID, peer, fields.ReplyMarkup); err != nil { + return false, err + } + } + if fields.SetMessage && (!utf8.ValidString(fields.Message) || !validEphemeralEntityBounds(fields.Message, fields.Entities) || utf8.RuneCountInString(fields.Message) > domain.MaxMessageTextLength) { + return false, errors.New("ENTITY_BOUNDS_INVALID") + } + message, err := r.deps.Ephemeral.EditFieldsFromBotLazy(ctx, input.BotUserID, input.ReceiverUserID, peer, input.MessageID, input.Mode, func(buildCtx context.Context) (domain.EditEphemeralFields, error) { + built := fields + if input.MediaKind != "" { + media, err := r.botAPIEphemeralMedia(buildCtx, input.BotUserID, input.MediaKind, input.File, input.SecondaryFile) + if err != nil { + return domain.EditEphemeralFields{}, err + } + built.SetMedia = true + built.Media = media + } + return built, nil + }) + if err != nil { + return false, ephemeralBotAPIError(err) + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushEdit, TargetUserID: message.ReceiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + return true, nil +} + +func (r *Router) BotAPIDeleteEphemeral(ctx context.Context, botUserID, chatID, receiverUserID int64, messageID int) (bool, error) { + peer, ok := botAPIPeerFromChatID(chatID) + if r == nil || r.deps.Ephemeral == nil || !ok || peer.Type != domain.PeerTypeChannel { + return false, errors.New("CHAT_ID_INVALID") + } + message, deleted, err := r.deps.Ephemeral.Delete(ctx, botUserID, receiverUserID, peer, messageID) + if err != nil { + return false, ephemeralBotAPIError(err) + } + if deleted { + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: receiverUserID, + TargetBusinessAuthKey: message.OriginDevice.BusinessAuthKeyID, Message: message, + }) + } + return true, nil +} + +func ephemeralBotAPIError(err error) error { + switch { + case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), errors.Is(err, domain.ErrEphemeralDeleted): + return errors.New("EPHEMERAL_MESSAGE_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralReplyExpired): + return errors.New("EPHEMERAL_ACTION_EXPIRED") + case errors.Is(err, domain.ErrEphemeralPeerInvalid): + return errors.New("CHAT_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralReceiverInvalid): + return errors.New("USER_ID_INVALID") + case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch): + return errors.New("CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrEphemeralVersionConflict): + return errors.New("MESSAGE_NOT_MODIFIED") + default: + return err + } +} + +func (r *Router) botAPIEphemeralMedia(ctx context.Context, botID int64, kind string, file, secondary domain.BotAPIFileInput) (*domain.MessageMedia, error) { + if kind == "live_photo" { + photo, err := r.botAPIMedia(ctx, botID, "photo", file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes) + if err != nil { + return nil, err + } + video, err := r.botAPIDocumentMedia(ctx, botID, "video", secondary) + if err != nil { + return nil, err + } + photo.LivePhotoVideo = video.Document + return photo, nil + } + if kind == "photo" { + return r.botAPIMedia(ctx, botID, kind, file.LocationKey, file.RemoteURL, file.FileName, file.MimeType, file.Bytes) + } + return r.botAPIDocumentMedia(ctx, botID, kind, file) +} + +func (r *Router) botAPIDocumentMedia(ctx context.Context, botID int64, kind string, file domain.BotAPIFileInput) (*domain.MessageMedia, error) { + if r.deps.Files == nil { + return nil, errors.New("MEDIA_INVALID") + } + attrs, forceFile, ok := botAPIDocumentKindAttributes(kind, file) + if !ok { + return nil, errors.New("MEDIA_INVALID") + } + var document domain.Document + var err error + switch { + case len(file.Bytes) > 0: + document, err = r.deps.Files.CreateDocumentFromBytes(ctx, file.Bytes, domain.DocumentSpec{MimeType: file.MimeType, Attributes: attrs, ForceFile: forceFile}) + case file.RemoteURL != "": + document, err = r.deps.Files.CreateDocumentFromURL(ctx, file.RemoteURL) + document.Attributes = mergeDocumentAttributes(document.Attributes, attrs) + case file.LocationKey != "": + id, valid := botAPIDocumentID(file.LocationKey) + if !valid { + return nil, errors.New("FILE_ID_INVALID") + } + var found bool + document, found, err = r.deps.Files.GetDocument(ctx, id) + if err == nil && !found { + err = errors.New("FILE_ID_INVALID") + } + default: + err = errors.New("FILE_ID_INVALID") + } + if err != nil { + return nil, botAPIMediaErr(err) + } + if !botAPIDocumentMatchesKind(document, kind) { + return nil, errors.New("MEDIA_INVALID") + } + return messageMediaFromDocument(document, false, 0), nil +} + +func botAPIDocumentKindAttributes(kind string, file domain.BotAPIFileInput) ([]domain.DocumentAttribute, bool, bool) { + filename := botAPIDocumentAttributes(file.FileName) + w, h, duration := file.Width, file.Height, file.Duration + if w <= 0 { + w = 1 + } + if h <= 0 { + h = 1 + } + if duration <= 0 { + duration = 1 + } + switch kind { + case "document": + return filename, true, true + case "animation": + return append(filename, + domain.DocumentAttribute{Kind: domain.DocAttrAnimated}, + domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), NoSound: true}), false, true + case "audio": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Title: file.Title, Performer: file.Performer}), false, true + case "sticker": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrSticker, W: w, H: h, Alt: file.Emoji}), false, true + case "video": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), SupportsStreaming: true}), false, true + case "video_note": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, Duration: float64(duration), RoundMessage: true, SupportsStreaming: true}), false, true + case "voice": + return append(filename, domain.DocumentAttribute{Kind: domain.DocAttrAudio, AudioDuration: duration, Voice: true}), false, true + default: + return nil, false, false + } +} + +func mergeDocumentAttributes(base, additional []domain.DocumentAttribute) []domain.DocumentAttribute { + out := append([]domain.DocumentAttribute(nil), base...) + seen := make(map[domain.DocumentAttributeKind]struct{}, len(base)+len(additional)) + for _, attribute := range base { + seen[attribute.Kind] = struct{}{} + } + for _, attribute := range additional { + if _, exists := seen[attribute.Kind]; exists { + continue + } + seen[attribute.Kind] = struct{}{} + out = append(out, attribute) + } + return out +} + +func botAPIDocumentMatchesKind(document domain.Document, kind string) bool { + has := func(target domain.DocumentAttributeKind, predicate func(domain.DocumentAttribute) bool) bool { + for _, attribute := range document.Attributes { + if attribute.Kind == target && (predicate == nil || predicate(attribute)) { + return true + } + } + return false + } + switch kind { + case "document": + return document.ID > 0 + case "animation": + return has(domain.DocAttrAnimated, nil) + case "audio": + return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return !a.Voice }) + case "sticker": + return document.IsSticker() + case "video": + return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return !a.RoundMessage }) + case "video_note": + return has(domain.DocAttrVideo, func(a domain.DocumentAttribute) bool { return a.RoundMessage }) + case "voice": + return has(domain.DocAttrAudio, func(a domain.DocumentAttribute) bool { return a.Voice }) + default: + return false + } +} + func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) { switch { case chatID > 0: diff --git a/internal/rpc/botapi_update_queue.go b/internal/rpc/botapi_update_queue.go index f549f0fa..b0492977 100644 --- a/internal/rpc/botapi_update_queue.go +++ b/internal/rpc/botapi_update_queue.go @@ -79,6 +79,9 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok { continue } + if item.Ephemeral != nil { + continue + } if item.Callback != nil && item.Callback.InlineMessage != nil { continue } @@ -171,6 +174,9 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time if !ok { return "", false } + if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) { + return "", false + } if item.Kind == domain.BotAPIUpdateCallbackQuery { if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) { return "", false @@ -205,11 +211,40 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time return eventType, true } +func botAPIQueuedEphemeralValid(botID int64, item domain.BotAPIUpdate, now time.Time) bool { + if item.Ephemeral == nil { + return true + } + message := item.Ephemeral.Message + if item.Ephemeral.Validate() != nil || item.SourcePts != 0 || item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID <= 0 || + message.ID != item.MessageID || message.Peer != item.Peer || message.Expired(now) || + message.SenderUserID <= 0 || message.ReceiverUserID <= 0 { + return false + } + if item.Kind == domain.BotAPIUpdateCallbackQuery { + return message.SenderUserID == botID + } + return message.ReceiverUserID == botID +} + 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 item.Ephemeral != nil { + message := item.Ephemeral.EphemeralMessage() + event := domain.UpdateEvent{ + UserID: botID, Type: eventType, Date: item.Date, Peer: item.Peer, + BotAPIUpdateID: item.ID, EphemeralMessage: &message, + } + if eventType == domain.UpdateEventBotCallbackQuery { + callback := *item.Callback + callback.Data = append([]byte(nil), item.Callback.Data...) + event.BotCallbackQuery = &callback + } + return event, true + } if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil { callback := *item.Callback callback.Data = append([]byte(nil), item.Callback.Data...) @@ -220,6 +255,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, Type: eventType, Pts: int(item.ID), PtsCount: 1, + BotAPIUpdateID: item.ID, Date: item.Date, BotCallbackQuery: &callback, }, true @@ -238,6 +274,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, Type: eventType, Pts: int(item.ID), PtsCount: 1, + BotAPIUpdateID: item.ID, Date: item.Date, Peer: item.Peer, Message: msg, @@ -249,13 +286,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, } msg.Pts = int(item.ID) return domain.UpdateEvent{ - UserID: botID, - Type: eventType, - Pts: int(item.ID), - PtsCount: 1, - Date: item.Date, - Peer: msg.Peer, - Message: msg, + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: msg.Peer, + Message: msg, }, true case domain.PeerTypeChannel: msg, found := channelMessages[item.Peer.ID][item.MessageID] @@ -271,6 +309,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, Type: eventType, Pts: int(item.ID), PtsCount: 1, + BotAPIUpdateID: item.ID, Date: item.Date, Peer: item.Peer, Message: projected, @@ -282,13 +321,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate, } projected.Pts = int(item.ID) return domain.UpdateEvent{ - UserID: botID, - Type: eventType, - Pts: int(item.ID), - PtsCount: 1, - Date: item.Date, - Peer: projected.Peer, - Message: projected, + UserID: botID, + Type: eventType, + Pts: int(item.ID), + PtsCount: 1, + BotAPIUpdateID: item.ID, + Date: item.Date, + Peer: projected.Peer, + Message: projected, }, true default: return domain.UpdateEvent{}, false diff --git a/internal/rpc/bots.go b/internal/rpc/bots.go index 5d714f18..10bed311 100644 --- a/internal/rpc/bots.go +++ b/internal/rpc/bots.go @@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool { func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { out := make([]domain.BotCommand, 0, len(in)) for _, c := range in { - out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description}) + out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral}) } return out } @@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { func tgBotCommands(in []domain.BotCommand) []tg.BotCommand { out := make([]tg.BotCommand, 0, len(in)) for _, c := range in { - out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description}) + out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description, Ephemeral: c.Ephemeral}) } return out } diff --git a/internal/rpc/bots_callback.go b/internal/rpc/bots_callback.go index 65c79d52..4960253c 100644 --- a/internal/rpc/bots_callback.go +++ b/internal/rpc/bots_callback.go @@ -111,6 +111,10 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes Date: int(r.clock.Now().Unix()), }) + return r.waitBotCallbackAnswer(ctx, botUserID, queryID, pending) +} + +func (r *Router) waitBotCallbackAnswer(ctx context.Context, botUserID, queryID int64, pending *pendingCallback) (*tg.MessagesBotCallbackAnswer, error) { waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout) defer cancel() ticker := time.NewTicker(250 * time.Millisecond) diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 784df441..29b36fe8 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -27,6 +27,10 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { if m.TTLSeconds > 0 { out.TTLSeconds = m.TTLSeconds } + if m.LivePhotoVideo != nil { + out.LivePhoto = true + out.SetVideo(tgDocument(*m.LivePhotoVideo)) + } return out case domain.MessageMediaKindDocument: nopremium := m.Nopremium diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index feb34456..8d13b082 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -180,6 +180,15 @@ type AuthKeyTargetedSessionBinder interface { PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) } +// ExactLayerTransientSessionBinder is the admission boundary for updates whose +// constructors do not exist in older profiles. Implementations must filter the +// live session index before encoding, skip unknown/not-ready profiles, and must +// never queue the transient payload for later delivery. +type ExactLayerTransientSessionBinder interface { + PushToUserTransientAtLeastLayer(ctx context.Context, userID int64, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) + PushToUserAuthKeyTransientAtLeastLayer(ctx context.Context, userID int64, businessAuthKeyID [8]byte, minLayer int, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) +} + // OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout. type OnlineUserProvider interface { IsUserOnline(userID int64) bool @@ -805,6 +814,22 @@ type AIComposeService interface { Compose(ctx context.Context, req domain.AIComposeRequest) (domain.AIComposeResult, error) } +// EphemeralService owns Layer 228 short-lived bot/member state. It must never +// write ordinary messages, dialogs, pts/qts/seq logs or durable update outbox. +type EphemeralService interface { + SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) + SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) + SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) + EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) + EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) + 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) + Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) + DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) + Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) + PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) + ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) +} + // Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 type Deps struct { Auth AuthService @@ -817,6 +842,9 @@ type Deps struct { Help HelpService AccountFreeze AccountFreezeService AICompose AIComposeService + Ephemeral EphemeralService + EphemeralPush store.EphemeralPushBroker + EphemeralReports store.EphemeralReportStore Users UsersService Updates UpdatesService BootstrapUpdates store.BootstrapUpdateJobStore diff --git a/internal/rpc/ephemeral.go b/internal/rpc/ephemeral.go new file mode 100644 index 00000000..16b5eda4 --- /dev/null +++ b/internal/rpc/ephemeral.go @@ -0,0 +1,460 @@ +package rpc + +import ( + "context" + "errors" + "unicode/utf8" + + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tgerr" + "github.com/iamxvbaba/td/tlprofile" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func (r *Router) registerEphemeral(d *tlprofile.Dispatcher) { + registerRPC[*tg.EphemeralSendMessageRequest](d, tlprofile.SemanticMethodEphemeralSendMessage, func(ctx context.Context, request *tg.EphemeralSendMessageRequest) (any, error) { + return r.onEphemeralSendMessage(ctx, request) + }) + registerRPC[*tg.EphemeralDeleteMessageRequest](d, tlprofile.SemanticMethodEphemeralDeleteMessage, func(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (any, error) { + return r.onEphemeralDeleteMessage(ctx, request) + }) + registerRPC[*tg.EphemeralReportMessageRequest](d, tlprofile.SemanticMethodEphemeralReportMessage, func(ctx context.Context, request *tg.EphemeralReportMessageRequest) (any, error) { + return r.onEphemeralReportMessage(ctx, request) + }) + registerRPC[*tg.EphemeralGetCallbackAnswerRequest](d, tlprofile.SemanticMethodEphemeralGetCallbackAnswer, func(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (any, error) { + return r.onEphemeralGetCallbackAnswer(ctx, request) + }) +} + +func (r *Router) onEphemeralSendMessage(ctx context.Context, request *tg.EphemeralSendMessageRequest) (tg.UpdatesClass, error) { + if request == nil || r.deps.Ephemeral == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID <= 0 { + return nil, peerIDInvalidErr() + } + receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID) + if err != nil { + return nil, internalErr() + } + if !found || !receiver.Bot { + return nil, userBotInvalidErr() + } + content, err := r.domainEphemeralInputContent(ctx, userID, request) + if err != nil { + return nil, err + } + topMessageID, replyID, err := ephemeralReplyFromInput(request.ReplyTo) + if err != nil { + return nil, err + } + queryID, _ := request.GetQueryID() + authKeyID, authKeyOK := AuthKeyIDFrom(ctx) + sessionID, sessionOK := SessionIDFrom(ctx) + if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 { + return nil, internalErr() + } + message, fresh, err := r.deps.Ephemeral.SendFromClient(ctx, domain.SendClientEphemeralRequest{ + SenderUserID: userID, ReceiverBotID: receiver.ID, Peer: peer, + QueryID: queryID, RandomID: request.RandomID, TopMessageID: topMessageID, + ReplyToEphemeralID: replyID, Content: content, + OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, + }) + if err != nil { + return nil, ephemeralRPCError(err) + } + if fresh && r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: receiver.ID, + Kind: domain.BotAPIUpdateMessage, + Peer: message.Peer, + MessageID: message.ID, + Date: message.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(message), + }); err != nil { + r.log.Warn("enqueue bot api ephemeral message", zap.Int64("bot_user_id", receiver.ID), zap.Int("ephemeral_message_id", message.ID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(receiver.ID) + } + } + if fresh { + // OriginDevice belongs to the human sender and must not constrain the + // receiving bot's sessions. + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: message.ReceiverUserID, Message: message, + }) + } + // A lost create response can be retried after the ephemeral message was + // deleted. The random-id index deliberately returns its tombstone; reflect + // that final fact instead of projecting an impossible empty new message. + if message.Deleted { + return ephemeralDeleteUpdates(message, int(r.clock.Now().Unix())), nil + } + return r.ephemeralMessageUpdates(ctx, userID, message, false) +} + +func (r *Router) onEphemeralGetCallbackAnswer(ctx context.Context, request *tg.EphemeralGetCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return nil, err + } + data, _ := request.GetData() + callback, err := r.deps.Ephemeral.Callback(ctx, userID, device, peer, request.ID, data) + if err != nil { + return nil, ephemeralRPCError(err) + } + queryID, pending, err := r.callbacks.registerContext(ctx, r.clock.Now(), callback.BotUserID, userID, botCallbackTimeout) + if err != nil { + r.log.Warn("register shared ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Error(err)) + return nil, internalErr() + } + defer r.callbacks.deregisterContext(context.Background(), callback.BotUserID, queryID) + created, err := r.deps.Ephemeral.PutCallbackAction(ctx, domain.EphemeralCallbackAction{ + QueryID: queryID, BotUserID: callback.BotUserID, UserID: userID, Peer: peer, + MessageID: request.ID, TopMessageID: callback.Message.TopMessageID, Device: callback.Device, CreatedAt: callback.OccurredAt, + ExpiresAt: callback.OccurredAt.Add(domain.EphemeralReplyWindow), + }) + if err != nil || !created { + return nil, internalErr() + } + + botCallback := domain.BotCallbackQuery{ + ID: queryID, BotUserID: callback.BotUserID, UserID: userID, + Peer: peer, MessageID: request.ID, ChatInstance: chatInstanceForPeer(callback.BotUserID, peer), + Data: append([]byte(nil), data...), + } + if r.deps.BotAPIUpdates != nil { + if _, created, err := r.deps.BotAPIUpdates.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: callback.BotUserID, + Kind: domain.BotAPIUpdateCallbackQuery, + Peer: peer, + MessageID: request.ID, + Date: int(callback.OccurredAt.Unix()), + Callback: &botCallback, + Ephemeral: domain.NewBotAPIEphemeralPayload(callback.Message), + }); err != nil { + r.log.Warn("enqueue bot api ephemeral callback query", zap.Int64("bot_user_id", callback.BotUserID), zap.Int64("query_id", queryID), zap.Error(err)) + return nil, internalErr() + } else if created { + r.notifyBotAPIUpdate(callback.BotUserID) + } + } + + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushCallback, TargetUserID: callback.BotUserID, + Message: callback.Message, Callback: &botCallback, Date: int(callback.OccurredAt.Unix()), + }) + return r.waitBotCallbackAnswer(ctx, callback.BotUserID, queryID, pending) +} + +func (r *Router) onEphemeralDeleteMessage(ctx context.Context, request *tg.EphemeralDeleteMessageRequest) (bool, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return false, err + } + receiver, found, err := r.userFromInput(ctx, userID, request.ReceiverID) + if err != nil { + return false, internalErr() + } + if !found { + return false, userIDInvalidErr() + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return false, err + } + message, deleted, err := r.deps.Ephemeral.DeleteFromDevice(ctx, userID, receiver.ID, device, peer, request.ID) + if err != nil { + return false, ephemeralRPCError(err) + } + if deleted { + for _, targetUserID := range []int64{message.SenderUserID, message.ReceiverUserID} { + var targetAuthKey [8]byte + if message.OriginDevice.UserID == targetUserID { + targetAuthKey = message.OriginDevice.BusinessAuthKeyID + } + r.publishEphemeralPush(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: targetUserID, + TargetBusinessAuthKey: targetAuthKey, Message: message, + }) + } + } + return true, nil +} + +func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.EphemeralReportMessageRequest) (tg.ReportResultClass, error) { + if request == nil || r.deps.Ephemeral == nil || request.ID <= 0 || request.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil || userID <= 0 { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, request.Peer) + if err != nil { + return nil, err + } + device, err := ephemeralDeviceFromContext(ctx, userID) + if err != nil { + return nil, err + } + target, err := r.deps.Ephemeral.ReportTarget(ctx, userID, device, peer, request.ID) + if err != nil { + return nil, ephemeralRPCError(err) + } + if utf8.RuneCountInString(request.Message) > 1024 { + return nil, messageTooLongErr() + } + result, err := reportResultForOption(string(request.Option)) + if err != nil { + return nil, err + } + if _, final := result.(*tg.ReportResultReported); !final { + return result, nil + } + if r.deps.EphemeralReports == nil { + return nil, internalErr() + } + report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now()) + if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil { + r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err)) + return nil, internalErr() + } + return result, nil +} + +func (r *Router) domainEphemeralInputContent(ctx context.Context, userID int64, request *tg.EphemeralSendMessageRequest) (domain.EphemeralContent, error) { + if !utf8.ValidString(request.Message) || utf8.RuneCountInString(request.Message) > domain.MaxMessageTextLength || len(request.Entities) > domain.MaxMessageEntityCount { + return domain.EphemeralContent{}, messageTooLongErr() + } + entities := domainMessageEntitiesForViewer(userID, request.Entities) + if len(entities) != len(request.Entities) || !validEphemeralEntityBounds(request.Message, entities) { + return domain.EphemeralContent{}, tgerr.New(400, "ENTITY_BOUNDS_INVALID") + } + var media *domain.MessageMedia + if request.Media != nil { + resolved, err := r.resolveInputMedia(ctx, userID, request.Media) + if err != nil { + return domain.EphemeralContent{}, err + } + if !ephemeralMediaAllowed(resolved) { + return domain.EphemeralContent{}, mediaTypeInvalidErr() + } + media = resolved + } + var markup *domain.MessageReplyMarkup + if request.ReplyMarkup != nil { + var err error + markup, err = domainReplyMarkupForSender(request.ReplyMarkup, false) + if err != nil { + return domain.EphemeralContent{}, replyMarkupErr(err) + } + } + // Layer 228 exposes f_rich_message on the request but its + // ephemeralMessage result has no field capable of carrying that content. + // Official TDesktop always sends an empty InputRichMessage here. Reject the + // otherwise lossy shape instead of acknowledging content the receiver could + // never reconstruct. + if request.RichMessage != nil { + return domain.EphemeralContent{}, inputConstructorInvalidErr() + } + if request.Message == "" && media == nil { + return domain.EphemeralContent{}, messageEmptyErr() + } + content := domain.EphemeralContent{Message: request.Message, Entities: entities, Media: media, ReplyMarkup: markup} + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralContent{}, inputRequestInvalidErr() + } + return content, nil +} + +func ephemeralReplyFromInput(reply tg.InputReplyToClass) (topMessageID, ephemeralID int, err error) { + switch value := reply.(type) { + case nil: + return 0, 0, nil + case *tg.InputReplyToEphemeralMessage: + if value.ID <= 0 || value.ID > domain.MaxMessageBoxID { + return 0, 0, messageIDInvalidErr() + } + return 0, value.ID, nil + case *tg.InputReplyToMessage: + topMessageID = value.ReplyToMsgID + if explicit, ok := value.GetTopMsgID(); ok { + topMessageID = explicit + } + if topMessageID <= 0 || topMessageID > domain.MaxMessageBoxID { + return 0, 0, messageIDInvalidErr() + } + if value.ReplyToPeerID != nil || value.QuoteText != "" || len(value.QuoteEntities) != 0 || value.QuoteOffset != 0 || + value.MonoforumPeerID != nil || value.TodoItemID != 0 || len(value.PollOption) != 0 { + return 0, 0, inputConstructorInvalidErr() + } + return topMessageID, 0, nil + default: + return 0, 0, inputConstructorInvalidErr() + } +} + +func validEphemeralEntityBounds(message string, entities []domain.MessageEntity) bool { + utf16Length := 0 + for _, runeValue := range message { + utf16Length++ + if runeValue > 0xffff { + utf16Length++ + } + } + for _, entity := range entities { + if entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length || entity.Length > utf16Length-entity.Offset { + return false + } + } + return true +} + +func ephemeralMediaAllowed(media *domain.MessageMedia) bool { + if media == nil || media.IsZero() { + return false + } + switch media.Kind { + case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument, domain.MessageMediaKindContact, + domain.MessageMediaKindGeo, domain.MessageMediaKindVenue: + return true + default: + return false + } +} + +func (r *Router) ephemeralMessageUpdates(ctx context.Context, viewerUserID int64, message domain.EphemeralMessage, edited bool) (*tg.Updates, error) { + if r.deps.Users == nil || r.deps.Channels == nil { + return nil, internalErr() + } + users, err := r.deps.Users.ByIDs(ctx, viewerUserID, []int64{message.SenderUserID, message.ReceiverUserID}) + if err != nil { + return nil, internalErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, message.Peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + wire := tgEphemeralMessage(viewerUserID, message) + var update tg.UpdateClass = &tg.UpdateNewEphemeralMessage{Message: wire} + if edited { + update = &tg.UpdateEditEphemeralMessage{Message: wire} + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: tgUsersForViewer(viewerUserID, users), + Chats: []tg.ChatClass{tgChannelChatForView(viewerUserID, view)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func ephemeralDeleteUpdates(message domain.EphemeralMessage, date int) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateDeleteEphemeralMessages{ + Peer: tgPeer(message.Peer), IDs: []int{message.ID}, + }}, + Date: date, + Seq: 0, + } +} + +func tgEphemeralMessage(viewerUserID int64, message domain.EphemeralMessage) tg.EphemeralMessage { + out := tg.EphemeralMessage{ + Out: viewerUserID == message.SenderUserID, + ID: message.ID, + FromID: &tg.PeerUser{UserID: message.SenderUserID}, + PeerID: tgPeer(message.Peer), + ReceiverID: message.ReceiverUserID, + Date: message.Date, + Message: message.Content.Message, + } + if message.TopMessageID > 0 { + out.SetTopMsgID(message.TopMessageID) + } + if len(message.Content.Entities) != 0 { + out.SetEntities(tgMessageEntities(message.Content.Entities)) + } + if message.Content.Media != nil && !message.Content.Media.IsZero() { + out.SetMedia(tgMessageMedia(message.Content.Media)) + } + if message.Content.ReplyMarkup != nil && !message.Content.ReplyMarkup.IsZero() { + out.SetReplyMarkup(tgReplyMarkup(message.Content.ReplyMarkup)) + } + if message.ReplyToEphemeralID > 0 { + reply := &tg.MessageReplyHeader{ReplyToEphemeral: true} + reply.SetReplyToMsgID(message.ReplyToEphemeralID) + if message.TopMessageID > 0 { + reply.ForumTopic = true + reply.SetReplyToTopID(message.TopMessageID) + } + out.SetReplyTo(reply) + } + return out +} + +func ephemeralDeviceFromContext(ctx context.Context, userID int64) (domain.EphemeralDevice, error) { + authKeyID, authOK := AuthKeyIDFrom(ctx) + sessionID, sessionOK := SessionIDFrom(ctx) + if !authOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 { + return domain.EphemeralDevice{}, internalErr() + } + return domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKeyID, SessionID: sessionID}, nil +} + +func ephemeralRPCError(err error) error { + switch { + case errors.Is(err, domain.ErrEphemeralNotFound), errors.Is(err, domain.ErrEphemeralExpired), + errors.Is(err, domain.ErrEphemeralDeleted), errors.Is(err, domain.ErrEphemeralReplyExpired): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralPeerInvalid): + return peerIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralSenderInvalid), errors.Is(err, domain.ErrEphemeralReceiverInvalid): + return userIDInvalidErr() + case errors.Is(err, domain.ErrEphemeralCommandInvalid): + return tgerr.New(400, "BOT_COMMAND_INVALID") + case errors.Is(err, domain.ErrEphemeralForbidden), errors.Is(err, domain.ErrEphemeralDeviceMismatch): + return tgerr.New(403, "CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrEphemeralCallbackInvalid): + return dataInvalidErr() + case errors.Is(err, domain.ErrEphemeralInvalid), errors.Is(err, domain.ErrEphemeralRandomIDConflict), + errors.Is(err, domain.ErrEphemeralVersionConflict): + return inputRequestInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/ephemeral_push.go b/internal/rpc/ephemeral_push.go new file mode 100644 index 00000000..62216ad7 --- /dev/null +++ b/internal/rpc/ephemeral_push.go @@ -0,0 +1,107 @@ +package rpc + +import ( + "context" + "time" + + "github.com/iamxvbaba/td/proto" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap" + + "telesrv/internal/store" +) + +const ephemeralPushSubscribeRetry = time.Second + +func (r *Router) RunEphemeralPushSubscriber(ctx context.Context) { + if r == nil || r.deps.EphemeralPush == nil { + return + } + for { + err := r.deps.EphemeralPush.SubscribeEphemeralPushes(ctx, func(ctx context.Context, event store.EphemeralPush) { + if event.SourceID == "" || event.SourceID == r.instanceID { + return + } + r.deliverEphemeralPushLocal(ctx, event) + }) + if ctx.Err() != nil { + return + } + if err != nil { + r.log.Warn("ephemeral push subscriber stopped", zap.Error(err)) + } + select { + case <-ctx.Done(): + return + case <-time.After(ephemeralPushSubscribeRetry): + } + } +} + +func (r *Router) publishEphemeralPush(ctx context.Context, event store.EphemeralPush) { + if r == nil || event.TargetUserID <= 0 { + return + } + event.SourceID = r.instanceID + if event.Date <= 0 { + event.Date = int(r.clock.Now().Unix()) + } + r.deliverEphemeralPushLocal(ctx, event) + if r.deps.EphemeralPush != nil { + if err := r.deps.EphemeralPush.PublishEphemeralPush(ctx, event); err != nil { + r.log.Debug("publish ephemeral push", zap.String("kind", string(event.Kind)), zap.Int64("target_user_id", event.TargetUserID), zap.Error(err)) + } + } +} + +func (r *Router) deliverEphemeralPushLocal(ctx context.Context, event store.EphemeralPush) { + if r == nil || r.deps.Sessions == nil || event.TargetUserID <= 0 || event.Message.ID <= 0 { + return + } + if online, ok := r.deps.Sessions.(OnlineUserProvider); ok && !online.IsUserOnline(event.TargetUserID) { + return + } + binder, ok := r.deps.Sessions.(ExactLayerTransientSessionBinder) + if !ok { + return + } + var updates tg.UpdatesClass + switch event.Kind { + case store.EphemeralPushNew, store.EphemeralPushEdit: + if event.TargetUserID != event.Message.ReceiverUserID || event.Message.Deleted { + return + } + built, err := r.ephemeralMessageUpdates(ctx, event.TargetUserID, event.Message, event.Kind == store.EphemeralPushEdit) + if err != nil { + return + } + updates = built + case store.EphemeralPushDelete: + if !event.Message.Deleted || (event.TargetUserID != event.Message.SenderUserID && event.TargetUserID != event.Message.ReceiverUserID) { + return + } + updates = ephemeralDeleteUpdates(event.Message, event.Date) + case store.EphemeralPushCallback: + callback := event.Callback + if callback == nil || callback.BotUserID != event.TargetUserID || callback.MessageID != event.Message.ID || callback.Peer != event.Message.Peer { + return + } + update := &tg.UpdateBotCallbackQuery{ + QueryID: callback.ID, UserID: callback.UserID, Peer: tgPeer(callback.Peer), + MsgID: callback.MessageID, ChatInstance: callback.ChatInstance, + } + update.SetData(callback.Data) + updates = &tg.Updates{Updates: []tg.UpdateClass{update}, Date: event.Date} + default: + return + } + minLayer := 228 + if event.Kind == store.EphemeralPushCallback { + minLayer = 225 + } + if event.TargetBusinessAuthKey != ([8]byte{}) { + _, _ = binder.PushToUserAuthKeyTransientAtLeastLayer(ctx, event.TargetUserID, event.TargetBusinessAuthKey, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout) + return + } + _, _ = binder.PushToUserTransientAtLeastLayer(ctx, event.TargetUserID, minLayer, proto.MessageFromServer, updates, r.cfg.OutboundPushTimeout) +} diff --git a/internal/rpc/ephemeral_push_test.go b/internal/rpc/ephemeral_push_test.go new file mode 100644 index 00000000..7ffd17e6 --- /dev/null +++ b/internal/rpc/ephemeral_push_test.go @@ -0,0 +1,220 @@ +package rpc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/proto" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type ephemeralPushChannels struct { + ChannelsService + view domain.ChannelView + calls int +} + +func (s *ephemeralPushChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + s.calls++ + return s.view, nil +} + +type ephemeralPushSessions struct { + SessionBinder + OnlineUserProvider + mu sync.Mutex + online bool + broadcasts []ephemeralPushCapture + targeted []ephemeralPushCapture +} + +type ephemeralPushCapture struct { + userID int64 + authKey [8]byte + minLayer int + message tg.UpdatesClass +} + +func (s *ephemeralPushSessions) IsUserOnline(int64) bool { return s.online } + +func (s *ephemeralPushSessions) PushToUserTransientAtLeastLayer(_ context.Context, userID int64, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.broadcasts = append(s.broadcasts, ephemeralPushCapture{userID: userID, minLayer: minLayer, message: message}) + return 1, nil +} + +func (s *ephemeralPushSessions) PushToUserAuthKeyTransientAtLeastLayer(_ context.Context, userID int64, authKey [8]byte, minLayer int, _ proto.MessageType, message tg.UpdatesClass, _ time.Duration) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.targeted = append(s.targeted, ephemeralPushCapture{userID: userID, authKey: authKey, minLayer: minLayer, message: message}) + return 1, nil +} + +func (s *ephemeralPushSessions) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.broadcasts), len(s.targeted) +} + +type inMemoryEphemeralBroker struct { + mu sync.Mutex + subscribers []func(context.Context, store.EphemeralPush) + registered chan struct{} + published []store.EphemeralPush +} + +func newInMemoryEphemeralBroker() *inMemoryEphemeralBroker { + return &inMemoryEphemeralBroker{registered: make(chan struct{}, 8)} +} + +func (b *inMemoryEphemeralBroker) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error { + b.mu.Lock() + b.published = append(b.published, event) + handlers := append([]func(context.Context, store.EphemeralPush){}, b.subscribers...) + b.mu.Unlock() + for _, handler := range handlers { + handler(ctx, event) + } + return nil +} + +func (b *inMemoryEphemeralBroker) SubscribeEphemeralPushes(ctx context.Context, handler func(context.Context, store.EphemeralPush)) error { + b.mu.Lock() + b.subscribers = append(b.subscribers, handler) + b.mu.Unlock() + b.registered <- struct{}{} + <-ctx.Done() + return ctx.Err() +} + +func TestEphemeralPushMultiInstanceSourceDedupAndLayerRouting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + broker := newInMemoryEphemeralBroker() + users := mapUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Bot", Bot: true}, + 2001: {ID: 2001, FirstName: "Alice"}, + }} + view := domain.ChannelView{ + Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true}, + Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive}, + } + channels1, channels2 := &ephemeralPushChannels{view: view}, &ephemeralPushChannels{view: view} + sessions1, sessions2 := &ephemeralPushSessions{online: true}, &ephemeralPushSessions{online: true} + r1 := New(Config{InstanceID: "one"}, Deps{Users: users, Channels: channels1, Sessions: sessions1, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System) + r2 := New(Config{InstanceID: "two"}, Deps{Users: users, Channels: channels2, Sessions: sessions2, EphemeralPush: broker}, zaptest.NewLogger(t), clock.System) + go r1.RunEphemeralPushSubscriber(ctx) + go r2.RunEphemeralPushSubscriber(ctx) + for range 2 { + select { + case <-broker.registered: + case <-time.After(time.Second): + t.Fatal("subscriber did not register") + } + } + + now := time.Now() + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + r1.publishEphemeralPush(ctx, store.EphemeralPush{Kind: store.EphemeralPushNew, TargetUserID: 2001, Message: message}) + if broadcast, targeted := sessions1.counts(); broadcast != 1 || targeted != 0 { + t.Fatalf("source delivery broadcast=%d targeted=%d", broadcast, targeted) + } + if broadcast, targeted := sessions2.counts(); broadcast != 1 || targeted != 0 { + t.Fatalf("remote delivery broadcast=%d targeted=%d", broadcast, targeted) + } + if sessions1.broadcasts[0].minLayer != 228 || sessions2.broadcasts[0].minLayer != 228 { + t.Fatalf("min layers source=%d remote=%d", sessions1.broadcasts[0].minLayer, sessions2.broadcasts[0].minLayer) + } + if len(broker.published) != 1 || broker.published[0].SourceID != "one" { + t.Fatalf("published=%+v", broker.published) + } + + key := [8]byte{9, 8, 7} + message.Deleted = true + message.Version++ + message.Content = domain.EphemeralContent{} + r2.deliverEphemeralPushLocal(ctx, store.EphemeralPush{ + Kind: store.EphemeralPushDelete, TargetUserID: 2001, + TargetBusinessAuthKey: key, Message: message, Date: int(time.Now().Unix()), + }) + _, targeted := sessions2.counts() + if targeted != 1 || sessions2.targeted[0].authKey != key || sessions2.targeted[0].minLayer != 228 { + t.Fatalf("targeted=%+v", sessions2.targeted) + } + deletedUpdates, ok := sessions2.targeted[0].message.(*tg.Updates) + if !ok || deletedUpdates.Seq != 0 || len(deletedUpdates.Updates) != 1 { + t.Fatalf("delete updates=%#v", sessions2.targeted[0].message) + } + deleted, ok := deletedUpdates.Updates[0].(*tg.UpdateDeleteEphemeralMessages) + if !ok || len(deleted.IDs) != 1 || deleted.IDs[0] != message.ID { + t.Fatalf("delete update=%#v", deletedUpdates.Updates[0]) + } +} + +func TestEphemeralMessageUpdatesAreTransientAndPtsFree(t *testing.T) { + now := time.Now() + router := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Bot", Bot: true}, + 2001: {ID: 2001, FirstName: "Alice"}, + }}, + Channels: &ephemeralPushChannels{view: domain.ChannelView{ + Channel: domain.Channel{ID: 3001, AccessHash: 7, Title: "Group", Megagroup: true}, + Self: domain.ChannelMember{ChannelID: 3001, UserID: 2001, Status: domain.ChannelMemberActive}, + }}, + }, zaptest.NewLogger(t), clock.System) + message := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + updates, err := router.ephemeralMessageUpdates(context.Background(), 2001, message, false) + if err != nil || updates.Seq != 0 || len(updates.Updates) != 1 { + t.Fatalf("updates=%#v err=%v", updates, err) + } + if _, ok := updates.Updates[0].(*tg.UpdateNewEphemeralMessage); !ok { + t.Fatalf("update type=%T", updates.Updates[0]) + } + deleted := ephemeralDeleteUpdates(domain.EphemeralMessage{ID: message.ID, Peer: message.Peer}, int(now.Unix())) + if deleted.Seq != 0 { + t.Fatalf("delete seq=%d", deleted.Seq) + } +} + +func TestEphemeralPushOfflineSkipsHydration(t *testing.T) { + channels := &ephemeralPushChannels{view: domain.ChannelView{Channel: domain.Channel{ID: 3001}}} + sessions := &ephemeralPushSessions{online: false} + now := time.Now() + router := New(Config{InstanceID: "offline"}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{}}, Channels: channels, Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + router.deliverEphemeralPushLocal(context.Background(), store.EphemeralPush{ + Kind: store.EphemeralPushNew, TargetUserID: 2001, + Message: domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + SenderUserID: 1001, ReceiverUserID: 2001, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "private"}, PayloadHash: [32]byte{1}, Version: 1, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + }, + }) + if channels.calls != 0 { + t.Fatalf("offline push performed %d channel hydrations", channels.calls) + } + if broadcast, targeted := sessions.counts(); broadcast != 0 || targeted != 0 { + t.Fatalf("offline delivery broadcast=%d targeted=%d", broadcast, targeted) + } +} diff --git a/internal/rpc/ephemeral_rpc_test.go b/internal/rpc/ephemeral_rpc_test.go new file mode 100644 index 00000000..a9661b4c --- /dev/null +++ b/internal/rpc/ephemeral_rpc_test.go @@ -0,0 +1,96 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type ephemeralReportChannels struct { + ChannelsService + view domain.ChannelView +} + +func (s *ephemeralReportChannels) ResolveChannel(context.Context, int64, int64) (domain.ChannelView, error) { + return s.view, nil +} + +type ephemeralReportService struct { + EphemeralService + target domain.EphemeralMessage + calls int +} + +func (s *ephemeralReportService) ReportTarget(_ context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) { + s.calls++ + if userID != s.target.ReceiverUserID || device.UserID != userID || device.BusinessAuthKeyID != s.target.OriginDevice.BusinessAuthKeyID || + peer != s.target.Peer || id != s.target.ID { + return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden + } + return s.target, nil +} + +func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) { + const userID int64 = 2001 + const channelID int64 = 3001 + now := time.Now() + authKey := [8]byte{1, 2, 3} + target := domain.EphemeralMessage{ + ID: 77, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + SenderUserID: 1001, ReceiverUserID: userID, Date: int(now.Unix()), RandomID: 78, + Content: domain.EphemeralContent{Message: "abuse"}, + OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99}, + PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + reports := memory.NewEphemeralReportStore() + ephemeral := &ephemeralReportService{target: target} + channels := &ephemeralReportChannels{view: domain.ChannelView{ + Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true}, + Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive}, + }} + router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99) + request := &tg.EphemeralReportMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID, + } + + result, err := router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultChooseOption); !ok || len(reports.Reports()) != 0 { + t.Fatalf("initial result=%T reports=%+v", result, reports.Reports()) + } + request.Option = []byte("other") + result, err = router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultAddComment); !ok || len(reports.Reports()) != 0 { + t.Fatalf("comment result=%T reports=%+v", result, reports.Reports()) + } + request.Option, request.Message = []byte("spam"), "evidence comment" + for range 2 { + result, err = router.onEphemeralReportMessage(ctx, request) + if err != nil { + t.Fatal(err) + } + if _, ok := result.(*tg.ReportResultReported); !ok { + t.Fatalf("final result=%T", result) + } + } + stored := reports.Reports() + if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" { + t.Fatalf("reports=%+v", stored) + } + if ephemeral.calls != 4 { + t.Fatalf("ReportTarget calls=%d", ephemeral.calls) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 52801173..9948a856 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -274,6 +274,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerPremium(d) r.registerAiCompose(d) r.registerBots(d) + r.registerEphemeral(d) r.dispatcher = d return r diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index 0cab9519..c98c8578 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -45,6 +45,12 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser addDomainPeerRef(peer, 0, userIDs, channelIDs) } collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs) + if message := out[i].EphemeralMessage; message != nil { + collectEphemeralMessagePeerRefs(*message, userIDs, channelIDs) + if message.BotAPIReply != nil { + collectEphemeralMessagePeerRefs(*message.BotAPIReply, userIDs, channelIDs) + } + } if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 { userIDs[out[i].BotCallbackQuery.UserID] = struct{}{} } @@ -66,6 +72,24 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser return out } +func collectEphemeralMessagePeerRefs(message domain.EphemeralMessage, userIDs, channelIDs map[int64]struct{}) { + if message.SenderUserID != 0 { + userIDs[message.SenderUserID] = struct{}{} + } + if message.ReceiverUserID != 0 { + userIDs[message.ReceiverUserID] = struct{}{} + } + addDomainPeerRef(message.Peer, 0, userIDs, channelIDs) + for _, entity := range message.Content.Entities { + if entity.UserID != 0 { + userIDs[entity.UserID] = struct{}{} + } + } + if message.Content.Media != nil && message.Content.Media.Contact != nil && message.Content.Media.Contact.UserID != 0 { + userIDs[message.Content.Media.Contact.UserID] = struct{}{} + } +} + type updateEventPeerRefs struct { userIDs map[int64]struct{} channelIDs map[int64]struct{} diff --git a/internal/rpc/users.go b/internal/rpc/users.go index 34f29418..bfdc0857 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -629,7 +629,11 @@ func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) t if len(profile.Commands) > 0 { cmds := make([]tg.BotCommand, 0, len(profile.Commands)) for _, c := range profile.Commands { - cmds = append(cmds, tg.BotCommand{Command: c.Command, Description: c.Description}) + cmds = append(cmds, tg.BotCommand{ + Command: c.Command, + Description: c.Description, + Ephemeral: c.Ephemeral, + }) } info.SetCommands(cmds) } diff --git a/internal/rpc/users_bot_info_test.go b/internal/rpc/users_bot_info_test.go new file mode 100644 index 00000000..d667adac --- /dev/null +++ b/internal/rpc/users_bot_info_test.go @@ -0,0 +1,25 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestTGBotInfoPreservesEphemeralCommandMarker(t *testing.T) { + got := tgBotInfoFromProfile(42, domain.BotProfile{ + Commands: []domain.BotCommand{ + {Command: "public", Description: "visible everywhere"}, + {Command: "private", Description: "Layer 228 only", Ephemeral: true}, + }, + }, true) + if len(got.Commands) != 2 { + t.Fatalf("commands = %+v, want two", got.Commands) + } + if got.Commands[0].Ephemeral { + t.Fatalf("public command = %+v, want ephemeral=false", got.Commands[0]) + } + if !got.Commands[1].Ephemeral { + t.Fatalf("private command = %+v, want ephemeral=true", got.Commands[1]) + } +} diff --git a/internal/store/ephemeral.go b/internal/store/ephemeral.go new file mode 100644 index 00000000..01ba43c9 --- /dev/null +++ b/internal/store/ephemeral.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// EphemeralMessageStore is the short-lived authoritative state used for +// idempotency, callback, edit, delete and report lookups. Implementations must +// make Create atomic across the message ID and random-ID indexes. +type EphemeralMessageStore interface { + CreateEphemeralMessage(ctx context.Context, message domain.EphemeralMessage) (stored domain.EphemeralMessage, created bool, err error) + GetEphemeralMessage(ctx context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) + EditEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) + DeleteEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) + PruneExpiredEphemeralMessages(ctx context.Context, now time.Time, limit int) (int, error) + PutEphemeralCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) + GetEphemeralCallbackAction(ctx context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) +} + +// EphemeralReportStore is deliberately durable: transient messages disappear +// after 48 hours, while a submitted abuse report must retain review evidence. +type EphemeralReportStore interface { + CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (created bool, err error) +} + +type EphemeralPushKind string + +const ( + EphemeralPushNew EphemeralPushKind = "new" + EphemeralPushEdit EphemeralPushKind = "edit" + EphemeralPushDelete EphemeralPushKind = "delete" + EphemeralPushCallback EphemeralPushKind = "callback" +) + +// EphemeralPush is a process-to-process online accelerator. It is deliberately +// not a durable event: Redis Pub/Sub and ready Layer 228 sessions are the only +// consumers, while EphemeralMessageStore remains the short-lived lookup truth. +type EphemeralPush struct { + SourceID string + Kind EphemeralPushKind + TargetUserID int64 + TargetBusinessAuthKey [8]byte + Message domain.EphemeralMessage + Callback *domain.BotCallbackQuery + Date int +} + +type EphemeralPushBroker interface { + PublishEphemeralPush(ctx context.Context, event EphemeralPush) error + SubscribeEphemeralPushes(ctx context.Context, handle func(context.Context, EphemeralPush)) error +} diff --git a/internal/store/memory/botapi_update.go b/internal/store/memory/botapi_update.go index 3230a7f3..673c3385 100644 --- a/internal/store/memory/botapi_update.go +++ b/internal/store/memory/botapi_update.go @@ -253,6 +253,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En SourcePts: req.SourcePts, Date: req.Date, Callback: cloneBotAPICallback(req.Callback), + Ephemeral: cloneBotAPIEphemeral(req.Ephemeral), } s.nextID++ s.rows = append(s.rows, row) @@ -433,6 +434,17 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if req.Ephemeral != nil { + message := req.Ephemeral.Message + if req.Ephemeral.Validate() != nil || message.ID != req.MessageID || message.Peer != req.Peer || message.Expired(time.Unix(int64(req.Date), 0)) || + req.Peer.Type != domain.PeerTypeChannel || req.SourcePts != 0 { + return fmt.Errorf("invalid bot api ephemeral update") + } + if (req.Kind == domain.BotAPIUpdateCallbackQuery && message.SenderUserID != req.BotUserID) || + (req.Kind != domain.BotAPIUpdateCallbackQuery && message.ReceiverUserID != req.BotUserID) { + return fmt.Errorf("invalid bot api ephemeral target") + } + } if req.Kind == domain.BotAPIUpdateCallbackQuery { cb := req.Callback if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || @@ -457,14 +469,25 @@ 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) } + if req.Ephemeral != nil { + return fmt.Sprintf("%d:%s:ephemeral:%s:%d:%d:%d", req.BotUserID, req.Kind, req.Peer.Type, req.Peer.ID, req.MessageID, req.Ephemeral.Message.Version) + } 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) + row.Ephemeral = cloneBotAPIEphemeral(row.Ephemeral) return row } +func cloneBotAPIEphemeral(in *domain.BotAPIEphemeralPayload) *domain.BotAPIEphemeralPayload { + if in == nil { + return nil + } + return domain.NewBotAPIEphemeralPayload(cloneEphemeralMessage(in.EphemeralMessage())) +} + func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery { if in == nil { return nil diff --git a/internal/store/memory/botapi_update_test.go b/internal/store/memory/botapi_update_test.go index ddc70edd..6c9df57c 100644 --- a/internal/store/memory/botapi_update_test.go +++ b/internal/store/memory/botapi_update_test.go @@ -189,3 +189,57 @@ func TestBotAPIInlineCallbackRoundTrip(t *testing.T) { t.Fatalf("inline callback rows=%#v err=%v", rows, err) } } + +func TestBotAPIEphemeralMessageVersionsAndCallbackRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewBotAPIUpdateStore() + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001} + incoming := domain.EphemeralMessage{ + ID: 71, Peer: peer, SenderUserID: 2001, ReceiverUserID: 1001, + Date: int(now.Unix()), RandomID: 1, Content: domain.EphemeralContent{Message: "/private"}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + request := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateMessage, Peer: peer, + MessageID: incoming.ID, Date: incoming.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(incoming), + } + first, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || first.SourcePts != 0 || first.Ephemeral == nil { + t.Fatalf("first=%+v created=%v err=%v", first, created, err) + } + if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID { + t.Fatalf("replay=%+v created=%v err=%v", replay, created, err) + } + incoming.Version = 2 + incoming.EditDate = incoming.Date + 1 + incoming.Content.Message = "edited" + request.Kind = domain.BotAPIUpdateEditedMessage + request.Ephemeral = domain.NewBotAPIEphemeralPayload(incoming) + edited, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || edited.ID <= first.ID { + t.Fatalf("edited=%+v created=%v err=%v", edited, created, err) + } + + outgoing := incoming + outgoing.ID, outgoing.SenderUserID, outgoing.ReceiverUserID = 72, 1001, 2001 + outgoing.Version, outgoing.Content.Message = 1, "button" + callback := &domain.BotCallbackQuery{ + ID: 9001, BotUserID: 1001, UserID: 2001, Peer: peer, + MessageID: outgoing.ID, ChatInstance: 901, Data: []byte("tap"), + } + callbackRow, created, err := store.EnqueueBotAPIUpdate(ctx, domain.EnqueueBotAPIUpdateRequest{ + BotUserID: 1001, Kind: domain.BotAPIUpdateCallbackQuery, Peer: peer, + MessageID: outgoing.ID, Date: outgoing.Date, Callback: callback, + Ephemeral: domain.NewBotAPIEphemeralPayload(outgoing), + }) + if err != nil || !created || callbackRow.Callback == nil || callbackRow.Ephemeral == nil { + t.Fatalf("callback=%+v created=%v err=%v", callbackRow, created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, 1001, first.ID, 100) + if err != nil || len(rows) != 3 || rows[0].Ephemeral.Message.Content.Message != "/private" || + rows[1].Ephemeral.Message.Content.Message != "edited" || string(rows[2].Callback.Data) != "tap" { + t.Fatalf("rows=%+v err=%v", rows, err) + } +} diff --git a/internal/store/memory/ephemeral.go b/internal/store/memory/ephemeral.go new file mode 100644 index 00000000..cb36a24f --- /dev/null +++ b/internal/store/memory/ephemeral.go @@ -0,0 +1,380 @@ +package memory + +import ( + "container/heap" + "context" + "sync" + "sync/atomic" + "time" + + "telesrv/internal/domain" +) + +const ephemeralShardCount = 64 + +type ephemeralMessageKey struct { + peerType domain.PeerType + peerID int64 + id int +} + +type ephemeralRandomKey struct { + peerType domain.PeerType + peerID int64 + senderID int64 + receiverID int64 + randomID int64 +} + +type ephemeralEntry struct { + message domain.EphemeralMessage + generation uint64 +} + +type ephemeralExpiry struct { + key ephemeralMessageKey + expiresAt int64 + generation uint64 +} + +type ephemeralExpiryHeap []ephemeralExpiry + +func (h ephemeralExpiryHeap) Len() int { return len(h) } +func (h ephemeralExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt } +func (h ephemeralExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *ephemeralExpiryHeap) Push(value any) { + *h = append(*h, value.(ephemeralExpiry)) +} + +func (h *ephemeralExpiryHeap) Pop() any { + old := *h + n := len(old) + value := old[n-1] + old[n-1] = ephemeralExpiry{} + *h = old[:n-1] + return value +} + +type ephemeralShard struct { + mu sync.RWMutex + messages map[ephemeralMessageKey]ephemeralEntry + random map[ephemeralRandomKey]ephemeralMessageKey + expiry ephemeralExpiryHeap + nextGeneration uint64 +} + +type ephemeralCallbackActionShard struct { + mu sync.RWMutex + actions map[int64]ephemeralCallbackActionEntry + expiry ephemeralCallbackExpiryHeap + nextGeneration uint64 +} + +type ephemeralCallbackActionEntry struct { + action domain.EphemeralCallbackAction + generation uint64 +} + +type ephemeralCallbackExpiry struct { + queryID int64 + expiresAt int64 + generation uint64 +} + +type ephemeralCallbackExpiryHeap []ephemeralCallbackExpiry + +func (h ephemeralCallbackExpiryHeap) Len() int { return len(h) } +func (h ephemeralCallbackExpiryHeap) Less(i, j int) bool { return h[i].expiresAt < h[j].expiresAt } +func (h ephemeralCallbackExpiryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } + +func (h *ephemeralCallbackExpiryHeap) Push(value any) { + *h = append(*h, value.(ephemeralCallbackExpiry)) +} + +func (h *ephemeralCallbackExpiryHeap) Pop() any { + old := *h + n := len(old) + value := old[n-1] + old[n-1] = ephemeralCallbackExpiry{} + *h = old[:n-1] + return value +} + +// EphemeralMessageStore shards by peer. A create touches one shard, so the ID +// and random-ID indexes can be updated atomically without a process-wide lock. +type EphemeralMessageStore struct { + shards [ephemeralShardCount]ephemeralShard + callbackActions [ephemeralShardCount]ephemeralCallbackActionShard + messageCursor atomic.Uint32 + callbackCursor atomic.Uint32 +} + +func NewEphemeralMessageStore() *EphemeralMessageStore { + s := &EphemeralMessageStore{} + for i := range s.shards { + s.shards[i].messages = make(map[ephemeralMessageKey]ephemeralEntry) + s.shards[i].random = make(map[ephemeralRandomKey]ephemeralMessageKey) + s.callbackActions[i].actions = make(map[int64]ephemeralCallbackActionEntry) + } + return s +} + +func (s *EphemeralMessageStore) PutEphemeralCallbackAction(_ context.Context, action domain.EphemeralCallbackAction) (bool, error) { + if action.QueryID == 0 || action.BotUserID <= 0 || action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel || + action.Peer.ID <= 0 || action.MessageID <= 0 || action.Device.UserID != action.UserID || + action.Device.BusinessAuthKeyID == ([8]byte{}) || action.CreatedAt.IsZero() || !action.ExpiresAt.After(action.CreatedAt) || + action.ExpiresAt.Sub(action.CreatedAt) > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralInvalid + } + shard := &s.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)] + shard.mu.Lock() + defer shard.mu.Unlock() + if existing, ok := shard.actions[action.QueryID]; ok && action.CreatedAt.Before(existing.action.ExpiresAt) { + return false, nil + } + shard.nextGeneration++ + entry := ephemeralCallbackActionEntry{action: action, generation: shard.nextGeneration} + shard.actions[action.QueryID] = entry + heap.Push(&shard.expiry, ephemeralCallbackExpiry{ + queryID: action.QueryID, expiresAt: action.ExpiresAt.UnixNano(), generation: entry.generation, + }) + return true, nil +} + +func (s *EphemeralMessageStore) GetEphemeralCallbackAction(_ context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) { + if botUserID <= 0 || queryID == 0 { + return domain.EphemeralCallbackAction{}, false, nil + } + shard := &s.callbackActions[uint64(queryID)&(ephemeralShardCount-1)] + shard.mu.RLock() + entry, ok := shard.actions[queryID] + if ok && entry.action.BotUserID == botUserID && now.Before(entry.action.ExpiresAt) { + shard.mu.RUnlock() + return entry.action, true, nil + } + shard.mu.RUnlock() + if !ok || entry.action.BotUserID != botUserID { + return domain.EphemeralCallbackAction{}, false, nil + } + shard.mu.Lock() + if current, exists := shard.actions[queryID]; exists && !now.Before(current.action.ExpiresAt) { + delete(shard.actions, queryID) + } + shard.mu.Unlock() + return domain.EphemeralCallbackAction{}, false, nil +} + +func (s *EphemeralMessageStore) CreateEphemeralMessage(_ context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) { + now := message.CreatedAt + if err := message.ValidateForCreate(now); err != nil { + return domain.EphemeralMessage{}, false, err + } + shard := s.shard(message.Peer) + messageKey := ephemeralKey(message.Peer, message.ID) + randomKey := ephemeralRandom(message) + shard.mu.Lock() + defer shard.mu.Unlock() + + if existingKey, ok := shard.random[randomKey]; ok { + if existing, found := shard.messages[existingKey]; found && !existing.message.Expired(now) { + if existing.message.PayloadHash != message.PayloadHash { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict + } + return cloneEphemeralMessage(existing.message), false, nil + } + delete(shard.random, randomKey) + delete(shard.messages, existingKey) + } + if existing, ok := shard.messages[messageKey]; ok { + if !existing.message.Expired(now) { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision + } + delete(shard.random, ephemeralRandom(existing.message)) + delete(shard.messages, messageKey) + } + stored := cloneEphemeralMessage(message) + stored.BotAPIReply = nil + shard.nextGeneration++ + entry := ephemeralEntry{message: stored, generation: shard.nextGeneration} + shard.messages[messageKey] = entry + shard.random[randomKey] = messageKey + heap.Push(&shard.expiry, ephemeralExpiry{ + key: messageKey, + expiresAt: stored.ExpiresAt.UnixNano(), + generation: entry.generation, + }) + return cloneEphemeralMessage(stored), true, nil +} + +func (s *EphemeralMessageStore) GetEphemeralMessage(_ context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.RLock() + entry, ok := shard.messages[key] + if ok && !entry.message.Expired(now) { + message := cloneEphemeralMessage(entry.message) + shard.mu.RUnlock() + return message, true, nil + } + shard.mu.RUnlock() + if !ok { + return domain.EphemeralMessage{}, false, nil + } + shard.mu.Lock() + if entry, ok = shard.messages[key]; ok && entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + } + shard.mu.Unlock() + return domain.EphemeralMessage{}, false, nil +} + +func (s *EphemeralMessageStore) EditEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.Lock() + defer shard.mu.Unlock() + entry, ok := shard.messages[key] + if !ok { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + return domain.EphemeralMessage{}, domain.ErrEphemeralExpired + } + if entry.message.Deleted { + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + } + if expectedVersion == 0 || entry.message.Version != expectedVersion { + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + } + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + entry.message.Content = cloneEphemeralContent(content) + entry.message.EditDate = editDate + entry.message.Version++ + shard.messages[key] = entry + return cloneEphemeralMessage(entry.message), nil +} + +func (s *EphemeralMessageStore) DeleteEphemeralMessage(_ context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) { + key := ephemeralKey(peer, id) + shard := s.shard(peer) + shard.mu.Lock() + defer shard.mu.Unlock() + entry, ok := shard.messages[key] + if !ok { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + } + if entry.message.Expired(now) { + delete(shard.messages, key) + delete(shard.random, ephemeralRandom(entry.message)) + return domain.EphemeralMessage{}, false, domain.ErrEphemeralExpired + } + if entry.message.Deleted { + return cloneEphemeralMessage(entry.message), false, nil + } + if expectedVersion == 0 || entry.message.Version != expectedVersion { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + } + entry.message.Deleted = true + entry.message.Version++ + // Keep a small tombstone until the original TTL. It prevents a delayed + // random-id retry from resurrecting a message after delete. + entry.message.Content = domain.EphemeralContent{} + shard.messages[key] = entry + return cloneEphemeralMessage(entry.message), true, nil +} + +func (s *EphemeralMessageStore) PruneExpiredEphemeralMessages(_ context.Context, now time.Time, limit int) (int, error) { + if limit <= 0 { + return 0, nil + } + deleted := 0 + nowUnixNano := now.UnixNano() + start := int(s.messageCursor.Add(1)-1) & (ephemeralShardCount - 1) + for offset := range ephemeralShardCount { + shard := &s.shards[(start+offset)&(ephemeralShardCount-1)] + shard.mu.Lock() + for deleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano { + expiry := heap.Pop(&shard.expiry).(ephemeralExpiry) + entry, ok := shard.messages[expiry.key] + if !ok || entry.generation != expiry.generation { + continue + } + delete(shard.messages, expiry.key) + delete(shard.random, ephemeralRandom(entry.message)) + deleted++ + } + shard.mu.Unlock() + if deleted >= limit { + break + } + } + // Callback authorizations have an independent 15-second TTL. Give their + // heap an independent bounded budget so a hot message shard cannot starve + // callback cleanup and cause an in-memory deployment to grow forever. + callbackDeleted := 0 + callbackStart := int(s.callbackCursor.Add(1)-1) & (ephemeralShardCount - 1) + for offset := range ephemeralShardCount { + shard := &s.callbackActions[(callbackStart+offset)&(ephemeralShardCount-1)] + shard.mu.Lock() + for callbackDeleted < limit && shard.expiry.Len() > 0 && shard.expiry[0].expiresAt <= nowUnixNano { + expiry := heap.Pop(&shard.expiry).(ephemeralCallbackExpiry) + entry, ok := shard.actions[expiry.queryID] + if !ok || entry.generation != expiry.generation { + continue + } + delete(shard.actions, expiry.queryID) + callbackDeleted++ + } + shard.mu.Unlock() + if callbackDeleted >= limit { + break + } + } + return deleted, nil +} + +func (s *EphemeralMessageStore) shard(peer domain.Peer) *ephemeralShard { + // Peer IDs are already uniformly allocated monotonically; multiplicative + // mixing avoids adjacent hot groups concentrating in neighboring low bits. + index := (uint64(peer.ID) * 11400714819323198485) >> (64 - 6) + return &s.shards[index] +} + +func ephemeralKey(peer domain.Peer, id int) ephemeralMessageKey { + return ephemeralMessageKey{peerType: peer.Type, peerID: peer.ID, id: id} +} + +func ephemeralRandom(message domain.EphemeralMessage) ephemeralRandomKey { + return ephemeralRandomKey{ + peerType: message.Peer.Type, + peerID: message.Peer.ID, + senderID: message.SenderUserID, + receiverID: message.ReceiverUserID, + randomID: message.RandomID, + } +} + +func cloneEphemeralMessage(message domain.EphemeralMessage) domain.EphemeralMessage { + message.Content = cloneEphemeralContent(message.Content) + if message.BotAPIReply != nil { + reply := *message.BotAPIReply + reply.Content = cloneEphemeralContent(reply.Content) + reply.BotAPIReply = nil + message.BotAPIReply = &reply + } + return message +} + +func cloneEphemeralContent(content domain.EphemeralContent) domain.EphemeralContent { + content.Entities = append([]domain.MessageEntity(nil), content.Entities...) + content.Media = cloneRequestedPeerMedia(content.Media) + content.ReplyMarkup = cloneReplyMarkup(content.ReplyMarkup) + content.RichMessage = cloneRichMessage(content.RichMessage) + return content +} diff --git a/internal/store/memory/ephemeral_report.go b/internal/store/memory/ephemeral_report.go new file mode 100644 index 00000000..ed3ca8aa --- /dev/null +++ b/internal/store/memory/ephemeral_report.go @@ -0,0 +1,53 @@ +package memory + +import ( + "context" + "sync" + + "telesrv/internal/domain" +) + +type ephemeralReportKey struct { + reporterUserID int64 + channelID int64 + messageID int + option string + commentHash [32]byte +} + +// EphemeralReportStore is the deterministic in-memory test implementation. +type EphemeralReportStore struct { + mu sync.Mutex + reports map[ephemeralReportKey]domain.EphemeralAbuseReport +} + +func NewEphemeralReportStore() *EphemeralReportStore { + return &EphemeralReportStore{reports: make(map[ephemeralReportKey]domain.EphemeralAbuseReport)} +} + +func (s *EphemeralReportStore) CreateEphemeralReport(_ context.Context, report domain.EphemeralAbuseReport) (bool, error) { + if err := report.Validate(); err != nil { + return false, err + } + key := ephemeralReportKey{ + reporterUserID: report.ReporterUserID, channelID: report.Evidence.Peer.ID, + messageID: report.Evidence.MessageID, option: report.Option, commentHash: report.CommentHash, + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.reports[key]; exists { + return false, nil + } + s.reports[key] = report + return true, nil +} + +func (s *EphemeralReportStore) Reports() []domain.EphemeralAbuseReport { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.EphemeralAbuseReport, 0, len(s.reports)) + for _, report := range s.reports { + out = append(out, report) + } + return out +} diff --git a/internal/store/memory/ephemeral_test.go b/internal/store/memory/ephemeral_test.go new file mode 100644 index 00000000..4648d978 --- /dev/null +++ b/internal/store/memory/ephemeral_test.go @@ -0,0 +1,184 @@ +package memory + +import ( + "context" + "crypto/sha256" + "errors" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestEphemeralMessageStoreCreateReplayEditDeleteAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + message := testEphemeralMessage(now) + created, fresh, err := store.CreateEphemeralMessage(ctx, message) + if err != nil || !fresh || created.ID != message.ID { + t.Fatalf("create = %+v fresh=%v err=%v", created, fresh, err) + } + + replayed, fresh, err := store.CreateEphemeralMessage(ctx, message) + if err != nil || fresh || replayed.Version != 1 { + t.Fatalf("replay = %+v fresh=%v err=%v", replayed, fresh, err) + } + conflict := message + conflict.ID++ + conflict.PayloadHash = sha256.Sum256([]byte("different")) + if _, _, err := store.CreateEphemeralMessage(ctx, conflict); !errors.Is(err, domain.ErrEphemeralRandomIDConflict) { + t.Fatalf("random-id conflict err=%v", err) + } + + edited, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, int(now.Unix())+1, now) + if err != nil || edited.Version != 2 || edited.Content.Message != "edited" { + t.Fatalf("edit = %+v err=%v", edited, err) + } + if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "stale"}, int(now.Unix())+2, now); !errors.Is(err, domain.ErrEphemeralVersionConflict) { + t.Fatalf("stale edit err=%v", err) + } + + deleted, changed, err := store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now) + if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 || deleted.Content.Message != "" { + t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err) + } + deleted, changed, err = store.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 3, now) + if err != nil || changed || !deleted.Deleted { + t.Fatalf("repeat delete = %+v changed=%v err=%v", deleted, changed, err) + } + if _, err := store.EditEphemeralMessage(ctx, message.Peer, message.ID, 3, domain.EphemeralContent{Message: "resurrect"}, int(now.Unix())+3, now); !errors.Is(err, domain.ErrEphemeralDeleted) { + t.Fatalf("edit deleted err=%v", err) + } + + if _, found, err := store.GetEphemeralMessage(ctx, message.Peer, message.ID, message.ExpiresAt); err != nil || found { + t.Fatalf("expired found=%v err=%v", found, err) + } +} + +func TestEphemeralMessageStoreIDCollisionAndBoundedPrune(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_100, 0) + first := testEphemeralMessage(now) + if _, _, err := store.CreateEphemeralMessage(ctx, first); err != nil { + t.Fatal(err) + } + second := first + second.RandomID++ + second.PayloadHash = sha256.Sum256([]byte("second")) + if _, _, err := store.CreateEphemeralMessage(ctx, second); !errors.Is(err, domain.ErrEphemeralIDCollision) { + t.Fatalf("id collision err=%v", err) + } + if got, err := store.PruneExpiredEphemeralMessages(ctx, first.ExpiresAt, 1); err != nil || got != 1 { + t.Fatalf("prune=%d err=%v", got, err) + } +} + +func TestEphemeralCallbackActionExactBotAndExpiry(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + action := domain.EphemeralCallbackAction{ + QueryID: 81, BotUserID: 2001, UserID: 3001, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17, TopMessageID: 42, + Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || created { + t.Fatalf("duplicate created=%v err=%v", created, err) + } + if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID+1, action.QueryID, now); err != nil || found { + t.Fatalf("wrong bot found=%v err=%v", found, err) + } + got, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now) + if err != nil || !found || got.TopMessageID != 42 { + t.Fatalf("get=%+v found=%v err=%v", got, found, err) + } + if _, found, err := store.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, action.ExpiresAt); err != nil || found { + t.Fatalf("expired found=%v err=%v", found, err) + } +} + +func TestEphemeralCallbackActionBoundedHeapPrune(t *testing.T) { + ctx := context.Background() + store := NewEphemeralMessageStore() + now := time.Unix(1_800_000_000, 0) + action := domain.EphemeralCallbackAction{ + QueryID: 82, BotUserID: 2001, UserID: 3001, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, MessageID: 17, + Device: domain.EphemeralDevice{UserID: 3001, BusinessAuthKeyID: [8]byte{1}, SessionID: 9}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := store.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put created=%v err=%v", created, err) + } + if _, err := store.PruneExpiredEphemeralMessages(ctx, action.ExpiresAt, 1); err != nil { + t.Fatalf("prune err=%v", err) + } + shard := &store.callbackActions[uint64(action.QueryID)&(ephemeralShardCount-1)] + shard.mu.RLock() + _, found := shard.actions[action.QueryID] + shard.mu.RUnlock() + if found { + t.Fatal("expired callback action survived bounded heap prune") + } +} + +func TestEphemeralReportStoreIdempotency(t *testing.T) { + store := NewEphemeralReportStore() + now := time.Unix(1_800_000_000, 0) + message := testEphemeralMessage(now) + message.ReceiverUserID = 3001 + report := domain.NewEphemeralAbuseReport(message.ReceiverUserID, "spam", "evidence", message, now) + if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || !created { + t.Fatalf("create=%v err=%v", created, err) + } + if created, err := store.CreateEphemeralReport(context.Background(), report); err != nil || created { + t.Fatalf("retry create=%v err=%v", created, err) + } + reports := store.Reports() + if len(reports) != 1 || reports[0].Evidence.Content.Message != message.Content.Message { + t.Fatalf("reports=%+v", reports) + } +} + +func testEphemeralMessage(now time.Time) domain.EphemeralMessage { + return domain.EphemeralMessage{ + ID: 17, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 1001}, + SenderUserID: 2001, + ReceiverUserID: 3001, + Date: int(now.Unix()), + RandomID: 99, + Content: domain.EphemeralContent{Message: "/private"}, + PayloadHash: sha256.Sum256([]byte("payload")), + Version: 1, + CreatedAt: now, + ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } +} + +func BenchmarkEphemeralMessageStoreParallelCreate(b *testing.B) { + store := NewEphemeralMessageStore() + base := time.Unix(1_800_000_000, 0) + ctx := context.Background() + var sequence atomic.Int64 + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + n := sequence.Add(1) + message := testEphemeralMessage(base) + message.ID = int(n%1_000_000) + 1 + message.Peer.ID += n + message.RandomID += n + message.PayloadHash = sha256.Sum256([]byte{byte(n), byte(n >> 8), byte(n >> 16), byte(n >> 24)}) + if _, _, err := store.CreateEphemeralMessage(ctx, message); err != nil { + b.Errorf("create: %v", err) + } + } + }) +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index 1a5996ee..f930af2e 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -108,6 +108,12 @@ func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia { return nil } clone := *media + if media.LivePhotoVideo != nil { + video := *media.LivePhotoVideo + video.FileReference = append([]byte(nil), media.LivePhotoVideo.FileReference...) + video.Attributes = append([]domain.DocumentAttribute(nil), media.LivePhotoVideo.Attributes...) + clone.LivePhotoVideo = &video + } if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil { return &clone } diff --git a/internal/store/postgres/botapi_update.go b/internal/store/postgres/botapi_update.go index a562d571..d6df7017 100644 --- a/internal/store/postgres/botapi_update.go +++ b/internal/store/postgres/botapi_update.go @@ -1,8 +1,11 @@ package postgres import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "time" "github.com/jackc/pgx/v5" @@ -291,6 +294,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain. var callbackInlineDCID, callbackInlineMessageID int var callbackInlineOwnerID, callbackInlineAccessHash int64 var callbackData []byte + var ephemeralPayload []byte if req.Callback != nil { callbackQueryID = req.Callback.ID callbackUserID = req.Callback.UserID @@ -303,13 +307,21 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain. callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash } } + if req.Ephemeral != nil { + var err error + ephemeralPayload, err = json.Marshal(req.Ephemeral) + if err != nil { + return domain.BotAPIUpdate{}, false, fmt.Errorf("marshal bot api ephemeral payload: %w", err) + } + } row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload +) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb WHERE NOT EXISTS ( SELECT 1 FROM bot_api_update_states @@ -320,7 +332,8 @@ WHERE NOT EXISTS ( 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload ), wake_webhook AS ( UPDATE bot_api_webhooks SET next_attempt_at = now(), updated_at = now() @@ -329,11 +342,12 @@ WHERE NOT EXISTS ( ) 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload 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)) + callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload)) if err == nil { return row, true, nil } @@ -343,16 +357,20 @@ FROM inserted row, err = s.scanBotAPIUpdate(s.db.QueryRow(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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload FROM bot_api_updates WHERE bot_user_id = $1 AND update_kind = $2 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) + (update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND ( + (ephemeral_payload IS NULL AND $8::jsonb IS NULL AND source_pts = $6) + OR (ephemeral_payload = $8::jsonb) + )) ) -`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID)) +`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID, ephemeralPayload)) if err != nil { if err == pgx.ErrNoRows { return domain.BotAPIUpdate{}, false, nil @@ -372,11 +390,13 @@ func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload 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) @@ -417,7 +437,8 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr 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 + callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash, + ephemeral_payload FROM bot_api_updates WHERE bot_user_id = $1 AND id >= $2 ORDER BY id @@ -619,9 +640,11 @@ func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) var callbackInlineDCID, callbackInlineMessageID int var callbackInlineOwnerID, callbackInlineAccessHash int64 var callbackData []byte + var ephemeralPayload []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 { + &callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash, + &ephemeralPayload); err != nil { return domain.BotAPIUpdate{}, err } item.Kind = domain.BotAPIUpdateKind(kind) @@ -640,6 +663,21 @@ func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error) item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash} } } + if len(ephemeralPayload) != 0 { + var payload domain.BotAPIEphemeralPayload + decoder := json.NewDecoder(bytes.NewReader(ephemeralPayload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: trailing JSON") + } + if err := validateBotAPIEphemeralPayload(item.BotUserID, item.Kind, item.Peer, item.MessageID, item.SourcePts, item.Date, &payload); err != nil { + return domain.BotAPIUpdate{}, err + } + item.Ephemeral = &payload + } return item, nil } @@ -662,6 +700,9 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { default: return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) } + if err := validateBotAPIEphemeralPayload(req.BotUserID, req.Kind, req.Peer, req.MessageID, req.SourcePts, req.Date, req.Ephemeral); err != nil { + return err + } if req.Kind == domain.BotAPIUpdateCallbackQuery { cb := req.Callback if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || @@ -681,3 +722,27 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error { } return nil } + +func validateBotAPIEphemeralPayload(botUserID int64, kind domain.BotAPIUpdateKind, peer domain.Peer, messageID, sourcePts, date int, payload *domain.BotAPIEphemeralPayload) error { + if payload == nil { + return nil + } + message := payload.Message + if payload.Validate() != nil || peer.Type != domain.PeerTypeChannel || message.ID != messageID || message.Peer != peer || + message.Expired(time.Unix(int64(date), 0)) || sourcePts != 0 { + return fmt.Errorf("invalid bot api ephemeral update") + } + if kind == domain.BotAPIUpdateCallbackQuery { + if message.SenderUserID != botUserID { + return fmt.Errorf("invalid bot api ephemeral callback target") + } + return nil + } + if kind != domain.BotAPIUpdateMessage && kind != domain.BotAPIUpdateEditedMessage { + return fmt.Errorf("invalid bot api ephemeral update kind") + } + if message.ReceiverUserID != botUserID { + return fmt.Errorf("invalid bot api ephemeral receiver") + } + return nil +} diff --git a/internal/store/postgres/botapi_update_integration_test.go b/internal/store/postgres/botapi_update_integration_test.go index 99d092af..50b72cb5 100644 --- a/internal/store/postgres/botapi_update_integration_test.go +++ b/internal/store/postgres/botapi_update_integration_test.go @@ -406,3 +406,70 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil { t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID) } } + +func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"}) + if err != nil { + t.Fatal(err) + } + human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"}) + 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, 'ephemeral-secret')`, bot.ID); err != nil { + t.Fatal(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) + }) + + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901} + message := domain.EphemeralMessage{ + ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID, + Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + store := NewBotAPIUpdateStore(pool) + request := domain.EnqueueBotAPIUpdateRequest{ + BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer, + MessageID: message.ID, Date: message.Date, + Ephemeral: domain.NewBotAPIEphemeralPayload(message), + } + first, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || first.Ephemeral == nil { + t.Fatalf("first=%+v created=%v err=%v", first, created, err) + } + var leakedPrivateRoutingState bool + if err := pool.QueryRow(ctx, ` + SELECT (ephemeral_payload -> 'Message') ?| ARRAY[ + 'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted' + ] + FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil { + t.Fatal(err) + } + if leakedPrivateRoutingState { + t.Fatal("durable Bot API envelope contains private ephemeral routing fields") + } + if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID { + t.Fatalf("replay=%+v created=%v err=%v", replay, created, err) + } + message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited" + request.Kind = domain.BotAPIUpdateEditedMessage + request.Ephemeral = domain.NewBotAPIEphemeralPayload(message) + second, created, err := store.EnqueueBotAPIUpdate(ctx, request) + if err != nil || !created || second.ID <= first.ID { + t.Fatalf("second=%+v created=%v err=%v", second, created, err) + } + rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100) + if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil || + rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" { + t.Fatalf("rows=%+v err=%v", rows, err) + } +} diff --git a/internal/store/postgres/ephemeral_report.go b/internal/store/postgres/ephemeral_report.go new file mode 100644 index 00000000..6513bab3 --- /dev/null +++ b/internal/store/postgres/ephemeral_report.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// EphemeralReportStore persists the low-volume abuse-review evidence path. +// The hot ephemeral send/edit/delete path remains entirely in Redis. +type EphemeralReportStore struct { + db sqlcgen.DBTX +} + +func NewEphemeralReportStore(db sqlcgen.DBTX) *EphemeralReportStore { + return &EphemeralReportStore{db: db} +} + +func (s *EphemeralReportStore) CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (bool, error) { + if s == nil || s.db == nil { + return false, fmt.Errorf("ephemeral report store is not configured") + } + if err := report.Validate(); err != nil { + return false, err + } + evidence, err := json.Marshal(report.Evidence) + if err != nil { + return false, fmt.Errorf("marshal ephemeral report evidence: %w", err) + } + tag, err := s.db.Exec(ctx, ` +INSERT INTO ephemeral_abuse_reports ( + reporter_user_id, channel_id, ephemeral_message_id, sender_user_id, + receiver_user_id, report_option, report_comment, comment_hash, + payload_hash, evidence, created_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11) +ON CONFLICT ( + reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash +) DO NOTHING +`, report.ReporterUserID, report.Evidence.Peer.ID, report.Evidence.MessageID, + report.Evidence.SenderUserID, report.Evidence.ReceiverUserID, + report.Option, report.Comment, report.CommentHash[:], report.Evidence.PayloadHash[:], evidence, report.CreatedAt) + if err != nil { + return false, fmt.Errorf("insert ephemeral abuse report: %w", err) + } + return tag.RowsAffected() == 1, nil +} diff --git a/internal/store/postgres/ephemeral_report_integration_test.go b/internal/store/postgres/ephemeral_report_integration_test.go new file mode 100644 index 00000000..63cb5843 --- /dev/null +++ b/internal/store/postgres/ephemeral_report_integration_test.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "context" + "encoding/json" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestEphemeralReportStoreDurableEvidenceAndIdempotency(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + now := time.Now() + reporter := now.UnixNano()&0x3fffffff + 1000 + sender := reporter + 1 + messageID := int(now.UnixNano()&0x3fffffff) + 1 + message := domain.EphemeralMessage{ + ID: messageID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: reporter + 2}, + SenderUserID: sender, ReceiverUserID: reporter, Date: int(now.Unix()), RandomID: 99, + Content: domain.EphemeralContent{Message: "abuse evidence"}, + OriginDevice: domain.EphemeralDevice{UserID: reporter, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44}, + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + report := domain.NewEphemeralAbuseReport(reporter, "spam", "review this", message, now) + store := NewEphemeralReportStore(pool) + created, err := store.CreateEphemeralReport(ctx, report) + if err != nil || !created { + t.Fatalf("create=%v err=%v", created, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM ephemeral_abuse_reports WHERE reporter_user_id = $1", reporter) + }) + if created, err := store.CreateEphemeralReport(ctx, report); err != nil || created { + t.Fatalf("retry create=%v err=%v", created, err) + } + var evidenceRaw []byte + var count int + if err := pool.QueryRow(ctx, ` +SELECT evidence, count(*) OVER () +FROM ephemeral_abuse_reports +WHERE reporter_user_id = $1 AND channel_id = $2 AND ephemeral_message_id = $3 +`, reporter, message.Peer.ID, message.ID).Scan(&evidenceRaw, &count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rows=%d", count) + } + var evidence map[string]any + if err := json.Unmarshal(evidenceRaw, &evidence); err != nil { + t.Fatal(err) + } + if evidence["MessageID"] != float64(message.ID) || evidence["Content"] == nil { + t.Fatalf("evidence=%s", evidenceRaw) + } + if _, leaked := evidence["OriginDevice"]; leaked { + t.Fatalf("device identity leaked into report evidence: %s", evidenceRaw) + } +} diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index 82a3af0c..93f42293 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) { if err != nil { t.Fatalf("migrate star gift lifecycle schema: %v", err) } - if status.Dirty || status.Empty || status.Version != 119 { - t.Fatalf("migration status = %+v, want clean version 119", status) + if status.Dirty || status.Empty || status.Version != 121 { + t.Fatalf("migration status = %+v, want clean version 121", status) } } diff --git a/internal/store/redisstore/ephemeral.go b/internal/store/redisstore/ephemeral.go new file mode 100644 index 00000000..162cc918 --- /dev/null +++ b/internal/store/redisstore/ephemeral.go @@ -0,0 +1,458 @@ +package redisstore + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + maxEncodedEphemeralMessageBytes = 2 << 20 + ephemeralPushChannel = "telesrv:ephemeral:push:v1" +) + +type EphemeralMessageStore struct { + c redis.UniversalClient +} + +func NewEphemeralMessageStore(c redis.UniversalClient) *EphemeralMessageStore { + return &EphemeralMessageStore{c: c} +} + +func (s *EphemeralMessageStore) PublishEphemeralPush(ctx context.Context, event store.EphemeralPush) error { + if s == nil || s.c == nil { + return errors.New("redis ephemeral push broker is not configured") + } + if !validEphemeralPush(event) { + return errors.New("invalid ephemeral push") + } + raw, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal ephemeral push: %w", err) + } + if len(raw) > maxEncodedEphemeralMessageBytes { + return errors.New("ephemeral push exceeds encoded size limit") + } + if err := s.c.Publish(ctx, ephemeralPushChannel, raw).Err(); err != nil { + return fmt.Errorf("redis publish ephemeral push: %w", err) + } + return nil +} + +func (s *EphemeralMessageStore) SubscribeEphemeralPushes(ctx context.Context, handle func(context.Context, store.EphemeralPush)) error { + if s == nil || s.c == nil { + return errors.New("redis ephemeral push broker is not configured") + } + if handle == nil { + return errors.New("ephemeral push handler is nil") + } + pubsub := s.c.Subscribe(ctx, ephemeralPushChannel) + defer func() { _ = pubsub.Close() }() + if _, err := pubsub.Receive(ctx); err != nil { + return fmt.Errorf("redis subscribe ephemeral push: %w", err) + } + messages := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case item, ok := <-messages: + if !ok { + return nil + } + if len(item.Payload) > maxEncodedEphemeralMessageBytes { + continue + } + var event store.EphemeralPush + if strictUnmarshalEphemeral([]byte(item.Payload), &event) != nil || !validEphemeralPush(event) { + continue + } + handle(ctx, event) + } + } +} + +func validEphemeralPush(event store.EphemeralPush) bool { + if event.SourceID == "" || event.TargetUserID <= 0 || event.Date <= 0 || event.Message.ID <= 0 || + event.Message.Peer.Type != domain.PeerTypeChannel || event.Message.Peer.ID <= 0 || event.Message.ValidateStored() != nil { + return false + } + if event.TargetBusinessAuthKey != ([8]byte{}) && + (event.Message.OriginDevice.UserID != event.TargetUserID || event.Message.OriginDevice.BusinessAuthKeyID != event.TargetBusinessAuthKey) { + return false + } + switch event.Kind { + case store.EphemeralPushNew, store.EphemeralPushEdit: + return !event.Message.Deleted && event.Callback == nil && event.TargetUserID == event.Message.ReceiverUserID + case store.EphemeralPushDelete: + return event.Message.Deleted && event.Callback == nil && + (event.TargetUserID == event.Message.SenderUserID || event.TargetUserID == event.Message.ReceiverUserID) + case store.EphemeralPushCallback: + return event.Callback != nil && event.Callback.BotUserID == event.TargetUserID && + event.Callback.ID != 0 && event.Callback.UserID == event.Message.ReceiverUserID && + event.Callback.ChatInstance != 0 && len(event.Callback.Data) <= domain.MaxEphemeralCallbackDataBytes && event.Callback.InlineMessage == nil && + event.Callback.MessageID == event.Message.ID && event.Callback.Peer == event.Message.Peer && + event.TargetUserID == event.Message.SenderUserID + default: + return false + } +} + +func ephemeralPeerTag(peer domain.Peer) string { + // A shared Redis Cluster hash tag keeps the message and random-id index in + // the same slot, so the two-key Lua transaction remains cluster-safe. + return fmt.Sprintf("{ephemeral:%s:%d}", peer.Type, peer.ID) +} + +func ephemeralMessageKey(peer domain.Peer, id int) string { + return fmt.Sprintf("telesrv:%s:message:%d", ephemeralPeerTag(peer), id) +} + +func ephemeralRandomKey(message domain.EphemeralMessage) string { + return fmt.Sprintf("telesrv:%s:random:%d:%d:%d", ephemeralPeerTag(message.Peer), + message.SenderUserID, message.ReceiverUserID, message.RandomID) +} + +func ephemeralCallbackActionKey(queryID int64) string { + return fmt.Sprintf("telesrv:ephemeral:callback_action:%d", queryID) +} + +func (s *EphemeralMessageStore) PutEphemeralCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) { + if s == nil || s.c == nil || action.QueryID == 0 || action.BotUserID <= 0 || action.UserID <= 0 || + action.Peer.Type != domain.PeerTypeChannel || action.Peer.ID <= 0 || action.MessageID <= 0 || + action.Device.UserID != action.UserID || action.Device.BusinessAuthKeyID == ([8]byte{}) || action.CreatedAt.IsZero() || + !action.ExpiresAt.After(action.CreatedAt) || action.ExpiresAt.Sub(action.CreatedAt) > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralInvalid + } + ttl := time.Until(action.ExpiresAt) + if ttl <= 0 || ttl > domain.EphemeralReplyWindow { + return false, domain.ErrEphemeralReplyExpired + } + raw, err := json.Marshal(action) + if err != nil { + return false, fmt.Errorf("marshal ephemeral callback action: %w", err) + } + created, err := s.c.SetNX(ctx, ephemeralCallbackActionKey(action.QueryID), raw, ttl).Result() + if err != nil { + return false, fmt.Errorf("redis put ephemeral callback action: %w", err) + } + return created, nil +} + +func (s *EphemeralMessageStore) GetEphemeralCallbackAction(ctx context.Context, botUserID, queryID int64, now time.Time) (domain.EphemeralCallbackAction, bool, error) { + if s == nil || s.c == nil || botUserID <= 0 || queryID == 0 { + return domain.EphemeralCallbackAction{}, false, nil + } + key := ephemeralCallbackActionKey(queryID) + raw, err := s.c.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return domain.EphemeralCallbackAction{}, false, nil + } + if err != nil { + return domain.EphemeralCallbackAction{}, false, fmt.Errorf("redis get ephemeral callback action: %w", err) + } + var action domain.EphemeralCallbackAction + if strictUnmarshalEphemeral(raw, &action) != nil || action.QueryID != queryID || action.BotUserID != botUserID || + action.UserID <= 0 || action.Peer.Type != domain.PeerTypeChannel || action.Peer.ID <= 0 || action.MessageID <= 0 || + action.Device.UserID != action.UserID || !now.Before(action.ExpiresAt) { + _ = s.c.Del(ctx, key).Err() + return domain.EphemeralCallbackAction{}, false, nil + } + return action, true, nil +} + +var createEphemeralMessageScript = redis.NewScript(` +local index = redis.call('GET', KEYS[2]) +if index then + local separator = string.find(index, '\n', 1, true) + if not separator then + return {4, ''} + end + local target = string.sub(index, 1, separator - 1) + local payload_hash = string.sub(index, separator + 1) + local existing = redis.call('GET', target) + if existing then + if payload_hash ~= ARGV[2] then + return {2, ''} + end + return {1, existing} + end + redis.call('DEL', KEYS[2]) +end +if redis.call('EXISTS', KEYS[1]) ~= 0 then + return {3, ''} +end +redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3]) +redis.call('SET', KEYS[2], KEYS[1] .. '\n' .. ARGV[2], 'PX', ARGV[3]) +return {0, ARGV[1]} +`) + +func (s *EphemeralMessageStore) CreateEphemeralMessage(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) { + if s == nil || s.c == nil { + return domain.EphemeralMessage{}, false, errors.New("redis ephemeral store is not configured") + } + now := message.CreatedAt + if err := message.ValidateForCreate(now); err != nil { + return domain.EphemeralMessage{}, false, err + } + ttl := message.ExpiresAt.Sub(now) + if ttl <= 0 || ttl > domain.EphemeralMessageRetention { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid + } + raw, err := marshalEphemeralMessage(message) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + value, err := createEphemeralMessageScript.Run(ctx, s.c, []string{ + ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message), + }, raw, hex.EncodeToString(message.PayloadHash[:]), ttl.Milliseconds()).Result() + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis create ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + switch status { + case 0, 1: + stored, err := unmarshalEphemeralMessage(encoded) + return stored, status == 0, err + case 2: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralRandomIDConflict + case 3: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision + default: + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral create index is corrupt") + } +} + +func (s *EphemeralMessageStore) GetEphemeralMessage(ctx context.Context, peer domain.Peer, id int, now time.Time) (domain.EphemeralMessage, bool, error) { + if s == nil || s.c == nil || peer.ID <= 0 || id <= 0 { + return domain.EphemeralMessage{}, false, nil + } + raw, err := s.c.Get(ctx, ephemeralMessageKey(peer, id)).Bytes() + if errors.Is(err, redis.Nil) { + return domain.EphemeralMessage{}, false, nil + } + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis get ephemeral message: %w", err) + } + message, err := unmarshalEphemeralMessage(raw) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if message.Peer != peer || message.ID != id { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral message identity mismatch") + } + if message.Expired(now) { + return domain.EphemeralMessage{}, false, nil + } + return message, true, nil +} + +var editEphemeralMessageScript = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, ''} +end +local decoded, record = pcall(cjson.decode, raw) +if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then + return {4, ''} +end +if record.Deleted == true then + return {2, raw} +end +if tonumber(record.Version) ~= tonumber(ARGV[1]) then + return {3, raw} +end +if redis.call('PTTL', KEYS[1]) <= 0 then + return {4, ''} +end +redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +return {1, ARGV[2]} +`) + +func (s *EphemeralMessageStore) EditEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, content domain.EphemeralContent, editDate int, now time.Time) (domain.EphemeralMessage, error) { + current, found, err := s.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, err + } + if !found { + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + } + if current.Deleted { + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + } + if expectedVersion == 0 || current.Version != expectedVersion { + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + } + if domain.ValidateEphemeralContent(content) != nil { + return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid + } + current.Content = content + current.EditDate = editDate + current.Version++ + replacement, err := marshalEphemeralMessage(current) + if err != nil { + return domain.EphemeralMessage{}, err + } + value, err := editEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result() + if err != nil { + return domain.EphemeralMessage{}, fmt.Errorf("redis edit ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, err + } + switch status { + case 1: + return unmarshalEphemeralMessage(encoded) + case 0: + return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound + case 2: + return domain.EphemeralMessage{}, domain.ErrEphemeralDeleted + case 3: + return domain.EphemeralMessage{}, domain.ErrEphemeralVersionConflict + default: + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral edit record is corrupt") + } +} + +var deleteEphemeralMessageScript = redis.NewScript(` +local raw = redis.call('GET', KEYS[1]) +if not raw then + return {0, ''} +end +local decoded, record = pcall(cjson.decode, raw) +if not decoded or type(record) ~= 'table' or tonumber(record.Version or 0) <= 0 then + return {4, ''} +end +if record.Deleted == true then + return {2, raw} +end +if tonumber(record.Version) ~= tonumber(ARGV[1]) then + return {3, raw} +end +if redis.call('PTTL', KEYS[1]) <= 0 then + return {4, ''} +end +redis.call('SET', KEYS[1], ARGV[2], 'KEEPTTL') +return {1, ARGV[2]} +`) + +func (s *EphemeralMessageStore) DeleteEphemeralMessage(ctx context.Context, peer domain.Peer, id int, expectedVersion uint64, now time.Time) (domain.EphemeralMessage, bool, error) { + current, found, err := s.GetEphemeralMessage(ctx, peer, id, now) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + if !found { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + } + if current.Deleted { + return current, false, nil + } + if expectedVersion == 0 || current.Version != expectedVersion { + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + } + current.Deleted = true + current.Version++ + current.Content = domain.EphemeralContent{} + replacement, err := marshalEphemeralMessage(current) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + value, err := deleteEphemeralMessageScript.Run(ctx, s.c, []string{ephemeralMessageKey(peer, id)}, expectedVersion, replacement).Result() + if err != nil { + return domain.EphemeralMessage{}, false, fmt.Errorf("redis delete ephemeral message: %w", err) + } + status, encoded, err := decodeEphemeralScriptResult(value) + if err != nil { + return domain.EphemeralMessage{}, false, err + } + switch status { + case 1, 2: + message, err := unmarshalEphemeralMessage(encoded) + return message, status == 1, err + case 0: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound + case 3: + return domain.EphemeralMessage{}, false, domain.ErrEphemeralVersionConflict + default: + return domain.EphemeralMessage{}, false, fmt.Errorf("redis ephemeral delete record is corrupt") + } +} + +func (*EphemeralMessageStore) PruneExpiredEphemeralMessages(context.Context, time.Time, int) (int, error) { + // Redis key expiry is the authoritative O(1) cleanup path; no key scan is + // permitted here because SCAN cost would grow with total ephemeral volume. + return 0, nil +} + +func marshalEphemeralMessage(message domain.EphemeralMessage) ([]byte, error) { + raw, err := json.Marshal(message) + if err != nil { + return nil, fmt.Errorf("marshal ephemeral message: %w", err) + } + if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes { + return nil, domain.ErrEphemeralInvalid + } + return raw, nil +} + +func unmarshalEphemeralMessage(raw []byte) (domain.EphemeralMessage, error) { + if len(raw) == 0 || len(raw) > maxEncodedEphemeralMessageBytes { + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message has invalid encoded size") + } + var message domain.EphemeralMessage + if err := strictUnmarshalEphemeral(raw, &message); err != nil { + return domain.EphemeralMessage{}, fmt.Errorf("decode redis ephemeral message: %w", err) + } + if message.ValidateStored() != nil { + return domain.EphemeralMessage{}, fmt.Errorf("redis ephemeral message violates stored invariants") + } + return message, nil +} + +func strictUnmarshalEphemeral(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("trailing ephemeral JSON") + } + return nil +} + +func decodeEphemeralScriptResult(value any) (int64, []byte, error) { + items, ok := value.([]interface{}) + if !ok || len(items) != 2 { + return 0, nil, fmt.Errorf("redis ephemeral script returned %T", value) + } + status, ok := items[0].(int64) + if !ok { + return 0, nil, fmt.Errorf("redis ephemeral script returned invalid status %T", items[0]) + } + var raw []byte + switch value := items[1].(type) { + case string: + raw = []byte(value) + case []byte: + raw = append([]byte(nil), value...) + case nil: + default: + return 0, nil, fmt.Errorf("redis ephemeral script returned invalid payload %T", items[1]) + } + return status, raw, nil +} diff --git a/internal/store/redisstore/ephemeral_integration_test.go b/internal/store/redisstore/ephemeral_integration_test.go new file mode 100644 index 00000000..d2693275 --- /dev/null +++ b/internal/store/redisstore/ephemeral_integration_test.go @@ -0,0 +1,99 @@ +package redisstore + +import ( + "context" + "crypto/sha256" + "os" + "testing" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestRedisEphemeralAtomicLifecycleCallbackAndBroker(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + client := redis.NewClient(&redis.Options{Addr: addr}) + defer client.Close() + ctx := context.Background() + storeImpl := NewEphemeralMessageStore(client) + now := time.Now() + seed := now.UnixNano() & 0x3fffffff + message := domain.EphemeralMessage{ + ID: int(seed) + 1, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: seed + 2}, + SenderUserID: seed + 3, ReceiverUserID: seed + 4, Date: int(now.Unix()), RandomID: seed + 5, + Content: domain.EphemeralContent{Message: "/private"}, PayloadHash: sha256.Sum256([]byte("payload")), + Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention), + } + t.Cleanup(func() { + _ = client.Del(context.Background(), ephemeralMessageKey(message.Peer, message.ID), ephemeralRandomKey(message), ephemeralCallbackActionKey(seed+6)).Err() + }) + created, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message) + if err != nil || !fresh || created.ID != message.ID { + t.Fatalf("create=%+v fresh=%v err=%v", created, fresh, err) + } + replay, fresh, err := storeImpl.CreateEphemeralMessage(ctx, message) + if err != nil || fresh || replay.ID != message.ID { + t.Fatalf("replay=%+v fresh=%v err=%v", replay, fresh, err) + } + edited, err := storeImpl.EditEphemeralMessage(ctx, message.Peer, message.ID, 1, domain.EphemeralContent{Message: "edited"}, message.Date+1, now) + if err != nil || edited.Version != 2 || edited.Content.Message != "edited" { + t.Fatalf("edit=%+v err=%v", edited, err) + } + deleted, changed, err := storeImpl.DeleteEphemeralMessage(ctx, message.Peer, message.ID, 2, now) + if err != nil || !changed || !deleted.Deleted || deleted.Version != 3 { + t.Fatalf("delete=%+v changed=%v err=%v", deleted, changed, err) + } + + action := domain.EphemeralCallbackAction{ + QueryID: seed + 6, BotUserID: seed + 3, UserID: seed + 4, Peer: message.Peer, + MessageID: message.ID, TopMessageID: 42, + Device: domain.EphemeralDevice{UserID: seed + 4, BusinessAuthKeyID: [8]byte{7}, SessionID: 8}, + CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralReplyWindow), + } + if created, err := storeImpl.PutEphemeralCallbackAction(ctx, action); err != nil || !created { + t.Fatalf("put callback created=%v err=%v", created, err) + } + got, found, err := storeImpl.GetEphemeralCallbackAction(ctx, action.BotUserID, action.QueryID, now) + if err != nil || !found || got.TopMessageID != 42 || got.Device.BusinessAuthKeyID != action.Device.BusinessAuthKeyID { + t.Fatalf("callback=%+v found=%v err=%v", got, found, err) + } + + brokerCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + received := make(chan store.EphemeralPush, 1) + go func() { + _ = storeImpl.SubscribeEphemeralPushes(brokerCtx, func(_ context.Context, event store.EphemeralPush) { + select { + case received <- event: + default: + } + }) + }() + event := store.EphemeralPush{ + SourceID: "redis-test", Kind: store.EphemeralPushDelete, + TargetUserID: message.ReceiverUserID, Message: deleted, Date: int(now.Unix()), + } + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + if err := storeImpl.PublishEphemeralPush(ctx, event); err != nil { + t.Fatal(err) + } + select { + case got := <-received: + if got.SourceID != event.SourceID || got.Message.ID != event.Message.ID || got.Kind != event.Kind { + t.Fatalf("broker event=%+v", got) + } + return + case <-brokerCtx.Done(): + t.Fatal("redis ephemeral broker did not deliver") + case <-ticker.C: + } + } +}