feat: sync ephemeral transient messages
Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
deploy/migrations/0120_bot_api_ephemeral_payload.down.sql
Normal file
13
deploy/migrations/0120_bot_api_ephemeral_payload.down.sql
Normal 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');
|
||||||
61
deploy/migrations/0120_bot_api_ephemeral_payload.up.sql
Normal file
61
deploy/migrations/0120_bot_api_ephemeral_payload.up.sql
Normal 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;
|
||||||
1
deploy/migrations/0121_ephemeral_abuse_reports.down.sql
Normal file
1
deploy/migrations/0121_ephemeral_abuse_reports.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS public.ephemeral_abuse_reports;
|
||||||
23
deploy/migrations/0121_ephemeral_abuse_reports.up.sql
Normal file
23
deploy/migrations/0121_ephemeral_abuse_reports.up.sql
Normal 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
2
go.mod
|
|
@ -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
2
go.sum
|
|
@ -80,6 +80,8 @@ github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g
|
||||||
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
github.com/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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
617
internal/app/ephemeral/service.go
Normal file
617
internal/app/ephemeral/service.go
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
package ephemeral
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChannelAccess interface {
|
||||||
|
ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error)
|
||||||
|
GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error)
|
||||||
|
GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserDirectory interface {
|
||||||
|
ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotCommands interface {
|
||||||
|
GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
func WithClock(now func() time.Time) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if now != nil {
|
||||||
|
s.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithIDGenerator(next func() (int, error)) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if next != nil {
|
||||||
|
s.nextID = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
messages store.EphemeralMessageStore
|
||||||
|
channels ChannelAccess
|
||||||
|
users UserDirectory
|
||||||
|
bots BotCommands
|
||||||
|
now func() time.Time
|
||||||
|
nextID func() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(messages store.EphemeralMessageStore, channels ChannelAccess, users UserDirectory, bots BotCommands, options ...Option) *Service {
|
||||||
|
s := &Service{
|
||||||
|
messages: messages,
|
||||||
|
channels: channels,
|
||||||
|
users: users,
|
||||||
|
bots: bots,
|
||||||
|
now: time.Now,
|
||||||
|
nextID: randomEphemeralID,
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
if option != nil {
|
||||||
|
option(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SendFromClient(ctx context.Context, request domain.SendClientEphemeralRequest) (domain.EphemeralMessage, bool, error) {
|
||||||
|
if s == nil || s.messages == nil || s.channels == nil || s.users == nil || s.bots == nil {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
if request.SenderUserID <= 0 || request.ReceiverBotID <= 0 || request.SenderUserID == request.ReceiverBotID ||
|
||||||
|
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 || request.RandomID == 0 ||
|
||||||
|
request.OriginDevice.UserID != request.SenderUserID || request.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
|
||||||
|
request.OriginDevice.SessionID == 0 || !validContent(request.Content) {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
view, err := s.requireActiveGroupPair(ctx, request.SenderUserID, request.ReceiverBotID, request.Peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
receiver, found, err := s.users.ByID(ctx, request.SenderUserID, request.ReceiverBotID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || !receiver.Bot || receiver.Deleted {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
|
||||||
|
}
|
||||||
|
var replyTarget *domain.EphemeralMessage
|
||||||
|
if request.ReplyToEphemeralID != 0 {
|
||||||
|
target, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, s.now())
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || target.Deleted || target.SenderUserID != request.ReceiverBotID || target.ReceiverUserID != request.SenderUserID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||||
|
}
|
||||||
|
if target.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && target.OriginDevice.BusinessAuthKeyID != request.OriginDevice.BusinessAuthKeyID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||||
|
}
|
||||||
|
if request.TopMessageID != 0 && request.TopMessageID != target.TopMessageID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
request.TopMessageID = target.TopMessageID
|
||||||
|
replyTarget = &target
|
||||||
|
} else {
|
||||||
|
allowed, err := s.isEphemeralCommand(ctx, receiver, request.Content.Message)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralCommandInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.validateForumTopic(ctx, request.SenderUserID, view, request.TopMessageID); err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
|
||||||
|
Peer: request.Peer,
|
||||||
|
SenderUserID: request.SenderUserID,
|
||||||
|
ReceiverUserID: request.ReceiverBotID,
|
||||||
|
RandomID: request.RandomID,
|
||||||
|
TopMessageID: request.TopMessageID,
|
||||||
|
ReplyToEphemeralID: request.ReplyToEphemeralID,
|
||||||
|
Content: request.Content,
|
||||||
|
OriginDevice: request.OriginDevice,
|
||||||
|
PayloadHash: clientPayloadHash(request),
|
||||||
|
})
|
||||||
|
if err == nil && replyTarget != nil {
|
||||||
|
message.BotAPIReply = replyTarget
|
||||||
|
}
|
||||||
|
return message, fresh, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest) (domain.EphemeralMessage, bool, error) {
|
||||||
|
return s.sendFromBot(ctx, request, func(context.Context) (domain.EphemeralContent, error) {
|
||||||
|
return request.Content, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendFromBotLazy authorizes the bot, receiver, chat and eligible action before
|
||||||
|
// materializing content. The RPC edge uses it for URL/upload media so an
|
||||||
|
// unauthorized target cannot consume file storage, network or decoder work.
|
||||||
|
func (s *Service) SendFromBotLazy(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
|
||||||
|
if build == nil {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
return s.sendFromBot(ctx, request, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) sendFromBot(ctx context.Context, request domain.SendBotEphemeralRequest, build func(context.Context) (domain.EphemeralContent, error)) (domain.EphemeralMessage, bool, error) {
|
||||||
|
if s == nil || s.messages == nil || s.channels == nil || s.users == nil {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
if request.BotUserID <= 0 || request.ReceiverUserID <= 0 || request.BotUserID == request.ReceiverUserID ||
|
||||||
|
request.Peer.Type != domain.PeerTypeChannel || request.Peer.ID <= 0 {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
view, err := s.requireActiveGroupPair(ctx, request.BotUserID, request.ReceiverUserID, request.Peer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
bot, found, err := s.users.ByID(ctx, request.BotUserID, request.BotUserID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || !bot.Bot || bot.Deleted {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralSenderInvalid
|
||||||
|
}
|
||||||
|
receiver, found, err := s.users.ByID(ctx, request.BotUserID, request.ReceiverUserID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || receiver.Bot || receiver.Deleted {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReceiverInvalid
|
||||||
|
}
|
||||||
|
now := s.now()
|
||||||
|
var targetDevice domain.EphemeralDevice
|
||||||
|
var replyTarget *domain.EphemeralMessage
|
||||||
|
if request.ActionMessageID != 0 && request.CallbackQueryID != 0 {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
if request.CallbackQueryID != 0 {
|
||||||
|
action, found, err := s.messages.GetEphemeralCallbackAction(ctx, request.BotUserID, request.CallbackQueryID, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || action.UserID != request.ReceiverUserID || action.Peer != request.Peer || !now.Before(action.ExpiresAt) {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||||
|
}
|
||||||
|
targetDevice = action.Device
|
||||||
|
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
request.TopMessageID = action.TopMessageID
|
||||||
|
} else if request.ActionMessageID != 0 {
|
||||||
|
action, found, err := s.messages.GetEphemeralMessage(ctx, request.Peer, request.ActionMessageID, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found || action.Deleted || action.SenderUserID != request.ReceiverUserID || action.ReceiverUserID != request.BotUserID ||
|
||||||
|
now.Sub(action.CreatedAt) < 0 || now.Sub(action.CreatedAt) > domain.EphemeralReplyWindow {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||||
|
}
|
||||||
|
targetDevice = action.OriginDevice
|
||||||
|
replyTarget = &action
|
||||||
|
if request.TopMessageID != 0 && request.TopMessageID != action.TopMessageID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
request.TopMessageID = action.TopMessageID
|
||||||
|
if request.ReplyToEphemeralID == 0 {
|
||||||
|
request.ReplyToEphemeralID = action.ID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.ReplyToEphemeralID != 0 {
|
||||||
|
var reply domain.EphemeralMessage
|
||||||
|
found := false
|
||||||
|
if replyTarget != nil && replyTarget.ID == request.ReplyToEphemeralID {
|
||||||
|
reply, found = *replyTarget, true
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
reply, found, err = s.messages.GetEphemeralMessage(ctx, request.Peer, request.ReplyToEphemeralID, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found || reply.Deleted || !sameEphemeralParticipants(reply, request.BotUserID, request.ReceiverUserID) {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralReplyExpired
|
||||||
|
}
|
||||||
|
if targetDevice.BusinessAuthKeyID != ([8]byte{}) && reply.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
|
||||||
|
targetDevice.BusinessAuthKeyID != reply.OriginDevice.BusinessAuthKeyID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||||
|
}
|
||||||
|
if request.TopMessageID != 0 && request.TopMessageID != reply.TopMessageID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
request.TopMessageID = reply.TopMessageID
|
||||||
|
replyTarget = &reply
|
||||||
|
}
|
||||||
|
if err := s.validateForumTopic(ctx, request.BotUserID, view, request.TopMessageID); err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
content, err := build(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !validContent(content) {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
request.Content = content
|
||||||
|
if request.RandomID == 0 {
|
||||||
|
request.RandomID, err = randomEphemeralRandomID()
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message, fresh, err := s.create(ctx, domain.EphemeralMessage{
|
||||||
|
Peer: request.Peer,
|
||||||
|
SenderUserID: request.BotUserID,
|
||||||
|
ReceiverUserID: request.ReceiverUserID,
|
||||||
|
RandomID: request.RandomID,
|
||||||
|
TopMessageID: request.TopMessageID,
|
||||||
|
ReplyToEphemeralID: request.ReplyToEphemeralID,
|
||||||
|
Content: request.Content,
|
||||||
|
OriginDevice: targetDevice,
|
||||||
|
PayloadHash: botPayloadHash(request),
|
||||||
|
})
|
||||||
|
if err == nil && replyTarget != nil {
|
||||||
|
message.BotAPIReply = replyTarget
|
||||||
|
}
|
||||||
|
return message, fresh, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EditFromBot(ctx context.Context, botUserID int64, peer domain.Peer, id int, content domain.EphemeralContent) (domain.EphemeralMessage, error) {
|
||||||
|
now := s.now()
|
||||||
|
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||||
|
}
|
||||||
|
if message.SenderUserID != botUserID {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EditFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, fields domain.EditEphemeralFields) (domain.EphemeralMessage, error) {
|
||||||
|
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, func(context.Context) (domain.EditEphemeralFields, error) {
|
||||||
|
return fields, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditFieldsFromBotLazy performs the identity/ownership lookup before building
|
||||||
|
// replacement media. This keeps invalid edit requests off the remote-fetch and
|
||||||
|
// blob-materialization paths while preserving a single CAS write on success.
|
||||||
|
func (s *Service) EditFieldsFromBotLazy(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
|
||||||
|
if build == nil {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
return s.editFieldsFromBot(ctx, botUserID, receiverUserID, peer, id, mode, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) editFieldsFromBot(ctx context.Context, botUserID, receiverUserID int64, peer domain.Peer, id int, mode domain.EphemeralEditMode, build func(context.Context) (domain.EditEphemeralFields, error)) (domain.EphemeralMessage, error) {
|
||||||
|
now := s.now()
|
||||||
|
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||||
|
}
|
||||||
|
if message.SenderUserID != botUserID || message.ReceiverUserID != receiverUserID {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
fields, err := build(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, err
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case domain.EphemeralEditText:
|
||||||
|
if message.Content.Media != nil || !message.Content.RichMessage.IsZero() || !fields.SetMessage {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
case domain.EphemeralEditCaption:
|
||||||
|
if message.Content.Media == nil || !fields.SetMessage {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
case domain.EphemeralEditMedia:
|
||||||
|
if message.Content.Media == nil || !fields.SetMedia {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
case domain.EphemeralEditReplyMarkup:
|
||||||
|
if !fields.SetReplyMarkup || fields.SetMessage || fields.SetMedia {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
content := message.Content
|
||||||
|
if fields.SetMessage {
|
||||||
|
content.Message = fields.Message
|
||||||
|
content.Entities = append([]domain.MessageEntity(nil), fields.Entities...)
|
||||||
|
}
|
||||||
|
if fields.SetMedia {
|
||||||
|
content.Media = fields.Media
|
||||||
|
}
|
||||||
|
if fields.SetReplyMarkup {
|
||||||
|
content.ReplyMarkup = fields.ReplyMarkup
|
||||||
|
}
|
||||||
|
if !validContent(content) {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
return s.messages.EditEphemeralMessage(ctx, peer, id, message.Version, content, int(now.Unix()), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(ctx context.Context, actorUserID, receiverUserID int64, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||||
|
return s.delete(ctx, actorUserID, receiverUserID, nil, peer, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteFromDevice(ctx context.Context, actorUserID, receiverUserID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||||
|
if device.UserID != actorUserID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
return s.delete(ctx, actorUserID, receiverUserID, &device, peer, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) delete(ctx context.Context, actorUserID, receiverUserID int64, device *domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, bool, error) {
|
||||||
|
now := s.now()
|
||||||
|
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralNotFound
|
||||||
|
}
|
||||||
|
if message.ReceiverUserID != receiverUserID || (actorUserID != message.SenderUserID && actorUserID != message.ReceiverUserID) {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
if device != nil && message.OriginDevice.UserID == actorUserID && message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) &&
|
||||||
|
message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralDeviceMismatch
|
||||||
|
}
|
||||||
|
return s.messages.DeleteEphemeralMessage(ctx, peer, id, message.Version, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Callback(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int, data []byte) (domain.EphemeralCallback, error) {
|
||||||
|
if len(data) > domain.MaxEphemeralCallbackDataBytes || userID <= 0 || device.UserID != userID ||
|
||||||
|
device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||||
|
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||||
|
}
|
||||||
|
now := s.now()
|
||||||
|
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, now)
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralCallback{}, err
|
||||||
|
}
|
||||||
|
if !found || message.Deleted || message.ReceiverUserID != userID {
|
||||||
|
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||||
|
}
|
||||||
|
if !ephemeralMarkupContainsCallback(message.Content.ReplyMarkup, data) {
|
||||||
|
return domain.EphemeralCallback{}, domain.ErrEphemeralCallbackInvalid
|
||||||
|
}
|
||||||
|
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||||
|
return domain.EphemeralCallback{}, domain.ErrEphemeralDeviceMismatch
|
||||||
|
}
|
||||||
|
return domain.EphemeralCallback{
|
||||||
|
Message: message,
|
||||||
|
BotUserID: message.SenderUserID,
|
||||||
|
UserID: userID,
|
||||||
|
Peer: peer,
|
||||||
|
Data: append([]byte(nil), data...),
|
||||||
|
Device: device,
|
||||||
|
OccurredAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PutCallbackAction(ctx context.Context, action domain.EphemeralCallbackAction) (bool, error) {
|
||||||
|
if s == nil || s.messages == nil {
|
||||||
|
return false, domain.ErrEphemeralInvalid
|
||||||
|
}
|
||||||
|
return s.messages.PutEphemeralCallbackAction(ctx, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error) {
|
||||||
|
if userID <= 0 || device.UserID != userID || device.BusinessAuthKeyID == ([8]byte{}) || device.SessionID == 0 {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
message, found, err := s.messages.GetEphemeralMessage(ctx, peer, id, s.now())
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, err
|
||||||
|
}
|
||||||
|
if !found || message.Deleted || message.ReceiverUserID != userID {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralNotFound
|
||||||
|
}
|
||||||
|
if message.OriginDevice.BusinessAuthKeyID != ([8]byte{}) && message.OriginDevice.BusinessAuthKeyID != device.BusinessAuthKeyID {
|
||||||
|
return domain.EphemeralMessage{}, domain.ErrEphemeralDeviceMismatch
|
||||||
|
}
|
||||||
|
return message, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ephemeralMarkupContainsCallback(markup *domain.MessageReplyMarkup, data []byte) bool {
|
||||||
|
if markup == nil || markup.Kind() != domain.MessageReplyMarkupInline {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, row := range markup.Inline {
|
||||||
|
for _, button := range row {
|
||||||
|
if button.Type == domain.MarkupButtonCallback && bytes.Equal(button.Data, data) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameEphemeralParticipants(message domain.EphemeralMessage, first, second int64) bool {
|
||||||
|
return (message.SenderUserID == first && message.ReceiverUserID == second) ||
|
||||||
|
(message.SenderUserID == second && message.ReceiverUserID == first)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) create(ctx context.Context, message domain.EphemeralMessage) (domain.EphemeralMessage, bool, error) {
|
||||||
|
now := s.now()
|
||||||
|
message.Date = int(now.Unix())
|
||||||
|
message.CreatedAt = now
|
||||||
|
message.ExpiresAt = now.Add(domain.EphemeralMessageRetention)
|
||||||
|
message.Version = 1
|
||||||
|
for attempt := 0; attempt < domain.MaxEphemeralCreateAttempts; attempt++ {
|
||||||
|
id, err := s.nextID()
|
||||||
|
if err != nil {
|
||||||
|
return domain.EphemeralMessage{}, false, err
|
||||||
|
}
|
||||||
|
message.ID = id
|
||||||
|
created, fresh, err := s.messages.CreateEphemeralMessage(ctx, message)
|
||||||
|
if !errors.Is(err, domain.ErrEphemeralIDCollision) {
|
||||||
|
return created, fresh, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domain.EphemeralMessage{}, false, domain.ErrEphemeralIDCollision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) requireActiveGroupPair(ctx context.Context, viewerUserID, otherUserID, channelID int64) (domain.ChannelView, error) {
|
||||||
|
view, err := s.channels.ResolveChannel(ctx, viewerUserID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChannelView{}, err
|
||||||
|
}
|
||||||
|
if view.Channel.Deleted || view.Channel.Broadcast || view.Channel.Monoforum || view.Self.Status != domain.ChannelMemberActive {
|
||||||
|
return domain.ChannelView{}, domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
other, err := s.channels.GetParticipant(ctx, viewerUserID, channelID, otherUserID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.ChannelView{}, err
|
||||||
|
}
|
||||||
|
if other.Status != domain.ChannelMemberActive {
|
||||||
|
return domain.ChannelView{}, domain.ErrEphemeralReceiverInvalid
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateForumTopic(ctx context.Context, userID int64, view domain.ChannelView, topMessageID int) error {
|
||||||
|
if topMessageID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !view.Channel.Forum || topMessageID < 0 || topMessageID > domain.MaxMessageBoxID {
|
||||||
|
return domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
topics, err := s.channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, []int{topMessageID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(topics.Topics) != 1 || topics.Topics[0].TopicID != topMessageID || topics.Topics[0].Hidden {
|
||||||
|
return domain.ErrEphemeralPeerInvalid
|
||||||
|
}
|
||||||
|
if topics.Topics[0].Closed && view.Self.Role != domain.ChannelRoleAdmin && view.Self.Role != domain.ChannelRoleCreator {
|
||||||
|
return domain.ErrEphemeralForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) isEphemeralCommand(ctx context.Context, bot domain.User, message string) (bool, error) {
|
||||||
|
command, username, ok := parseCommand(message)
|
||||||
|
if !ok || (username != "" && !strings.EqualFold(username, bot.Username)) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
commands, err := s.bots.GetBotCommands(ctx, bot.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, candidate := range commands {
|
||||||
|
if candidate.Ephemeral && strings.EqualFold(candidate.Command, command) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCommand(message string) (command, username string, ok bool) {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(message))
|
||||||
|
if len(fields) == 0 || len(fields[0]) < 2 || fields[0][0] != '/' {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(fields[0][1:], "@", 2)
|
||||||
|
command = strings.ToLower(parts[0])
|
||||||
|
if command == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
username = strings.TrimPrefix(strings.ToLower(parts[1]), "@")
|
||||||
|
if username == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return command, username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func validContent(content domain.EphemeralContent) bool {
|
||||||
|
return domain.ValidateEphemeralContent(content) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientPayloadHash(request domain.SendClientEphemeralRequest) [32]byte {
|
||||||
|
return payloadHash(struct {
|
||||||
|
SenderUserID, ReceiverBotID int64
|
||||||
|
Peer domain.Peer
|
||||||
|
QueryID, RandomID int64
|
||||||
|
TopMessageID, ReplyID int
|
||||||
|
Content domain.EphemeralContent
|
||||||
|
Device domain.EphemeralDevice
|
||||||
|
}{request.SenderUserID, request.ReceiverBotID, request.Peer, request.QueryID, request.RandomID,
|
||||||
|
request.TopMessageID, request.ReplyToEphemeralID, request.Content, request.OriginDevice})
|
||||||
|
}
|
||||||
|
|
||||||
|
func botPayloadHash(request domain.SendBotEphemeralRequest) [32]byte {
|
||||||
|
return payloadHash(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func payloadHash(value any) [32]byte {
|
||||||
|
raw, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return sha256.Sum256([]byte("invalid-ephemeral-payload"))
|
||||||
|
}
|
||||||
|
return sha256.Sum256(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomEphemeralID() (int, error) {
|
||||||
|
var raw [4]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
value := binary.LittleEndian.Uint32(raw[:]) & 0x7fffffff
|
||||||
|
if value == 0 {
|
||||||
|
value = 1
|
||||||
|
}
|
||||||
|
return int(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomEphemeralRandomID() (int64, error) {
|
||||||
|
var raw [8]byte
|
||||||
|
if _, err := rand.Read(raw[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
value := int64(binary.LittleEndian.Uint64(raw[:]))
|
||||||
|
if value == 0 {
|
||||||
|
value = 1
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
385
internal/app/ephemeral/service_test.go
Normal file
385
internal/app/ephemeral/service_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
package ephemeral
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testHumanID int64 = 1001
|
||||||
|
testBotID int64 = 2001
|
||||||
|
testChannel int64 = 3001
|
||||||
|
testSession int64 = 4001
|
||||||
|
)
|
||||||
|
|
||||||
|
var testDeviceKey = [8]byte{1, 2, 3, 4}
|
||||||
|
|
||||||
|
type testChannels struct {
|
||||||
|
roles map[int64]domain.ChannelMemberRole
|
||||||
|
status map[int64]domain.ChannelMemberStatus
|
||||||
|
channel domain.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testChannels) ResolveChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
|
||||||
|
if channelID != c.channel.ID {
|
||||||
|
return domain.ChannelView{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
return domain.ChannelView{Channel: c.channel, Self: domain.ChannelMember{
|
||||||
|
ChannelID: channelID, UserID: userID, Role: c.roles[userID], Status: c.status[userID],
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testChannels) GetParticipant(_ context.Context, _ int64, channelID, participantUserID int64) (domain.ChannelMember, error) {
|
||||||
|
if channelID != c.channel.ID {
|
||||||
|
return domain.ChannelMember{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
return domain.ChannelMember{ChannelID: channelID, UserID: participantUserID, Role: c.roles[participantUserID], Status: c.status[participantUserID]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testChannels) GetForumTopicsByID(_ context.Context, _ int64, channelID int64, ids []int) (domain.ChannelForumTopicList, error) {
|
||||||
|
if channelID != c.channel.ID {
|
||||||
|
return domain.ChannelForumTopicList{}, domain.ErrChannelInvalid
|
||||||
|
}
|
||||||
|
out := domain.ChannelForumTopicList{Channel: c.channel}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id > 0 {
|
||||||
|
out.Topics = append(out.Topics, domain.ChannelForumTopic{ChannelID: channelID, TopicID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testUsers map[int64]domain.User
|
||||||
|
|
||||||
|
func (u testUsers) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) {
|
||||||
|
user, found := u[userID]
|
||||||
|
return user, found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testBots map[int64][]domain.BotCommand
|
||||||
|
|
||||||
|
func (b testBots) GetBotCommands(_ context.Context, botUserID int64) ([]domain.BotCommand, error) {
|
||||||
|
return append([]domain.BotCommand(nil), b[botUserID]...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceFixture struct {
|
||||||
|
service *Service
|
||||||
|
store *memory.EphemeralMessageStore
|
||||||
|
now time.Time
|
||||||
|
nextID int
|
||||||
|
channels *testChannels
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServiceFixture() *serviceFixture {
|
||||||
|
f := &serviceFixture{
|
||||||
|
store: memory.NewEphemeralMessageStore(),
|
||||||
|
now: time.Unix(1_900_000_000, 0),
|
||||||
|
nextID: 10,
|
||||||
|
channels: &testChannels{
|
||||||
|
roles: map[int64]domain.ChannelMemberRole{testHumanID: domain.ChannelRoleMember, testBotID: domain.ChannelRoleMember},
|
||||||
|
status: map[int64]domain.ChannelMemberStatus{testHumanID: domain.ChannelMemberActive, testBotID: domain.ChannelMemberActive},
|
||||||
|
channel: domain.Channel{ID: testChannel, Megagroup: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f.service = NewService(f.store, f.channels, testUsers{
|
||||||
|
testHumanID: {ID: testHumanID, Username: "alice"},
|
||||||
|
testBotID: {ID: testBotID, Username: "private_bot", Bot: true, BotInfoVersion: 1},
|
||||||
|
}, testBots{testBotID: {{Command: "private", Description: "private", Ephemeral: true}, {Command: "public", Description: "public"}}},
|
||||||
|
WithClock(func() time.Time { return f.now }),
|
||||||
|
WithIDGenerator(func() (int, error) { f.nextID++; return f.nextID, nil }))
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceFixture) clientRequest() domain.SendClientEphemeralRequest {
|
||||||
|
return domain.SendClientEphemeralRequest{
|
||||||
|
SenderUserID: testHumanID, ReceiverBotID: testBotID,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||||
|
RandomID: 91, Content: domain.EphemeralContent{Message: "/private@private_bot hello"},
|
||||||
|
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendFromClientRequiresEphemeralCommandAndPreservesDevice(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
message, fresh, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||||
|
if err != nil || !fresh {
|
||||||
|
t.Fatalf("send = %+v fresh=%v err=%v", message, fresh, err)
|
||||||
|
}
|
||||||
|
if message.SenderUserID != testHumanID || message.ReceiverUserID != testBotID || message.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||||
|
t.Fatalf("message = %+v", message)
|
||||||
|
}
|
||||||
|
request := f.clientRequest()
|
||||||
|
request.RandomID++
|
||||||
|
request.Content.Message = "/public"
|
||||||
|
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralCommandInvalid) {
|
||||||
|
t.Fatalf("ordinary command err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletedCreateReplayReturnsTombstoneWithoutResurrection(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
request := f.clientRequest()
|
||||||
|
message, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||||
|
if err != nil || !fresh {
|
||||||
|
t.Fatalf("create fresh=%v err=%v", fresh, err)
|
||||||
|
}
|
||||||
|
device := request.OriginDevice
|
||||||
|
if _, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testBotID, device, message.Peer, message.ID); err != nil || !changed {
|
||||||
|
t.Fatalf("delete changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
replayed, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||||
|
if err != nil || fresh || !replayed.Deleted || replayed.ID != message.ID || replayed.Version != 2 {
|
||||||
|
t.Fatalf("replay=%+v fresh=%v err=%v", replayed, fresh, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientReplyMustMatchTargetDevice(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
request := f.clientRequest()
|
||||||
|
request.Content.Message = "reply"
|
||||||
|
request.ReplyToEphemeralID = incoming.ID
|
||||||
|
reply, fresh, err := f.service.SendFromClient(context.Background(), request)
|
||||||
|
if err != nil || !fresh || reply.BotAPIReply == nil || reply.BotAPIReply.ID != incoming.ID {
|
||||||
|
t.Fatalf("reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||||
|
}
|
||||||
|
request.RandomID++
|
||||||
|
request.OriginDevice.BusinessAuthKeyID = [8]byte{9}
|
||||||
|
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||||
|
t.Fatalf("other device reply err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotReplyWindowAndAdminBroadcast(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
action, _, err := f.service.SendFromClient(context.Background(), f.clientRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.now = f.now.Add(14 * time.Second)
|
||||||
|
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID,
|
||||||
|
Peer: action.Peer, RandomID: 92, Content: domain.EphemeralContent{Message: "answer"}, ActionMessageID: action.ID,
|
||||||
|
})
|
||||||
|
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey || reply.ReplyToEphemeralID != action.ID ||
|
||||||
|
reply.BotAPIReply == nil || reply.BotAPIReply.ID != action.ID {
|
||||||
|
t.Fatalf("bot reply = %+v fresh=%v err=%v", reply, fresh, err)
|
||||||
|
}
|
||||||
|
f.now = f.now.Add(2 * time.Second)
|
||||||
|
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||||
|
RandomID: 93, Content: domain.EphemeralContent{Message: "late"}, ActionMessageID: action.ID,
|
||||||
|
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||||
|
t.Fatalf("late bot reply err=%v", err)
|
||||||
|
}
|
||||||
|
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||||
|
broadcast, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: action.Peer,
|
||||||
|
RandomID: 94, Content: domain.EphemeralContent{Message: "admin"},
|
||||||
|
})
|
||||||
|
if err != nil || broadcast.OriginDevice.BusinessAuthKeyID != ([8]byte{}) {
|
||||||
|
t.Fatalf("admin broadcast = %+v err=%v", broadcast, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackAndDeleteEnforceParticipantsAndDevice(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||||
|
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||||
|
if err != nil || callback.BotUserID != testBotID || string(callback.Data) != "ok" {
|
||||||
|
t.Fatalf("callback = %+v err=%v", callback, err)
|
||||||
|
}
|
||||||
|
device.BusinessAuthKeyID = [8]byte{7}
|
||||||
|
if _, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok")); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||||
|
t.Fatalf("other device callback err=%v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID); !errors.Is(err, domain.ErrEphemeralDeviceMismatch) {
|
||||||
|
t.Fatalf("other device delete err=%v", err)
|
||||||
|
}
|
||||||
|
device.BusinessAuthKeyID = testDeviceKey
|
||||||
|
deleted, changed, err := f.service.DeleteFromDevice(context.Background(), testHumanID, testHumanID, device, incoming.Peer, incoming.ID)
|
||||||
|
if err != nil || !changed || !deleted.Deleted {
|
||||||
|
t.Fatalf("delete = %+v changed=%v err=%v", deleted, changed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallbackActionTargetsExactDeviceAndExpiresAtFifteenSeconds(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
device := domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: testDeviceKey, SessionID: testSession}
|
||||||
|
callback, err := f.service.Callback(context.Background(), testHumanID, device, incoming.Peer, incoming.ID, []byte("ok"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const queryID = int64(777)
|
||||||
|
created, err := f.service.PutCallbackAction(context.Background(), domain.EphemeralCallbackAction{
|
||||||
|
QueryID: queryID, BotUserID: testBotID, UserID: testHumanID, Peer: incoming.Peer,
|
||||||
|
MessageID: incoming.ID, Device: callback.Device, CreatedAt: f.now,
|
||||||
|
ExpiresAt: f.now.Add(domain.EphemeralReplyWindow),
|
||||||
|
})
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("put callback action created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
reply, fresh, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||||
|
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "callback response"},
|
||||||
|
})
|
||||||
|
if err != nil || !fresh || reply.OriginDevice.BusinessAuthKeyID != testDeviceKey {
|
||||||
|
t.Fatalf("callback reply=%+v fresh=%v err=%v", reply, fresh, err)
|
||||||
|
}
|
||||||
|
f.now = f.now.Add(domain.EphemeralReplyWindow)
|
||||||
|
if _, _, err := f.service.SendFromBot(context.Background(), domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID, Peer: incoming.Peer,
|
||||||
|
CallbackQueryID: queryID, Content: domain.EphemeralContent{Message: "too late"},
|
||||||
|
}); !errors.Is(err, domain.ErrEphemeralReplyExpired) {
|
||||||
|
t.Fatalf("expired callback action err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForumRepliesInheritTopicAndNonForumRejectsTopic(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
f.channels.channel.Forum = true
|
||||||
|
incoming := f.putIncomingInTopic(t, testDeviceKey, f.now, 42)
|
||||||
|
request := f.clientRequest()
|
||||||
|
request.Content.Message = "topic reply"
|
||||||
|
request.ReplyToEphemeralID = incoming.ID
|
||||||
|
reply, _, err := f.service.SendFromClient(context.Background(), request)
|
||||||
|
if err != nil || reply.TopMessageID != 42 {
|
||||||
|
t.Fatalf("topic reply=%+v err=%v", reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f = newServiceFixture()
|
||||||
|
request = f.clientRequest()
|
||||||
|
request.TopMessageID = 42
|
||||||
|
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralPeerInvalid) {
|
||||||
|
t.Fatalf("non-forum topic err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEphemeralTextLimitCountsUnicodeCharacters(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
request := f.clientRequest()
|
||||||
|
request.Content.Message = "/private " + strings.Repeat("界", domain.MaxMessageTextLength-len("/private "))
|
||||||
|
if _, _, err := f.service.SendFromClient(context.Background(), request); err != nil {
|
||||||
|
t.Fatalf("4096 Unicode characters rejected: %v", err)
|
||||||
|
}
|
||||||
|
request.RandomID++
|
||||||
|
request.Content.Message += "界"
|
||||||
|
if _, _, err := f.service.SendFromClient(context.Background(), request); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||||
|
t.Fatalf("overlong Unicode text err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotEditModesCannotCrossTextAndMediaShapes(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
textMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||||
|
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "edited"}); err != nil {
|
||||||
|
t.Fatalf("text edit: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, textMessage.Peer, textMessage.ID,
|
||||||
|
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "caption"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||||
|
t.Fatalf("caption edit on text err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaMessage := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
mediaContent := domain.EphemeralContent{
|
||||||
|
Message: "caption",
|
||||||
|
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 99}},
|
||||||
|
}
|
||||||
|
mediaMessage, err := f.store.EditEphemeralMessage(context.Background(), mediaMessage.Peer, mediaMessage.ID, mediaMessage.Version, mediaContent, int(f.now.Unix()), f.now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||||
|
domain.EphemeralEditCaption, domain.EditEphemeralFields{SetMessage: true, Message: "new caption"}); err != nil {
|
||||||
|
t.Fatalf("media caption edit: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.service.EditFieldsFromBot(context.Background(), testBotID, testHumanID, mediaMessage.Peer, mediaMessage.ID,
|
||||||
|
domain.EphemeralEditText, domain.EditEphemeralFields{SetMessage: true, Message: "turn into text"}); !errors.Is(err, domain.ErrEphemeralInvalid) {
|
||||||
|
t.Fatalf("text edit on media err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBotLazyBuildersRunOnlyAfterAuthorization(t *testing.T) {
|
||||||
|
f := newServiceFixture()
|
||||||
|
builds := 0
|
||||||
|
buildText := func(context.Context) (domain.EphemeralContent, error) {
|
||||||
|
builds++
|
||||||
|
return domain.EphemeralContent{Message: "authorized"}, nil
|
||||||
|
}
|
||||||
|
request := domain.SendBotEphemeralRequest{
|
||||||
|
BotUserID: testBotID, ReceiverUserID: testHumanID + 99,
|
||||||
|
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||||
|
}
|
||||||
|
if _, _, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err == nil {
|
||||||
|
t.Fatal("unknown receiver was accepted")
|
||||||
|
}
|
||||||
|
if builds != 0 {
|
||||||
|
t.Fatalf("unauthorized send materialized content %d times", builds)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.channels.roles[testBotID] = domain.ChannelRoleAdmin
|
||||||
|
request.ReceiverUserID = testHumanID
|
||||||
|
if _, fresh, err := f.service.SendFromBotLazy(context.Background(), request, buildText); err != nil || !fresh {
|
||||||
|
t.Fatalf("authorized lazy send fresh=%v err=%v", fresh, err)
|
||||||
|
}
|
||||||
|
if builds != 1 {
|
||||||
|
t.Fatalf("authorized send materialized content %d times", builds)
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming := f.putIncoming(t, testDeviceKey, f.now)
|
||||||
|
editBuilds := 0
|
||||||
|
buildEdit := func(context.Context) (domain.EditEphemeralFields, error) {
|
||||||
|
editBuilds++
|
||||||
|
return domain.EditEphemeralFields{SetMessage: true, Message: "edited"}, nil
|
||||||
|
}
|
||||||
|
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID+99, testHumanID, incoming.Peer, incoming.ID,
|
||||||
|
domain.EphemeralEditText, buildEdit); !errors.Is(err, domain.ErrEphemeralForbidden) {
|
||||||
|
t.Fatalf("unauthorized lazy edit err=%v", err)
|
||||||
|
}
|
||||||
|
if editBuilds != 0 {
|
||||||
|
t.Fatalf("unauthorized edit materialized content %d times", editBuilds)
|
||||||
|
}
|
||||||
|
if _, err := f.service.EditFieldsFromBotLazy(context.Background(), testBotID, testHumanID, incoming.Peer, incoming.ID,
|
||||||
|
domain.EphemeralEditText, buildEdit); err != nil {
|
||||||
|
t.Fatalf("authorized lazy edit: %v", err)
|
||||||
|
}
|
||||||
|
if editBuilds != 1 {
|
||||||
|
t.Fatalf("authorized edit materialized content %d times", editBuilds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceFixture) putIncoming(t *testing.T, deviceKey [8]byte, createdAt time.Time) domain.EphemeralMessage {
|
||||||
|
return f.putIncomingInTopic(t, deviceKey, createdAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *serviceFixture) putIncomingInTopic(t *testing.T, deviceKey [8]byte, createdAt time.Time, topMessageID int) domain.EphemeralMessage {
|
||||||
|
t.Helper()
|
||||||
|
f.nextID++
|
||||||
|
message := domain.EphemeralMessage{
|
||||||
|
ID: f.nextID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: testChannel},
|
||||||
|
SenderUserID: testBotID, ReceiverUserID: testHumanID, Date: int(createdAt.Unix()), RandomID: int64(f.nextID),
|
||||||
|
TopMessageID: topMessageID,
|
||||||
|
Content: domain.EphemeralContent{Message: "incoming", ReplyMarkup: &domain.MessageReplyMarkup{
|
||||||
|
Type: domain.MessageReplyMarkupInline,
|
||||||
|
Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "OK", Data: []byte("ok")}}},
|
||||||
|
}},
|
||||||
|
OriginDevice: domain.EphemeralDevice{UserID: testHumanID, BusinessAuthKeyID: deviceKey, SessionID: testSession},
|
||||||
|
PayloadHash: sha256.Sum256([]byte("incoming")), Version: 1,
|
||||||
|
CreatedAt: createdAt, ExpiresAt: createdAt.Add(domain.EphemeralMessageRetention),
|
||||||
|
}
|
||||||
|
stored, _, err := f.store.CreateEphemeralMessage(context.Background(), message)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
104
internal/botapi/bot_commands.go
Normal file
104
internal/botapi/bot_commands.go
Normal 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)
|
||||||
|
}
|
||||||
375
internal/botapi/ephemeral.go
Normal file
375
internal/botapi/ephemeral.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 标识菜单按钮类型。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
internal/domain/botapi_update_test.go
Normal file
41
internal/domain/botapi_update_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
362
internal/domain/ephemeral.go
Normal file
362
internal/domain/ephemeral.go
Normal 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)
|
||||||
|
}
|
||||||
64
internal/domain/ephemeral_test.go
Normal file
64
internal/domain/ephemeral_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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 构造器没有
|
||||||
|
|
|
||||||
|
|
@ -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 的 transient(typing)best-effort 版本。
|
// PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transient(typing)best-effort 版本。
|
||||||
func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg tg.UpdatesClass, timeout time.Duration) (int, error) {
|
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 {
|
||||||
// transient(typing/presence):未就绪即丢,不进 pending。这些 update 不写
|
// transient(typing/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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
460
internal/rpc/ephemeral.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
107
internal/rpc/ephemeral_push.go
Normal file
107
internal/rpc/ephemeral_push.go
Normal 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)
|
||||||
|
}
|
||||||
220
internal/rpc/ephemeral_push_test.go
Normal file
220
internal/rpc/ephemeral_push_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/rpc/ephemeral_rpc_test.go
Normal file
96
internal/rpc/ephemeral_rpc_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
internal/rpc/users_bot_info_test.go
Normal file
25
internal/rpc/users_bot_info_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/store/ephemeral.go
Normal file
54
internal/store/ephemeral.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
380
internal/store/memory/ephemeral.go
Normal file
380
internal/store/memory/ephemeral.go
Normal 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
|
||||||
|
}
|
||||||
53
internal/store/memory/ephemeral_report.go
Normal file
53
internal/store/memory/ephemeral_report.go
Normal 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
|
||||||
|
}
|
||||||
184
internal/store/memory/ephemeral_test.go
Normal file
184
internal/store/memory/ephemeral_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
49
internal/store/postgres/ephemeral_report.go
Normal file
49
internal/store/postgres/ephemeral_report.go
Normal 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
|
||||||
|
}
|
||||||
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal file
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
458
internal/store/redisstore/ephemeral.go
Normal file
458
internal/store/redisstore/ephemeral.go
Normal 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
|
||||||
|
}
|
||||||
99
internal/store/redisstore/ephemeral_integration_test.go
Normal file
99
internal/store/redisstore/ephemeral_integration_test.go
Normal 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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue