feat: sync ephemeral transient messages

Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages).

Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
A 2026-07-20 16:43:27 +08:00
parent 3f78eaa2c6
commit f49c817def
53 changed files with 5793 additions and 112 deletions

View file

@ -32,6 +32,7 @@ import (
chatlistsapp "telesrv/internal/app/chatlists" chatlistsapp "telesrv/internal/app/chatlists"
"telesrv/internal/app/contacts" "telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs" "telesrv/internal/app/dialogs"
ephemeralapp "telesrv/internal/app/ephemeral"
filesapp "telesrv/internal/app/files" filesapp "telesrv/internal/app/files"
groupcallsapp "telesrv/internal/app/groupcalls" groupcallsapp "telesrv/internal/app/groupcalls"
"telesrv/internal/app/help" "telesrv/internal/app/help"
@ -363,6 +364,8 @@ func run(logger *zap.Logger) error {
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool) botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb) botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
@ -713,6 +716,7 @@ func run(logger *zap.Logger) error {
channelapp.WithReadModelVersions(readModelVersionStore), channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithSendPermissionChecker(adminService), channelapp.WithSendPermissionChecker(adminService),
) )
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
chatlistsService := chatlistsapp.NewService( chatlistsService := chatlistsapp.NewService(
chatlistStore, chatlistStore,
dialogStore, dialogStore,
@ -789,6 +793,9 @@ func run(logger *zap.Logger) error {
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithAccountFreezeProvider(adminService)), Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken), help.WithAccountFreezeProvider(adminService)),
AccountFreeze: adminService, AccountFreeze: adminService,
AICompose: aiComposeService, AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
EphemeralReports: ephemeralReportStore,
Users: usersService, Users: usersService,
Updates: updatesService, Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore, BootstrapUpdates: bootstrapUpdateStore,
@ -903,6 +910,7 @@ func run(logger *zap.Logger) error {
}() }()
go router.RunInlineBotPushSubscriber(ctx) go router.RunInlineBotPushSubscriber(ctx)
go router.RunBotCallbackAnswerSubscriber(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 { if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err) return fmt.Errorf("start bot api: %w", err)
} }

View file

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

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.ephemeral_abuse_reports;

View file

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

2
go.mod
View file

@ -8,7 +8,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/gotd/ige v0.2.2 github.com/gotd/ige v0.2.2
github.com/gotd/log/logzap v0.1.1 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/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/pion/datachannel v1.6.2 github.com/pion/datachannel v1.6.2

2
go.sum
View file

@ -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/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 h1:6Ddxi8sOuxOioGs3vyDGWlC6q53j4AQ2hFJ4AirJvFk=
github.com/iamxvbaba/td v1.1.0/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04= 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 h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View file

@ -27,7 +27,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
before, _, _ := users.ByID(ctx, bot.ID) before, _, _ := users.ByID(ctx, bot.ID)
v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{ v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{
{Command: "/Start", Description: "begin"}, {Command: "/Start", Description: "begin", Ephemeral: true},
{Command: "help", Description: "show help"}, {Command: "help", Description: "show help"},
}) })
if err != nil { if err != nil {
@ -40,7 +40,7 @@ func TestSetBotCommandsAndBump(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get commands: %v", err) 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) t.Fatalf("commands = %+v, want normalized [start,help]", got)
} }

View file

@ -497,7 +497,7 @@ func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands
if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen { if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen {
return 0, domain.ErrBotCommandInvalid 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 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的
// bot_info_version bump驱动全体客户端多打一轮 getFullUser与多余推送。 // bot_info_version bump驱动全体客户端多打一轮 getFullUser与多余推送。
@ -528,7 +528,7 @@ func botCommandsEqual(a, b []domain.BotCommand) bool {
return false return false
} }
for i := range a { 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 return false
} }
} }

View file

@ -0,0 +1,617 @@
package ephemeral
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"strings"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type ChannelAccess interface {
ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
}
type UserDirectory interface {
ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error)
}
type BotCommands interface {
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
}
type Option func(*Service)
func WithClock(now func() time.Time) Option {
return func(s *Service) {
if now != nil {
s.now = now
}
}
}
func WithIDGenerator(next func() (int, error)) Option {
return func(s *Service) {
if next != nil {
s.nextID = next
}
}
}
type Service struct {
messages store.EphemeralMessageStore
channels ChannelAccess
users UserDirectory
bots BotCommands
now func() time.Time
nextID func() (int, error)
}
func NewService(messages store.EphemeralMessageStore, channels ChannelAccess, users UserDirectory, bots BotCommands, options ...Option) *Service {
s := &Service{
messages: messages,
channels: channels,
users: users,
bots: bots,
now: time.Now,
nextID: randomEphemeralID,
}
for _, option := range options {
if option != nil {
option(s)
}
}
return s
}
func (s *Service) SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) {
if s == nil || s.messages == nil || s.channels == nil || s.users == nil || s.bots == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.SenderUserID <= 0 || request.ReceiverBotID <= 0 || request.SenderUserID == request.ReceiverBotID ||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 || request.RandomID == 0 ||
request.OriginDevice.UserID != request.SenderUserID || request.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
request.OriginDevice.SessionID == 0 || !validContent(request.Content) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
view, err := s.requireActiveGroupPair(ctx, request.SenderUserID, request.ReceiverBotID, request.Peer.ID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
receiver, found, err := s.users.ByID(ctx, request.SenderUserID, request.ReceiverBotID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || !receiver.Bot || receiver.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
}
var replyTarget *domain.EphemeralMessage
if request.ReplyToEphemeralID != 0 {
target, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, s.now())
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || target.Deleted || target.SenderUserID != request.ReceiverBotID || target.ReceiverUserID != request.SenderUserID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
if target.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && target.OriginDevice.BusinessAuthKeyID != request.OriginDevice.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
if request.TopMessageID != 0 && request.TopMessageID != target.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = target.TopMessageID
replyTarget = &target
} else {
allowed, err := s.isEphemeralCommand(ctx, receiver, request.Content.Message)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !allowed {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralCommandInvalid
}
}
if err := s.validateForumTopic(ctx, request.SenderUserID, view, request.TopMessageID); err != nil {
return domain.EphemeralMessage{}, false, err
}
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
Peer: request.Peer,
SenderUserID: request.SenderUserID,
ReceiverUserID: request.ReceiverBotID,
RandomID: request.RandomID,
TopMessageID: request.TopMessageID,
ReplyToEphemeralID: request.ReplyToEphemeralID,
Content: request.Content,
OriginDevice: request.OriginDevice,
PayloadHash: clientPayloadHash(request),
})
if err == nil && replyTarget != nil {
message.BotAPIReply = replyTarget
}
return message, fresh, err
}
func (s *Service) SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) {
return s.sendFromBot(ctx, request, func(context.Context) (domain.EphemeralContent, error) {
return request.Content, nil
})
}
// SendFromBotLazy authorizes the bot, receiver, chat and eligible action before
// materializing content. The RPC edge uses it for URL/upload media so an
// unauthorized target cannot consume file storage, network or decoder work.
func (s *Service) SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
if build == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
return s.sendFromBot(ctx, request, build)
}
func (s *Service) sendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
if s == nil || s.messages == nil || s.channels == nil || s.users == nil {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.BotUserID <= 0 || request.ReceiverUserID <= 0 || request.BotUserID == request.ReceiverUserID ||
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
view, err := s.requireActiveGroupPair(ctx, request.BotUserID, request.ReceiverUserID, request.Peer.ID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
bot, found, err := s.users.ByID(ctx, request.BotUserID, request.BotUserID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || !bot.Bot || bot.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralSenderInvalid
}
receiver, found, err := s.users.ByID(ctx, request.BotUserID, request.ReceiverUserID)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || receiver.Bot || receiver.Deleted {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
}
now := s.now()
var targetDevice domain.EphemeralDevice
var replyTarget *domain.EphemeralMessage
if request.ActionMessageID != 0 && request.CallbackQueryID != 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
if request.CallbackQueryID != 0 {
action, found, err := s.messages.GetEphemeralCallbackAction(ctx, request.BotUserID, request.CallbackQueryID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || action.UserID != request.ReceiverUserID || action.Peer != request.Peer || !now.Before(action.ExpiresAt) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
targetDevice = action.Device
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = action.TopMessageID
} else if request.ActionMessageID != 0 {
action, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ActionMessageID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found || action.Deleted || action.SenderUserID != request.ReceiverUserID || action.ReceiverUserID != request.BotUserID ||
now.Sub(action.CreatedAt) < 0 || now.Sub(action.CreatedAt) > domain.EphemeralReplyWindow {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
targetDevice = action.OriginDevice
replyTarget = &action
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = action.TopMessageID
if request.ReplyToEphemeralID == 0 {
request.ReplyToEphemeralID = action.ID
}
} else {
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
}
if request.ReplyToEphemeralID != 0 {
var reply domain.EphemeralMessage
found := false
if replyTarget != nil && replyTarget.ID == request.ReplyToEphemeralID {
reply, found = *replyTarget, true
} else {
var err error
reply, found, err = s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
}
if !found || reply.Deleted || !sameEphemeralParticipants(reply, request.BotUserID, request.ReceiverUserID) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
}
if targetDevice.BusinessAuthKeyID != ([8]byte{}) && reply.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
targetDevice.BusinessAuthKeyID != reply.OriginDevice.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
if request.TopMessageID != 0 && request.TopMessageID != reply.TopMessageID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
}
request.TopMessageID = reply.TopMessageID
replyTarget = &reply
}
if err := s.validateForumTopic(ctx, request.BotUserID, view, request.TopMessageID); err != nil {
return domain.EphemeralMessage{}, false, err
}
content, err := build(ctx)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !validContent(content) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
}
request.Content = content
if request.RandomID == 0 {
request.RandomID, err = randomEphemeralRandomID()
if err != nil {
return domain.EphemeralMessage{}, false, err
}
}
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
Peer: request.Peer,
SenderUserID: request.BotUserID,
ReceiverUserID: request.ReceiverUserID,
RandomID: request.RandomID,
TopMessageID: request.TopMessageID,
ReplyToEphemeralID: request.ReplyToEphemeralID,
Content: request.Content,
OriginDevice: targetDevice,
PayloadHash: botPayloadHash(request),
})
if err == nil && replyTarget != nil {
message.BotAPIReply = replyTarget
}
return message, fresh, err
}
func (s *Service) EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.SenderUserID != botUserID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
}
func (s *Service) EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) {
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, func(context.Context) (domain.EditEphemeralFields, error) {
return fields, nil
})
}
// EditFieldsFromBotLazy performs the identity/ownership lookup before building
// replacement media. This keeps invalid edit requests off the remote-fetch and
// blob-materialization paths while preserving a single CAS write on success.
func (s *Service) EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
if build == nil {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, build)
}
func (s *Service) editFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.SenderUserID != botUserID || message.ReceiverUserID != receiverUserID {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
fields, err := build(ctx)
if err != nil {
return domain.EphemeralMessage{}, err
}
switch mode {
case domain.EphemeralEditText:
if message.Content.Media != nil || !message.Content.RichMessage.IsZero() || !fields.SetMessage {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditCaption:
if message.Content.Media == nil || !fields.SetMessage {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditMedia:
if message.Content.Media == nil || !fields.SetMedia {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
case domain.EphemeralEditReplyMarkup:
if !fields.SetReplyMarkup || fields.SetMessage || fields.SetMedia {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
default:
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
content := message.Content
if fields.SetMessage {
content.Message = fields.Message
content.Entities = append([]domain.MessageEntity(nil), fields.Entities...)
}
if fields.SetMedia {
content.Media = fields.Media
}
if fields.SetReplyMarkup {
content.ReplyMarkup = fields.ReplyMarkup
}
if !validContent(content) {
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
}
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
}
func (s *Service) Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
return s.delete(ctx, actorUserID, receiverUserID, nil, peer, id)
}
func (s *Service) DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
if device.UserID != actorUserID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
return s.delete(ctx, actorUserID, receiverUserID, &device, peer, id)
}
func (s *Service) delete(ctx context.Context, actorUserID, receiverUserID int64, device *domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralMessage{}, false, err
}
if !found {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
}
if message.ReceiverUserID != receiverUserID || (actorUserID != message.SenderUserID && actorUserID != message.ReceiverUserID) {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
}
if device != nil && message.OriginDevice.UserID == actorUserID && message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
}
return s.messages.DeleteEphemeralMessage(ctx, peer, id, message.Version, now)
}
func (s *Service) Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) {
if len(data) > domain.MaxEphemeralCallbackDataBytes || userID <= 0 || device.UserID != userID ||
device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
now := s.now()
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
if err != nil {
return domain.EphemeralCallback{}, err
}
if !found || message.Deleted || message.ReceiverUserID != userID {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
if !ephemeralMarkupContainsCallback(message.Content.ReplyMarkup, data) {
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
}
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralCallback{}, domain.ErrEphemeralDeviceMismatch
}
return domain.EphemeralCallback{
Message: message,
BotUserID: message.SenderUserID,
UserID: userID,
Peer: peer,
Data: append([]byte(nil), data...),
Device: device,
OccurredAt: now,
}, nil
}
func (s *Service) PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) {
if s == nil || s.messages == nil {
return false, domain.ErrEphemeralInvalid
}
return s.messages.PutEphemeralCallbackAction(ctx, action)
}
func (s *Service) ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
if userID <= 0 || device.UserID != userID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
}
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, s.now())
if err != nil {
return domain.EphemeralMessage{}, err
}
if !found || message.Deleted || message.ReceiverUserID != userID {
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
}
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
return domain.EphemeralMessage{}, domain.ErrEphemeralDeviceMismatch
}
return message, nil
}
func ephemeralMarkupContainsCallback(markup *domain.MessageReplyMarkup, data []byte) bool {
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
return false
}
for _, row := range markup.Inline {
for _, button := range row {
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
return true
}
}
}
return false
}
func sameEphemeralParticipants(message domain.EphemeralMessage, first, second int64) bool {
return (message.SenderUserID == first && message.ReceiverUserID == second) ||
(message.SenderUserID == second && message.ReceiverUserID == first)
}
func (s *Service) create(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
now := s.now()
message.Date = int(now.Unix())
message.CreatedAt = now
message.ExpiresAt = now.Add(domain.EphemeralMessageRetention)
message.Version = 1
for attempt := 0; attempt < domain.MaxEphemeralCreateAttempts; attempt++ {
id, err := s.nextID()
if err != nil {
return domain.EphemeralMessage{}, false, err
}
message.ID = id
created, fresh, err := s.messages.CreateEphemeralMessage(ctx, message)
if !errors.Is(err, domain.ErrEphemeralIDCollision) {
return created, fresh, err
}
}
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
}
func (s *Service) requireActiveGroupPair(ctx context.Context, viewerUserID, otherUserID, channelID int64) (domain.ChannelView, error) {
view, err := s.channels.ResolveChannel(ctx, viewerUserID, channelID)
if err != nil {
return domain.ChannelView{}, err
}
if view.Channel.Deleted || view.Channel.Broadcast || view.Channel.Monoforum || view.Self.Status != domain.ChannelMemberActive {
return domain.ChannelView{}, domain.ErrEphemeralPeerInvalid
}
other, err := s.channels.GetParticipant(ctx, viewerUserID, channelID, otherUserID)
if err != nil {
return domain.ChannelView{}, err
}
if other.Status != domain.ChannelMemberActive {
return domain.ChannelView{}, domain.ErrEphemeralReceiverInvalid
}
return view, nil
}
func (s *Service) validateForumTopic(ctx context.Context, userID int64, view domain.ChannelView, topMessageID int) error {
if topMessageID == 0 {
return nil
}
if !view.Channel.Forum || topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
return domain.ErrEphemeralPeerInvalid
}
topics, err := s.channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, []int{topMessageID})
if err != nil {
return err
}
if len(topics.Topics) != 1 || topics.Topics[0].TopicID != topMessageID || topics.Topics[0].Hidden {
return domain.ErrEphemeralPeerInvalid
}
if topics.Topics[0].Closed && view.Self.Role != domain.ChannelRoleAdmin && view.Self.Role != domain.ChannelRoleCreator {
return domain.ErrEphemeralForbidden
}
return nil
}
func (s *Service) isEphemeralCommand(ctx context.Context, bot domain.User, message string) (bool, error) {
command, username, ok := parseCommand(message)
if !ok || (username != "" && !strings.EqualFold(username, bot.Username)) {
return false, nil
}
commands, err := s.bots.GetBotCommands(ctx, bot.ID)
if err != nil {
return false, err
}
for _, candidate := range commands {
if candidate.Ephemeral && strings.EqualFold(candidate.Command, command) {
return true, nil
}
}
return false, nil
}
func parseCommand(message string) (command, username string, ok bool) {
fields := strings.Fields(strings.TrimSpace(message))
if len(fields) == 0 || len(fields[0]) < 2 || fields[0][0] != '/' {
return "", "", false
}
parts := strings.SplitN(fields[0][1:], "@", 2)
command = strings.ToLower(parts[0])
if command == "" {
return "", "", false
}
if len(parts) == 2 {
username = strings.TrimPrefix(strings.ToLower(parts[1]), "@")
if username == "" {
return "", "", false
}
}
return command, username, true
}
func validContent(content domain.EphemeralContent) bool {
return domain.ValidateEphemeralContent(content) == nil
}
func clientPayloadHash(request domain.SendClientEphemeralRequest) [32]byte {
return payloadHash(struct {
SenderUserID, ReceiverBotID int64
Peer domain.Peer
QueryID, RandomID int64
TopMessageID, ReplyID int
Content domain.EphemeralContent
Device domain.EphemeralDevice
}{request.SenderUserID, request.ReceiverBotID, request.Peer, request.QueryID, request.RandomID,
request.TopMessageID, request.ReplyToEphemeralID, request.Content, request.OriginDevice})
}
func botPayloadHash(request domain.SendBotEphemeralRequest) [32]byte {
return payloadHash(request)
}
func payloadHash(value any) [32]byte {
raw, err := json.Marshal(value)
if err != nil {
return sha256.Sum256([]byte("invalid-ephemeral-payload"))
}
return sha256.Sum256(raw)
}
func randomEphemeralID() (int, error) {
var raw [4]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
}
value := binary.LittleEndian.Uint32(raw[:]) & 0x7fffffff
if value == 0 {
value = 1
}
return int(value), nil
}
func randomEphemeralRandomID() (int64, error) {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
return 0, err
}
value := int64(binary.LittleEndian.Uint64(raw[:]))
if value == 0 {
value = 1
}
return value, nil
}

View file

@ -0,0 +1,385 @@
package ephemeral
import (
"context"
"crypto/sha256"
"errors"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
const (
testHumanID int64 = 1001
testBotID int64 = 2001
testChannel int64 = 3001
testSession int64 = 4001
)
var testDeviceKey = [8]byte{1, 2, 3, 4}
type testChannels struct {
roles map[int64]domain.ChannelMemberRole
status map[int64]domain.ChannelMemberStatus
channel domain.Channel
}
func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
if channelID != c.channel.ID {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{
ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID],
}}, nil
}
func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) {
if channelID != c.channel.ID {
return domain.ChannelMember{}, domain.ErrChannelInvalid
}
return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil
}
func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
if channelID != c.channel.ID {
return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
}
out := domain.ChannelForumTopicList{Channel: c.channel}
for _, id := range ids {
if id > 0 {
out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id})
}
}
return out, nil
}
type testUsers map[int64]domain.User
func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) {
user, found := u[userID]
return user, found, nil
}
type testBots map[int64][]domain.BotCommand
func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) {
return append([]domain.BotCommand(nil), b[botUserID]...), nil
}
type serviceFixture struct {
service *Service
store *memory.EphemeralMessageStore
now time.Time
nextID int
channels *testChannels
}
func newServiceFixture() *serviceFixture {
f := &serviceFixture{
store: memory.NewEphemeralMessageStore(),
now: time.Unix(1_900_000_000, 0),
nextID: 10,
channels: &testChannels{
roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember},
status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive},
channel: domain.Channel{ID: testChannel, Megagroup: true},
},
}
f.service = NewService(f.store, f.channels, testUsers{
testHumanID: {ID: testHumanID, Username: "alice"},
testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1},
}, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}},
WithClock(func() time.Time { return f.now }),
WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil }))
return f
}
func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest {
return domain.SendClientEphemeralRequest{
SenderUserID: testHumanID, ReceiverBotID: testBotID,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"},
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession},
}
}
func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) {
f := newServiceFixture()
message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest())
if err != nil || !fresh {
t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err)
}
if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey {
t.Fatalf("message = %+v", message)
}
request := f.clientRequest()
request.RandomID++
request.Content.Message = "/public"
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) {
t.Fatalf("ordinary command err=%v", err)
}
}
func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) {
f := newServiceFixture()
request := f.clientRequest()
message, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || !fresh {
t.Fatalf("create fresh=%v err=%v", fresh, err)
}
device := request.OriginDevice
if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed {
t.Fatalf("delete changed=%v err=%v", changed, err)
}
replayed, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 {
t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err)
}
}
func TestClientReplyMustMatchTargetDevice(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
request := f.clientRequest()
request.Content.Message = "reply"
request.ReplyToEphemeralID = incoming.ID
reply, fresh, err := f.service.SendFromClient(context.Background(), request)
if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID {
t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err)
}
request.RandomID++
request.OriginDevice.BusinessAuthKeyID = [8]byte{9}
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device reply err=%v", err)
}
}
func TestBotReplyWindowAndAdminBroadcast(t *testing.T) {
f := newServiceFixture()
action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest())
if err != nil {
t.Fatal(err)
}
f.now = f.now.Add(14 * time.Second)
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID,
Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID,
})
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID ||
reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID {
t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err)
}
f.now = f.now.Add(2 * time.Second)
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID,
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
t.Fatalf("late bot reply err=%v", err)
}
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
RandomID: 94, Content: domain.EphemeralContent{Message: "admin"},
})
if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) {
t.Fatalf("admin broadcast = %+v err=%v", broadcast, err)
}
}
func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" {
t.Fatalf("callback = %+v err=%v", callback, err)
}
device.BusinessAuthKeyID = [8]byte{7}
if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device callback err=%v", err)
}
if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
t.Fatalf("other device delete err=%v", err)
}
device.BusinessAuthKeyID = testDeviceKey
deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID)
if err != nil || !changed || !deleted.Deleted {
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
}
}
func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) {
f := newServiceFixture()
incoming := f.putIncoming(t, testDeviceKey, f.now)
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
if err != nil {
t.Fatal(err)
}
const queryID = int64(777)
created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{
QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer,
MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now,
ExpiresAt: f.now.Add(domain.EphemeralReplyWindow),
})
if err != nil || !created {
t.Fatalf("put callback action created=%v err=%v", created, err)
}
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"},
})
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey {
t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err)
}
f.now = f.now.Add(domain.EphemeralReplyWindow)
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"},
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
t.Fatalf("expired callback action err=%v", err)
}
}
func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) {
f := newServiceFixture()
f.channels.channel.Forum = true
incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42)
request := f.clientRequest()
request.Content.Message = "topic reply"
request.ReplyToEphemeralID = incoming.ID
reply, _, err := f.service.SendFromClient(context.Background(), request)
if err != nil || reply.TopMessageID != 42 {
t.Fatalf("topic reply=%+v err=%v", reply, err)
}
f = newServiceFixture()
request = f.clientRequest()
request.TopMessageID = 42
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) {
t.Fatalf("non-forum topic err=%v", err)
}
}
func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) {
f := newServiceFixture()
request := f.clientRequest()
request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private "))
if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil {
t.Fatalf("4096 Unicode characters rejected: %v", err)
}
request.RandomID++
request.Content.Message += "界"
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("overlong Unicode text err=%v", err)
}
}
func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) {
f := newServiceFixture()
textMessage := f.putIncoming(t, testDeviceKey, f.now)
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil {
t.Fatalf("text edit: %v", err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("caption edit on text err=%v", err)
}
mediaMessage := f.putIncoming(t, testDeviceKey, f.now)
mediaContent := domain.EphemeralContent{
Message: "caption",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}},
}
mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now)
if err != nil {
t.Fatal(err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil {
t.Fatalf("media caption edit: %v", err)
}
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
t.Fatalf("text edit on media err=%v", err)
}
}
func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) {
f := newServiceFixture()
builds := 0
buildText := func(context.Context) (domain.EphemeralContent, error) {
builds++
return domain.EphemeralContent{Message: "authorized"}, nil
}
request := domain.SendBotEphemeralRequest{
BotUserID: testBotID, ReceiverUserID: testHumanID + 99,
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
}
if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil {
t.Fatal("unknown receiver was accepted")
}
if builds != 0 {
t.Fatalf("unauthorized send materialized content %d times", builds)
}
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
request.ReceiverUserID = testHumanID
if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh {
t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err)
}
if builds != 1 {
t.Fatalf("authorized send materialized content %d times", builds)
}
incoming := f.putIncoming(t, testDeviceKey, f.now)
editBuilds := 0
buildEdit := func(context.Context) (domain.EditEphemeralFields, error) {
editBuilds++
return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil
}
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID,
domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) {
t.Fatalf("unauthorized lazy edit err=%v", err)
}
if editBuilds != 0 {
t.Fatalf("unauthorized edit materialized content %d times", editBuilds)
}
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID,
domain.EphemeralEditText, buildEdit); err != nil {
t.Fatalf("authorized lazy edit: %v", err)
}
if editBuilds != 1 {
t.Fatalf("authorized edit materialized content %d times", editBuilds)
}
}
func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage {
return f.putIncomingInTopic(t, deviceKey, createdAt, 0)
}
func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage {
t.Helper()
f.nextID++
message := domain.EphemeralMessage{
ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID),
TopMessageID: topMessageID,
Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{
Type: domain.MessageReplyMarkupInline,
Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}},
}},
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession},
PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1,
CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention),
}
stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message)
if err != nil {
t.Fatal(err)
}
return stored
}

View file

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

View file

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

View file

@ -85,24 +85,42 @@ func apiUpdates(events []domain.UpdateEvent, limit int) []map[string]any {
} }
func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) { 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 return nil, "", false
} }
switch event.Type { switch event.Type {
case domain.UpdateEventNewMessage: 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) { if !apiMessageProjectable(event.Message) {
return nil, "", false return nil, "", false
} }
return map[string]any{ return map[string]any{
"update_id": event.Pts, "update_id": updateID,
"message": apiMessage(event.Message, event.Users, event.Channels), "message": apiMessage(event.Message, event.Users, event.Channels),
}, "message", true }, "message", true
case domain.UpdateEventEditMessage: 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) { if !apiMessageProjectable(event.Message) {
return nil, "", false return nil, "", false
} }
return map[string]any{ return map[string]any{
"update_id": event.Pts, "update_id": updateID,
"edited_message": apiMessage(event.Message, event.Users, event.Channels), "edited_message": apiMessage(event.Message, event.Users, event.Channels),
}, "edited_message", true }, "edited_message", true
case domain.UpdateEventBotCallbackQuery: case domain.UpdateEventBotCallbackQuery:
@ -132,6 +150,15 @@ func apiUpdate(event domain.UpdateEvent) (map[string]any, string, bool) {
return nil, "", false return nil, "", false
} }
query["inline_message_id"] = inlineMessageID 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 { } else {
if callback.MessageID <= 0 || event.Message.ID != callback.MessageID { if callback.MessageID <= 0 || event.Message.ID != callback.MessageID {
return nil, "", false 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) query["message"] = apiMessage(event.Message, event.Users, event.Channels)
} }
return map[string]any{ return map[string]any{
"update_id": event.Pts, "update_id": updateID,
"callback_query": query, "callback_query": query,
}, "callback_query", true }, "callback_query", true
default: 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 const botAPIInlineMessageIDVersion byte = 1
// encodeBotAPIInlineMessageID exposes the signed MTProto inline-message identity as an // 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) media := apiMessageMedia(msg.Media, userByID, channelByID)
if msg.Body != "" { if msg.Body != "" {
if _, photo := media["photo"]; photo { if apiMediaUsesCaption(media) {
out["caption"] = msg.Body
} else if _, document := media["document"]; document {
out["caption"] = msg.Body out["caption"] = msg.Body
} else if poll, ok := media["poll"].(map[string]any); ok { } else if poll, ok := media["poll"].(map[string]any); ok {
poll["description"] = msg.Body 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 entities := apiMessageEntities(msg.Entities, userByID); len(entities) > 0 {
if _, photo := media["photo"]; photo { if apiMediaUsesCaption(media) {
out["caption_entities"] = entities
} else if _, document := media["document"]; document {
out["caption_entities"] = entities out["caption_entities"] = entities
} else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" { } else if poll, ok := media["poll"].(map[string]any); ok && msg.Body != "" {
poll["description_entities"] = entities poll["description_entities"] = entities
@ -278,6 +341,15 @@ func apiMessage(msg domain.Message, users []domain.User, channelLists ...[]domai
return out 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 { func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
switch peer.Type { switch peer.Type {
case domain.PeerTypeUser: case domain.PeerTypeUser:
@ -476,12 +548,23 @@ func apiMessageMedia(media *domain.MessageMedia, users map[int64]domain.User, ch
if len(photos) == 0 { if len(photos) == 0 {
return nil 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} return map[string]any{"photo": photos}
case domain.MessageMediaKindDocument: case domain.MessageMediaKindDocument:
if media.Document == nil { if media.Document == nil {
return nil return nil
} }
return map[string]any{"document": apiDocument(*media.Document)} return apiDocumentMedia(*media.Document)
case domain.MessageMediaKindContact: case domain.MessageMediaKindContact:
if media.Contact == nil { if media.Contact == nil {
return 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 { func apiLocation(geo domain.MessageGeoPoint, live *domain.MessageGeoLive) map[string]any {
out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long} out := map[string]any{"latitude": geo.Lat, "longitude": geo.Long}
if geo.AccuracyRadius > 0 { if geo.AccuracyRadius > 0 {

View file

@ -15,6 +15,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"go.uber.org/zap" "go.uber.org/zap"
@ -23,6 +24,8 @@ import (
type BotsService interface { type BotsService interface {
BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) 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) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error)
GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error)
BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, 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) 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 { type GatewayUpdateWaiter interface {
BotAPIUpdateWaitVersion(botID int64) uint64 BotAPIUpdateWaitVersion(botID int64) uint64
WaitBotAPIUpdate(ctx context.Context, botID int64, version uint64, timeout time.Duration) bool 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) { switch strings.ToLower(method) {
case "getme": case "getme":
h.getMe(w, r, botID) 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": case "getupdates":
h.getUpdates(w, r, botID) h.getUpdates(w, r, botID)
case "sendmessage": case "sendmessage":
h.sendMessage(w, r, botID) h.sendMessage(w, r, botID)
case "sendphoto": case "sendphoto":
h.sendMedia(w, r, botID, "photo") h.sendMedia(w, r, botID, "photo")
case "sendanimation":
h.sendMedia(w, r, botID, "animation")
case "sendaudio":
h.sendMedia(w, r, botID, "audio")
case "senddocument": case "senddocument":
h.sendMedia(w, r, botID, "document") 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": case "editmessagetext":
h.editMessageText(w, r, botID) h.editMessageText(w, r, botID)
case "deletemessage": case "deletemessage":
h.deleteMessage(w, r, botID) 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": case "answercallbackquery":
h.answerCallbackQuery(w, r, botID) h.answerCallbackQuery(w, r, botID)
case "getfile": case "getfile":
@ -481,6 +526,10 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
return return
} }
text := values["text"] 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"]) != "" { if strings.TrimSpace(values["parse_mode"]) != "" {
writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED") writeAPIError(w, http.StatusBadRequest, "ENTITY_PARSE_UNSUPPORTED")
return return
@ -498,6 +547,33 @@ func (h *handler) sendMessage(w http.ResponseWriter, r *http.Request, botID int6
return 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) 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) 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 { 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()) writeAPIError(w, http.StatusBadRequest, err.Error())
return return
} }
if !utf8.ValidString(values["caption"]) || utf8.RuneCountInString(values["caption"]) > domain.MaxEphemeralCaptionLength {
writeAPIError(w, http.StatusBadRequest, "MESSAGE_TOO_LONG")
return
}
var markup *domain.MessageReplyMarkup var markup *domain.MessageReplyMarkup
if raw := strings.TrimSpace(values["reply_markup"]); raw != "" { if raw := strings.TrimSpace(values["reply_markup"]); raw != "" {
markup, err = replyMarkupFromAPI(json.RawMessage(raw)) markup, err = replyMarkupFromAPI(json.RawMessage(raw))
@ -543,11 +623,56 @@ func (h *handler) sendMedia(w http.ResponseWriter, r *http.Request, botID int64,
return 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 { if !ok {
writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID") writeAPIError(w, http.StatusBadRequest, "FILE_ID_INVALID")
return 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)) 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 { if err != nil {
writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err))
@ -1252,6 +1377,9 @@ func apiErrorDescription(err error) string {
"QUERY_ID_INVALID", "QUERY_ID_INVALID",
"MESSAGE_ID_INVALID", "MESSAGE_ID_INVALID",
"MESSAGE_NOT_MODIFIED", "MESSAGE_NOT_MODIFIED",
"BOT_COMMAND_INVALID",
"EPHEMERAL_MESSAGE_ID_INVALID",
"EPHEMERAL_ACTION_EXPIRED",
"CHAT_WRITE_FORBIDDEN", "CHAT_WRITE_FORBIDDEN",
"CHAT_ADMIN_REQUIRED", "CHAT_ADMIN_REQUIRED",
"REPLY_MESSAGE_ID_INVALID", "REPLY_MESSAGE_ID_INVALID",

View file

@ -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) { func TestGetUpdatesProjectsIncomingPrivateText(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{ 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) { func TestSendMessageParsesEntitiesMarkupAndCallsGateway(t *testing.T) {
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
gateway := &fakeBotAPIGateway{ gateway := &fakeBotAPIGateway{
@ -980,13 +1251,23 @@ type apiResponse struct {
} }
type fakeBotAPIBots struct { type fakeBotAPIBots struct {
profile domain.BotProfile profile domain.BotProfile
commands []domain.BotCommand
} }
func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) { func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) {
return f.profile, true, nil 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) { func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) {
return 0, nil return 0, nil
} }
@ -1038,41 +1319,46 @@ type fakeBotAPIGateway struct {
updateBotID int64 updateBotID int64
updateOffset int64 updateOffset int64
sendCalled bool sendCalled bool
sendBotID int64 sendBotID int64
sendChatID int64 sendChatID int64
sendText string sendText string
sendEntities []domain.MessageEntity sendEntities []domain.MessageEntity
sendMarkup *domain.MessageReplyMarkup sendMarkup *domain.MessageReplyMarkup
sendNoWebpage bool sendNoWebpage bool
sendSilent bool sendSilent bool
sendReplyTo int sendReplyTo int
sendMessage domain.Message sendMessage domain.Message
sendMediaCalled bool sendMediaCalled bool
sendMediaKind string sendMediaKind string
sendMediaChatID int64 sendMediaChatID int64
sendMediaFileName string sendMediaFileName string
sendMediaBytes []byte sendMediaBytes []byte
sendMediaCaption string sendMediaCaption string
sendMediaMessage domain.Message sendMediaMessage domain.Message
editCalled bool editCalled bool
editSetMarkup bool editSetMarkup bool
editMessage domain.Message editMessage domain.Message
editInlineCalled bool editInlineCalled bool
editInlineID domain.BotInlineMessageID editInlineID domain.BotInlineMessageID
deleteCalled bool deleteCalled bool
callbackCalled bool callbackCalled bool
callbackID string callbackID string
fileLocationKey string fileLocationKey string
fileChunks map[string]domain.FileChunk fileChunks map[string]domain.FileChunk
allowedUpdates []domain.BotAPIUpdateKind allowedUpdates []domain.BotAPIUpdateKind
dropPending bool dropPending bool
pendingCount int pendingCount int
webhook domain.BotAPIWebhook webhook domain.BotAPIWebhook
webhookFound bool webhookFound bool
webhookDeleted bool webhookDeleted bool
webhookDrop bool webhookDrop bool
webhookConfirmed int64 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) { 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]...) out.Bytes = append([]byte(nil), chunk.Bytes[offset:end]...)
return out, true, nil 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
}

View file

@ -92,6 +92,7 @@ const (
type BotCommand struct { type BotCommand struct {
Command string `json:"command"` Command string `json:"command"`
Description string `json:"description"` Description string `json:"description"`
Ephemeral bool `json:"ephemeral,omitempty"`
} }
// BotMenuButtonType 标识菜单按钮类型。 // BotMenuButtonType 标识菜单按钮类型。

View file

@ -1,5 +1,7 @@
package domain package domain
import "time"
// BotAPIUpdateKind is the Bot API delivery shape for a queued update. // BotAPIUpdateKind is the Bot API delivery shape for a queued update.
type BotAPIUpdateKind string type BotAPIUpdateKind string
@ -22,6 +24,109 @@ type BotCallbackQuery struct {
InlineMessage *BotInlineMessageID 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. // BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
// It can be projected both to MTProto and to Bot API's opaque // It can be projected both to MTProto and to Bot API's opaque
// inline_message_id without leaking tg types into the store boundary. // inline_message_id without leaking tg types into the store boundary.
@ -44,6 +149,7 @@ type BotAPIUpdate struct {
SourcePts int SourcePts int
Date int Date int
Callback *BotCallbackQuery Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
} }
// EnqueueBotAPIUpdateRequest describes a message-like update that should be // EnqueueBotAPIUpdateRequest describes a message-like update that should be
@ -56,4 +162,5 @@ type EnqueueBotAPIUpdateRequest struct {
SourcePts int SourcePts int
Date int Date int
Callback *BotCallbackQuery Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
} }

View file

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

View file

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

View file

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

View file

@ -716,25 +716,26 @@ type MessageStarGiftOfferDeclinedAction struct {
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
type MessageMedia struct { type MessageMedia struct {
Kind MessageMediaKind `json:"kind"` Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"` Photo *Photo `json:"photo,omitempty"`
Document *Document `json:"document,omitempty"` LivePhotoVideo *Document `json:"live_photo_video,omitempty"`
Contact *MessageContact `json:"contact,omitempty"` Document *Document `json:"document,omitempty"`
ServiceAction *MessageServiceAction `json:"service_action,omitempty"` Contact *MessageContact `json:"contact,omitempty"`
Geo *MessageGeoPoint `json:"geo,omitempty"` ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
Venue *MessageVenue `json:"venue,omitempty"` Geo *MessageGeoPoint `json:"geo,omitempty"`
Dice *MessageDice `json:"dice,omitempty"` Venue *MessageVenue `json:"venue,omitempty"`
Poll *MessagePoll `json:"poll,omitempty"` Dice *MessageDice `json:"dice,omitempty"`
GeoLive *MessageGeoLive `json:"geo_live,omitempty"` Poll *MessagePoll `json:"poll,omitempty"`
Todo *MessageTodo `json:"todo,omitempty"` GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
Story *MessageStory `json:"story,omitempty"` Todo *MessageTodo `json:"todo,omitempty"`
WebPage *MessageWebPage `json:"web_page,omitempty"` Story *MessageStory `json:"story,omitempty"`
Spoiler bool `json:"spoiler,omitempty"` WebPage *MessageWebPage `json:"web_page,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"` Spoiler bool `json:"spoiler,omitempty"`
Nopremium bool `json:"nopremium,omitempty"` TTLSeconds int `json:"ttl_seconds,omitempty"`
Voice bool `json:"voice,omitempty"` Nopremium bool `json:"nopremium,omitempty"`
Round bool `json:"round,omitempty"` Voice bool `json:"voice,omitempty"`
Video bool `json:"video,omitempty"` Round bool `json:"round,omitempty"`
Video bool `json:"video,omitempty"`
// InvertMedia 映射 message.invert_media媒体典型为链接预览渲染在文本上方。 // InvertMedia 映射 message.invert_media媒体典型为链接预览渲染在文本上方。
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。 // 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
InvertMedia bool `json:"invert_media,omitempty"` InvertMedia bool `json:"invert_media,omitempty"`

View file

@ -114,6 +114,10 @@ type UpdateEvent struct {
QuickReply QuickReply QuickReply QuickReply
QuickReplyMessage QuickReplyMessage QuickReplyMessage QuickReplyMessage
BotCallbackQuery *BotCallbackQuery 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 构造器没有 // LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有

View file

@ -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) { 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 // 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. // 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 的 transienttypingbest-effort 版本。 // PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transienttypingbest-effort 版本。
func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) { 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 { if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err() return 0, ctx.Err()
} }
@ -1397,7 +1401,7 @@ func (m *SessionManager) pushToBusinessAuthKeyBestEffort(ctx context.Context, us
defer cancel() defer cancel()
} }
getUpdates := onceLayerUpdatesFanout(sendCtx, msg) 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 { if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed 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() m.mu.Lock()
candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID) candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID)
conns := make([]*Conn, 0, len(candidates)) conns := make([]*Conn, 0, len(candidates))
@ -1432,6 +1436,9 @@ func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64
// 未就绪:密聊消息靠 getDifference 补typing 直接丢——都不进 pending。 // 未就绪:密聊消息靠 getDifference 补typing 直接丢——都不进 pending。
continue continue
} }
if !sessionSupportsMinimumLayer(c, minLayer) {
continue
}
conns = append(conns, c) conns = append(conns, c)
} }
m.mu.Unlock() 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) { 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) 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 { if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed return ErrConnClosed
} }
@ -1499,7 +1506,25 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu
// 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。 // 「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) { 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) 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 { if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed 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) { 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 { if ctx != nil && ctx.Err() != nil {
return 0, ctx.Err() return 0, ctx.Err()
} }
@ -1545,7 +1574,7 @@ func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64,
defer cancel() defer cancel()
} }
getUpdates := onceLayerUpdatesFanout(sendCtx, msg) 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 { if c.outbound == nil || c.outboundControl == nil {
return ErrConnClosed 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 格式化) // push fan-out 是连接层最热路径之一debug 日志的字段构造(含 auth_key hex 格式化)
// 在关闭 debug 时也会求值,先查级别一次、按需记日志。 // 在关闭 debug 时也会求值,先查级别一次、按需记日志。
debug := m.log.Core().Enabled(zapcore.DebugLevel) debug := m.log.Core().Enabled(zapcore.DebugLevel)
@ -1612,6 +1641,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
excluded++ excluded++
continue continue
} }
if !sessionSupportsMinimumLayer(c, minLayer) {
skipped++
continue
}
if !c.receivesUpdates.Load() { if !c.receivesUpdates.Load() {
if !queueWhenNotReady { if !queueWhenNotReady {
// transienttyping/presence未就绪即丢不进 pending。这些 update 不写 // transienttyping/presence未就绪即丢不进 pending。这些 update 不写
@ -1640,6 +1673,10 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64,
excluded++ excluded++
continue continue
} }
if !sessionSupportsMinimumLayer(c, minLayer) {
skipped++
continue
}
if !c.receivesUpdates.Load() { if !c.receivesUpdates.Load() {
if !queueWhenNotReady { if !queueWhenNotReady {
skipped++ skipped++
@ -2478,6 +2515,17 @@ func shouldExcludeSession(c *Conn, excludeAuthKeyID *[8]byte, excludeSessionID i
return c.authKeyID == *excludeAuthKeyID 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 { func sessionKeyLog(id [8]byte) string {
return fmt.Sprintf("%x", id) return fmt.Sprintf("%x", id)
} }

View file

@ -3,11 +3,13 @@ package mtprotoedge
import ( import (
"context" "context"
"testing" "testing"
"time"
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/iamxvbaba/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile"
) )
// TestPushTransientSkipsNotReadySession 锁定不变量transient 推送typing/presence // TestPushTransientSkipsNotReadySession 锁定不变量transient 推送typing/presence
@ -51,3 +53,57 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
t.Fatalf("durable push queued %d pending, want 1", n) 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)
}
}
}

View file

@ -287,6 +287,275 @@ func (r *Router) BotAPISendMedia(ctx context.Context, botID, chatID int64, kind,
return res.SenderMessage, nil 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) { func botAPIPeerFromChatID(chatID int64) (domain.Peer, bool) {
switch { switch {
case chatID > 0: case chatID > 0:

View file

@ -79,6 +79,9 @@ func (r *Router) botAPIQueuedUpdateEvents(ctx context.Context, botID int64, item
if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok { if _, ok := botAPIQueuedUpdateKind(botID, item, now); !ok {
continue continue
} }
if item.Ephemeral != nil {
continue
}
if item.Callback != nil && item.Callback.InlineMessage != nil { if item.Callback != nil && item.Callback.InlineMessage != nil {
continue continue
} }
@ -171,6 +174,9 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time
if !ok { if !ok {
return "", false return "", false
} }
if item.Ephemeral != nil && !botAPIQueuedEphemeralValid(botID, item, now) {
return "", false
}
if item.Kind == domain.BotAPIUpdateCallbackQuery { if item.Kind == domain.BotAPIUpdateCallbackQuery {
if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) { if item.Date <= 0 || !now.Before(time.Unix(int64(item.Date), 0).Add(botCallbackTimeout)) {
return "", false return "", false
@ -205,11 +211,40 @@ func botAPIQueuedUpdateKind(botID int64, item domain.BotAPIUpdate, now time.Time
return eventType, true 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) { 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) eventType, ok := botAPIQueuedUpdateKind(botID, item, now)
if !ok { if !ok {
return domain.UpdateEvent{}, false 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 { if eventType == domain.UpdateEventBotCallbackQuery && item.Callback.InlineMessage != nil {
callback := *item.Callback callback := *item.Callback
callback.Data = append([]byte(nil), item.Callback.Data...) callback.Data = append([]byte(nil), item.Callback.Data...)
@ -220,6 +255,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType, Type: eventType,
Pts: int(item.ID), Pts: int(item.ID),
PtsCount: 1, PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date, Date: item.Date,
BotCallbackQuery: &callback, BotCallbackQuery: &callback,
}, true }, true
@ -238,6 +274,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType, Type: eventType,
Pts: int(item.ID), Pts: int(item.ID),
PtsCount: 1, PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date, Date: item.Date,
Peer: item.Peer, Peer: item.Peer,
Message: msg, Message: msg,
@ -249,13 +286,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
} }
msg.Pts = int(item.ID) msg.Pts = int(item.ID)
return domain.UpdateEvent{ return domain.UpdateEvent{
UserID: botID, UserID: botID,
Type: eventType, Type: eventType,
Pts: int(item.ID), Pts: int(item.ID),
PtsCount: 1, PtsCount: 1,
Date: item.Date, BotAPIUpdateID: item.ID,
Peer: msg.Peer, Date: item.Date,
Message: msg, Peer: msg.Peer,
Message: msg,
}, true }, true
case domain.PeerTypeChannel: case domain.PeerTypeChannel:
msg, found := channelMessages[item.Peer.ID][item.MessageID] msg, found := channelMessages[item.Peer.ID][item.MessageID]
@ -271,6 +309,7 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
Type: eventType, Type: eventType,
Pts: int(item.ID), Pts: int(item.ID),
PtsCount: 1, PtsCount: 1,
BotAPIUpdateID: item.ID,
Date: item.Date, Date: item.Date,
Peer: item.Peer, Peer: item.Peer,
Message: projected, Message: projected,
@ -282,13 +321,14 @@ func botAPIQueuedUpdateEventFromMessages(botID int64, item domain.BotAPIUpdate,
} }
projected.Pts = int(item.ID) projected.Pts = int(item.ID)
return domain.UpdateEvent{ return domain.UpdateEvent{
UserID: botID, UserID: botID,
Type: eventType, Type: eventType,
Pts: int(item.ID), Pts: int(item.ID),
PtsCount: 1, PtsCount: 1,
Date: item.Date, BotAPIUpdateID: item.ID,
Peer: projected.Peer, Date: item.Date,
Message: projected, Peer: projected.Peer,
Message: projected,
}, true }, true
default: default:
return domain.UpdateEvent{}, false return domain.UpdateEvent{}, false

View file

@ -459,7 +459,7 @@ func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool {
func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
out := make([]domain.BotCommand, 0, len(in)) out := make([]domain.BotCommand, 0, len(in))
for _, c := range 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 return out
} }
@ -467,7 +467,7 @@ func domainBotCommands(in []tg.BotCommand) []domain.BotCommand {
func tgBotCommands(in []domain.BotCommand) []tg.BotCommand { func tgBotCommands(in []domain.BotCommand) []tg.BotCommand {
out := make([]tg.BotCommand, 0, len(in)) out := make([]tg.BotCommand, 0, len(in))
for _, c := range 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 return out
} }

View file

@ -111,6 +111,10 @@ func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.Mes
Date: int(r.clock.Now().Unix()), 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) waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout)
defer cancel() defer cancel()
ticker := time.NewTicker(250 * time.Millisecond) ticker := time.NewTicker(250 * time.Millisecond)

View file

@ -27,6 +27,10 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass {
if m.TTLSeconds > 0 { if m.TTLSeconds > 0 {
out.TTLSeconds = m.TTLSeconds out.TTLSeconds = m.TTLSeconds
} }
if m.LivePhotoVideo != nil {
out.LivePhoto = true
out.SetVideo(tgDocument(*m.LivePhotoVideo))
}
return out return out
case domain.MessageMediaKindDocument: case domain.MessageMediaKindDocument:
nopremium := m.Nopremium nopremium := m.Nopremium

View file

@ -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) 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. // OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout.
type OnlineUserProvider interface { type OnlineUserProvider interface {
IsUserOnline(userID int64) bool IsUserOnline(userID int64) bool
@ -805,6 +814,22 @@ type AIComposeService interface {
Compose(ctx context.Context, req domain.AIComposeRequest) (domain.AIComposeResult, error) 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 // Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct { type Deps struct {
Auth AuthService Auth AuthService
@ -817,6 +842,9 @@ type Deps struct {
Help HelpService Help HelpService
AccountFreeze AccountFreezeService AccountFreeze AccountFreezeService
AICompose AIComposeService AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Users UsersService Users UsersService
Updates UpdatesService Updates UpdatesService
BootstrapUpdates store.BootstrapUpdateJobStore BootstrapUpdates store.BootstrapUpdateJobStore

460
internal/rpc/ephemeral.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -274,6 +274,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerPremium(d) r.registerPremium(d)
r.registerAiCompose(d) r.registerAiCompose(d)
r.registerBots(d) r.registerBots(d)
r.registerEphemeral(d)
r.dispatcher = d r.dispatcher = d
return r return r

View file

@ -45,6 +45,12 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
addDomainPeerRef(peer, 0, userIDs, channelIDs) addDomainPeerRef(peer, 0, userIDs, channelIDs)
} }
collectMessagePeerRefs(out[i].Message, 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 { if out[i].BotCallbackQuery != nil && out[i].BotCallbackQuery.UserID != 0 {
userIDs[out[i].BotCallbackQuery.UserID] = struct{}{} userIDs[out[i].BotCallbackQuery.UserID] = struct{}{}
} }
@ -66,6 +72,24 @@ func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUser
return out 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 { type updateEventPeerRefs struct {
userIDs map[int64]struct{} userIDs map[int64]struct{}
channelIDs map[int64]struct{} channelIDs map[int64]struct{}

View file

@ -629,7 +629,11 @@ func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) t
if len(profile.Commands) > 0 { if len(profile.Commands) > 0 {
cmds := make([]tg.BotCommand, 0, len(profile.Commands)) cmds := make([]tg.BotCommand, 0, len(profile.Commands))
for _, c := range 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) info.SetCommands(cmds)
} }

View file

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

View file

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

View file

@ -253,6 +253,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(_ context.Context, req domain.En
SourcePts: req.SourcePts, SourcePts: req.SourcePts,
Date: req.Date, Date: req.Date,
Callback: cloneBotAPICallback(req.Callback), Callback: cloneBotAPICallback(req.Callback),
Ephemeral: cloneBotAPIEphemeral(req.Ephemeral),
} }
s.nextID++ s.nextID++
s.rows = append(s.rows, row) s.rows = append(s.rows, row)
@ -433,6 +434,17 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
default: default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) 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 { if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || 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 { if req.Kind == domain.BotAPIUpdateCallbackQuery && req.Callback != nil {
return fmt.Sprintf("%d:%s:%d", req.BotUserID, req.Kind, req.Callback.ID) return fmt.Sprintf("%d:%s:%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) 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 { func cloneBotAPIUpdate(row domain.BotAPIUpdate) domain.BotAPIUpdate {
row.Callback = cloneBotAPICallback(row.Callback) row.Callback = cloneBotAPICallback(row.Callback)
row.Ephemeral = cloneBotAPIEphemeral(row.Ephemeral)
return row 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 { func cloneBotAPICallback(in *domain.BotCallbackQuery) *domain.BotCallbackQuery {
if in == nil { if in == nil {
return nil return nil

View file

@ -189,3 +189,57 @@ func TestBotAPIInlineCallbackRoundTrip(t *testing.T) {
t.Fatalf("inline callback rows=%#v err=%v", rows, err) 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)
}
}

View file

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

View file

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

View file

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

View file

@ -108,6 +108,12 @@ func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
return nil return nil
} }
clone := *media 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 { if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
return &clone return &clone
} }

View file

@ -1,8 +1,11 @@
package postgres package postgres
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@ -291,6 +294,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.
var callbackInlineDCID, callbackInlineMessageID int var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64 var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte var callbackData []byte
var ephemeralPayload []byte
if req.Callback != nil { if req.Callback != nil {
callbackQueryID = req.Callback.ID callbackQueryID = req.Callback.ID
callbackUserID = req.Callback.UserID callbackUserID = req.Callback.UserID
@ -303,13 +307,21 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash 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, ` row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
WITH inserted AS ( WITH inserted AS (
INSERT INTO bot_api_updates ( INSERT INTO bot_api_updates (
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, 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_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,
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 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 ( WHERE NOT EXISTS (
SELECT 1 SELECT 1
FROM bot_api_update_states FROM bot_api_update_states
@ -320,7 +332,8 @@ WHERE NOT EXISTS (
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, 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_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 ( ), wake_webhook AS (
UPDATE bot_api_webhooks UPDATE bot_api_webhooks
SET next_attempt_at = now(), updated_at = now() 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, 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_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 FROM inserted
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date, `, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
callbackQueryID, callbackUserID, callbackChatInstance, callbackData, callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash)) callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload))
if err == nil { if err == nil {
return row, true, nil return row, true, nil
} }
@ -343,16 +357,20 @@ FROM inserted
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, ` row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data, callback_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 FROM bot_api_updates
WHERE bot_user_id = $1 WHERE bot_user_id = $1
AND update_kind = $2 AND update_kind = $2
AND ( AND (
(update_kind = 'callback_query' AND callback_query_id = $7) (update_kind = 'callback_query' AND callback_query_id = $7)
OR 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 != nil {
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
return domain.BotAPIUpdate{}, false, nil return domain.BotAPIUpdate{}, false, nil
@ -372,11 +390,13 @@ func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data, callback_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 ( FROM (
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data, callback_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 FROM bot_api_updates
WHERE bot_user_id = $1 WHERE bot_user_id = $1
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0) 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, ` rows, err := s.db.Query(ctx, `
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date, SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
callback_query_id, callback_user_id, callback_chat_instance, callback_data, callback_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 FROM bot_api_updates
WHERE bot_user_id = $1 AND id >= $2 WHERE bot_user_id = $1 AND id >= $2
ORDER BY id ORDER BY id
@ -619,9 +640,11 @@ func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error)
var callbackInlineDCID, callbackInlineMessageID int var callbackInlineDCID, callbackInlineMessageID int
var callbackInlineOwnerID, callbackInlineAccessHash int64 var callbackInlineOwnerID, callbackInlineAccessHash int64
var callbackData []byte 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, if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData, &callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil { &callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash,
&ephemeralPayload); err != nil {
return domain.BotAPIUpdate{}, err return domain.BotAPIUpdate{}, err
} }
item.Kind = domain.BotAPIUpdateKind(kind) 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} 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 return item, nil
} }
@ -662,6 +700,9 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
default: default:
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type) 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 { if req.Kind == domain.BotAPIUpdateCallbackQuery {
cb := req.Callback cb := req.Callback
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 || 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 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
}

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err) t.Fatalf("migrate star gift lifecycle schema: %v", err)
} }
if status.Dirty || status.Empty || status.Version != 119 { if status.Dirty || status.Empty || status.Version != 121 {
t.Fatalf("migration status = %+v, want clean version 119", status) t.Fatalf("migration status = %+v, want clean version 121", status)
} }
} }

View file

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

View file

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