feat: sync built-in sticker bot

This commit is contained in:
A 2026-07-01 21:55:55 +08:00
parent 7096625e13
commit 6867d201ed
60 changed files with 7063 additions and 144 deletions

View file

@ -11,7 +11,8 @@ import (
)
// 本文件实现 app/bots 的 RouterHooks 回调token revoke 后的 session 失效闭环,
// 以及命令变更后的 updateBotCommands 在线推送。Router 创建后经
// 命令变更后的 updateBotCommands 在线推送,以及 @Stickers 发布后的
// updateStickerSets 在线提示。Router 创建后经
// botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go
// maxBotCommandsPushPeers 限制单次命令变更的推送扇出bot 的最近 dialog peer 数)。
@ -60,6 +61,26 @@ func (r *Router) PushBotCommandsChanged(ctx context.Context, botUserID int64, co
go r.pushBotCommandsChanged(context.WithoutCancel(ctx), botUserID, cmds)
}
// PushStickerSetsChanged 给单个用户在线 session 推 updateStickerSets。该 update 无
// pts不进 getDifference权威安装态已写 user_sticker_sets离线端下次
// messages.getAllStickers/messages.getEmojiStickers 会重建。
func (r *Router) PushStickerSetsChanged(ctx context.Context, userID int64, kind domain.StickerSetKind) {
if userID == 0 {
return
}
r.invalidateStickerCatalog(kind)
go func() {
defer func() {
if rec := recover(); rec != nil {
r.log.Error("push sticker sets panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
}
}()
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
r.pushStickerSetsUpdate(ctx, userID, kind)
}()
}
func (r *Router) pushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) {
defer func() {
if rec := recover(); rec != nil {

View file

@ -578,6 +578,8 @@ func tgStickerSet(set domain.StickerSet) tg.StickerSet {
Official: set.Official,
Masks: set.Masks,
Emojis: set.Emojis,
TextColor: set.TextColor,
Creator: set.Creator,
ID: set.ID,
AccessHash: set.AccessHash,
Title: set.Title,
@ -633,12 +635,23 @@ func tgStickerPacks(packs []domain.StickerPack) []tg.StickerPack {
return out
}
func tgStickerKeywords(keywords []domain.StickerKeyword) []tg.StickerKeyword {
out := make([]tg.StickerKeyword, 0, len(keywords))
for _, kw := range keywords {
if kw.DocumentID == 0 || len(kw.Keywords) == 0 {
continue
}
out = append(out, tg.StickerKeyword{DocumentID: kw.DocumentID, Keyword: append([]string(nil), kw.Keywords...)})
}
return out
}
// tgMessagesStickerSet 构造完整 messages.stickerSetset + packs + documents
func tgMessagesStickerSet(set domain.StickerSet, docs []domain.Document) *tg.MessagesStickerSet {
return &tg.MessagesStickerSet{
Set: tgStickerSet(set),
Packs: tgStickerPacks(set.Packs),
Keywords: []tg.StickerKeyword{},
Keywords: tgStickerKeywords(set.Keywords),
Documents: tgDocuments(docs),
}
}

View file

@ -125,7 +125,7 @@ func applyTgUserBotFields(out *tg.User, u domain.User) {
version = 1
}
out.SetBotInfoVersion(version)
if u.ID != domain.BotFatherUserID {
if !domain.IsSystemUserID(u.ID) {
out.SetBotBusiness(true)
}
out.Phone = ""

View file

@ -607,6 +607,15 @@ type FilesService interface {
GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error)
ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (set domain.StickerSet, documents []domain.Document, found bool, err error)
ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error)
CheckStickerSetShortName(ctx context.Context, shortName string) (bool, error)
SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error)
CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error)
AddStickerToSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
RemoveStickerFromSet(ctx context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error)
ChangeStickerPosition(ctx context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error)
RenameStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error)
DeleteStickerSet(ctx context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error)
// 头像profile photo与消息媒体组装。
CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error)
CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error)

View file

@ -517,17 +517,12 @@ func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, use
}
func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass {
if hash != 0 {
return &tg.MessagesAllStickersNotModified{}
}
return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}}
return &tg.MessagesAllStickers{Hash: 0, Sets: []tg.StickerSet{}}
}
func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass {
if hash != 0 {
return &tg.MessagesFeaturedStickersNotModified{Count: 0}
}
return &tg.MessagesFeaturedStickers{
Hash: 0,
Count: 0,
Sets: []tg.StickerSetCoveredClass{},
Unread: []int64{},

View file

@ -91,6 +91,11 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions)
d.OnMessagesGetAvailableEffects(r.onMessagesGetAvailableEffects)
d.OnMessagesGetStickers(r.onMessagesGetStickers)
d.OnMessagesInstallStickerSet(r.onMessagesInstallStickerSet)
d.OnMessagesUninstallStickerSet(r.onMessagesUninstallStickerSet)
d.OnMessagesReorderStickerSets(r.onMessagesReorderStickerSets)
d.OnMessagesToggleStickerSets(r.onMessagesToggleStickerSets)
d.OnMessagesGetMyStickers(r.onMessagesGetMyStickers)
d.OnMessagesGetArchivedStickers(func(ctx context.Context, req *tg.MessagesGetArchivedStickersRequest) (*tg.MessagesArchivedStickers, error) {
return &tg.MessagesArchivedStickers{
Count: 0,

View file

@ -0,0 +1,263 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
type userStickerSetService interface {
InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error
UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error
SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error
ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error
ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error)
}
func (r *Router) userStickerSetSvc() (userStickerSetService, bool) {
svc, ok := r.deps.Account.(userStickerSetService)
return svc, ok
}
func (r *Router) onMessagesInstallStickerSet(ctx context.Context, req *tg.MessagesInstallStickerSetRequest) (tg.MessagesStickerSetInstallResultClass, error) {
if req == nil {
return nil, stickersetInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
set, _, err := r.resolveInstallableStickerSet(ctx, req.Stickerset)
if err != nil {
return nil, err
}
if svc, ok := r.userStickerSetSvc(); ok {
if err := svc.InstallUserStickerSet(ctx, userID, set.ID, userStickerSetKind(set), req.Archived, int(r.clock.Now().Unix())); err != nil {
return nil, internalErr()
}
}
r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set))
return &tg.MessagesStickerSetInstallResultSuccess{}, nil
}
func (r *Router) onMessagesUninstallStickerSet(ctx context.Context, input tg.InputStickerSetClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
set, _, err := r.resolveInstallableStickerSet(ctx, input)
if err != nil {
return false, err
}
if svc, ok := r.userStickerSetSvc(); ok {
if err := svc.UninstallUserStickerSet(ctx, userID, set.ID); err != nil {
return false, internalErr()
}
}
r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set))
return true, nil
}
func (r *Router) onMessagesReorderStickerSets(ctx context.Context, req *tg.MessagesReorderStickerSetsRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
kind := stickerSetKindFromFlags(req.Masks, req.Emojis)
order := uniqueNonZeroInt64s(req.Order, domain.MaxInstalledStickerSets)
if len(order) == 0 {
return true, nil
}
if svc, ok := r.userStickerSetSvc(); ok {
if err := svc.ReorderUserStickerSets(ctx, userID, kind, order, int(r.clock.Now().Unix())); err != nil {
return false, internalErr()
}
}
r.pushStickerSetsOrderUpdate(ctx, userID, kind, order)
return true, nil
}
func (r *Router) onMessagesToggleStickerSets(ctx context.Context, req *tg.MessagesToggleStickerSetsRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if len(req.Stickersets) > domain.MaxInstalledStickerSets {
return false, limitInvalidErr()
}
svc, hasSvc := r.userStickerSetSvc()
kinds := make(map[domain.StickerSetKind]bool)
now := int(r.clock.Now().Unix())
for _, input := range req.Stickersets {
set, _, err := r.resolveInstallableStickerSet(ctx, input)
if err != nil {
return false, err
}
kind := userStickerSetKind(set)
kinds[kind] = true
if !hasSvc {
continue
}
switch {
case req.Uninstall:
if err := svc.UninstallUserStickerSet(ctx, userID, set.ID); err != nil {
return false, internalErr()
}
case req.Archive:
if err := svc.SetUserStickerSetArchived(ctx, userID, set.ID, true, now); err != nil {
return false, internalErr()
}
case req.Unarchive:
if err := svc.SetUserStickerSetArchived(ctx, userID, set.ID, false, now); err != nil {
return false, internalErr()
}
default:
if err := svc.InstallUserStickerSet(ctx, userID, set.ID, kind, false, now); err != nil {
return false, internalErr()
}
}
}
for kind := range kinds {
r.pushStickerSetsUpdate(ctx, userID, kind)
}
return true, nil
}
func (r *Router) onMessagesGetMyStickers(ctx context.Context, req *tg.MessagesGetMyStickersRequest) (*tg.MessagesMyStickers, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Files == nil {
return &tg.MessagesMyStickers{Count: 0, Sets: []tg.StickerSetCoveredClass{}}, nil
}
limit := 50
var offsetID int64
if req != nil {
if req.OffsetID < 0 {
return nil, offsetInvalidErr()
}
if req.Limit < 0 || req.Limit > domain.MaxCreatedStickerSets {
return nil, limitInvalidErr()
}
if req.Limit > 0 {
limit = req.Limit
}
offsetID = req.OffsetID
}
sets, total, err := r.deps.Files.ListCreatedStickerSets(ctx, userID, offsetID, limit)
if err != nil {
return nil, internalErr()
}
covered := make([]tg.StickerSetCoveredClass, 0, len(sets))
for _, set := range sets {
set.Creator = true
covered = append(covered, &tg.StickerSetNoCovered{Set: tgStickerSet(set)})
}
return &tg.MessagesMyStickers{Count: total, Sets: covered}, nil
}
func (r *Router) resolveInstallableStickerSet(ctx context.Context, input tg.InputStickerSetClass) (domain.StickerSet, []domain.Document, error) {
ref, ok := stickerSetRefFromInput(input)
if !ok || (ref.Kind != domain.StickerSetRefByID && ref.Kind != domain.StickerSetRefByShortName) {
return domain.StickerSet{}, nil, stickersetInvalidErr()
}
if r.deps.Files == nil {
return domain.StickerSet{}, nil, stickersetInvalidErr()
}
set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, ref)
if err != nil {
return domain.StickerSet{}, nil, internalErr()
}
if !found || set.ID == 0 {
return domain.StickerSet{}, nil, stickersetInvalidErr()
}
if ref.Kind == domain.StickerSetRefByID && set.AccessHash != ref.AccessHash {
return domain.StickerSet{}, nil, stickersetInvalidErr()
}
return set, docs, nil
}
func userStickerSetKind(set domain.StickerSet) domain.StickerSetKind {
switch {
case set.Kind == domain.StickerSetKindMasks || set.Masks:
return domain.StickerSetKindMasks
case set.Kind == domain.StickerSetKindEmoji || set.Emojis:
return domain.StickerSetKindEmoji
default:
return domain.StickerSetKindStickers
}
}
func stickerSetKindFromFlags(masks, emojis bool) domain.StickerSetKind {
switch {
case masks:
return domain.StickerSetKindMasks
case emojis:
return domain.StickerSetKindEmoji
default:
return domain.StickerSetKindStickers
}
}
func uniqueNonZeroInt64s(in []int64, max int) []int64 {
if max <= 0 {
max = len(in)
}
out := make([]int64, 0, len(in))
seen := make(map[int64]struct{}, len(in))
for _, v := range in {
if v == 0 {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
if len(out) >= max {
break
}
}
return out
}
func (r *Router) pushStickerSetsUpdate(ctx context.Context, userID int64, kind domain.StickerSetKind) {
update := &tg.UpdateStickerSets{}
switch kind {
case domain.StickerSetKindMasks:
update.SetMasks(true)
case domain.StickerSetKindEmoji:
update.SetEmojis(true)
}
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{update},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
}
func (r *Router) pushStickerSetsOrderUpdate(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64) {
update := &tg.UpdateStickerSetsOrder{Order: append([]int64(nil), order...)}
switch kind {
case domain.StickerSetKindMasks:
update.SetMasks(true)
case domain.StickerSetKindEmoji:
update.SetEmojis(true)
}
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{update},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
}

View file

@ -0,0 +1,306 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func userStickerSetRouter(t *testing.T) (*Router, *memory.PasswordStore, *captureSessions) {
t.Helper()
files := &fakeFiles{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[domain.StickerSetKind][]domain.StickerSet{
domain.StickerSetKindStickers: {
{ID: 10, AccessHash: 100, ShortName: "funny", Title: "Funny", Kind: domain.StickerSetKindStickers, Count: 1, Hash: 7, Installed: true, InstalledDate: 1, DocumentIDs: []int64{101}},
{ID: 20, AccessHash: 200, ShortName: "work", Title: "Work", Kind: domain.StickerSetKindStickers, Count: 1, Hash: 8, Installed: true, InstalledDate: 1, DocumentIDs: []int64{102}},
},
domain.StickerSetKindEmoji: {
{ID: 30, AccessHash: 300, ShortName: "emoji_fun", Title: "Emoji Fun", Kind: domain.StickerSetKindEmoji, Emojis: true, Count: 1, Hash: 9, Installed: true, InstalledDate: 1, DocumentIDs: []int64{101}},
},
},
}
passwordStore := memory.NewPasswordStore()
sessions := &captureSessions{}
router := New(Config{}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)),
Files: files,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
return router, passwordStore, sessions
}
func TestMessagesInstallStickerSetPersistsAndPushesUpdate(t *testing.T) {
r, store, sessions := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
out, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"},
})
if err != nil {
t.Fatalf("install sticker set: %v", err)
}
if _, ok := out.(*tg.MessagesStickerSetInstallResultSuccess); !ok {
t.Fatalf("install result = %T, want *tg.MessagesStickerSetInstallResultSuccess", out)
}
items, total, err := store.ListUserStickerSets(ctx, 1000000001, domain.StickerSetKindStickers, nil, 0, 10)
if err != nil {
t.Fatalf("list installed sets: %v", err)
}
if total != 1 || len(items) != 1 || items[0].StickerSetID != 10 || items[0].Archived {
t.Fatalf("installed sets = total %d items %+v, want one active set 10", total, items)
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil)
}
func TestMessagesInstallStickerSetUpdatesAllStickersProjection(t *testing.T) {
r, _, _ := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
before, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("get all stickers before install: %v", err)
}
if full, ok := before.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 {
t.Fatalf("all stickers before install = %+v, want empty non-default catalog", full.Sets)
}
if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"},
}); err != nil {
t.Fatalf("install sticker set: %v", err)
}
after, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("get all stickers after install: %v", err)
}
full, ok := after.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("get all stickers after install = %T, want *tg.MessagesAllStickers", after)
}
if len(full.Sets) != 1 || full.Sets[0].ID != 10 || full.Sets[0].InstalledDate == 0 {
t.Fatalf("all stickers after install = %+v, want installed set 10", full.Sets)
}
if ok, err := r.onMessagesUninstallStickerSet(ctx, &tg.InputStickerSetID{ID: 10, AccessHash: 100}); err != nil || !ok {
t.Fatalf("uninstall sticker set = %v %v", ok, err)
}
afterUninstall, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("get all stickers after uninstall: %v", err)
}
if full, ok := afterUninstall.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 {
t.Fatalf("all stickers after uninstall = %+v, want empty", full.Sets)
}
}
func TestMessagesEmptyViewerStickerSetsInvalidateOldClientHash(t *testing.T) {
r, _, _ := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
stickers, err := r.onMessagesGetAllStickers(ctx, 3041827464193675523)
if err != nil {
t.Fatalf("get all stickers with old hash: %v", err)
}
full, ok := stickers.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("get all stickers with old hash = %T, want full empty list", stickers)
}
if full.Hash != 0 || len(full.Sets) != 0 {
t.Fatalf("empty sticker list = hash %d sets %+v, want hash 0 with no sets", full.Hash, full.Sets)
}
emoji, err := r.onMessagesGetEmojiStickers(ctx, 7254637046733671932)
if err != nil {
t.Fatalf("get emoji stickers with old hash: %v", err)
}
emojiFull, ok := emoji.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("get emoji stickers with old hash = %T, want full empty list", emoji)
}
if emojiFull.Hash != 0 || len(emojiFull.Sets) != 0 {
t.Fatalf("empty emoji list = hash %d sets %+v, want hash 0 with no sets", emojiFull.Hash, emojiFull.Sets)
}
}
func TestMessagesGetStickerSetUsesViewerInstallState(t *testing.T) {
r, _, _ := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
before, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"},
})
if err != nil {
t.Fatalf("get sticker set before install: %v", err)
}
full, ok := before.(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("get sticker set before install = %T, want *tg.MessagesStickerSet", before)
}
if full.Set.InstalledDate != 0 {
t.Fatalf("preview installed_date before install = %d, want 0", full.Set.InstalledDate)
}
if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"},
}); err != nil {
t.Fatalf("install sticker set: %v", err)
}
after, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "funny"},
})
if err != nil {
t.Fatalf("get sticker set after install: %v", err)
}
full, ok = after.(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("get sticker set after install = %T, want *tg.MessagesStickerSet", after)
}
if full.Set.InstalledDate == 0 {
t.Fatalf("preview installed_date after install = 0, want viewer install date")
}
}
func TestMessagesInstallStickerSetRejectsBadAccessHash(t *testing.T) {
r, store, sessions := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
out, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetID{ID: 10, AccessHash: 999},
})
if out != nil || !tgerr.Is(err, "STICKERSET_INVALID") {
t.Fatalf("install with bad access hash = %T %v, want STICKERSET_INVALID", out, err)
}
items, total, err := store.ListUserStickerSets(ctx, 1000000001, domain.StickerSetKindStickers, nil, 0, 10)
if err != nil {
t.Fatalf("list installed sets: %v", err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("installed after rejected install = total %d items %+v, want empty", total, items)
}
if push := sessions.lastUserPush(); push != nil {
t.Fatalf("push after rejected install = %T, want nil", push)
}
}
func TestMessagesReorderAndToggleStickerSets(t *testing.T) {
r, store, sessions := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
for _, shortName := range []string{"funny", "work"} {
if _, err := r.onMessagesInstallStickerSet(ctx, &tg.MessagesInstallStickerSetRequest{Stickerset: &tg.InputStickerSetShortName{ShortName: shortName}}); err != nil {
t.Fatalf("install %s: %v", shortName, err)
}
}
if ok, err := r.onMessagesReorderStickerSets(ctx, &tg.MessagesReorderStickerSetsRequest{Order: []int64{20, 10, 20, 0}}); err != nil || !ok {
t.Fatalf("reorder = %v %v", ok, err)
}
if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 2 || got[0] != 20 || got[1] != 10 {
t.Fatalf("installed order = %v, want [20 10]", got)
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, []int64{20, 10})
if ok, err := r.onMessagesToggleStickerSets(ctx, &tg.MessagesToggleStickerSetsRequest{
Stickersets: []tg.InputStickerSetClass{&tg.InputStickerSetID{ID: 20, AccessHash: 200}},
Uninstall: true,
}); err != nil || !ok {
t.Fatalf("toggle uninstall = %v %v", ok, err)
}
if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != 10 {
t.Fatalf("installed after toggle uninstall = %v, want [10]", got)
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil)
}
func TestMessagesToggleEmojiStickerSetsUsesEmojiUpdateFlag(t *testing.T) {
r, store, sessions := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
if ok, err := r.onMessagesToggleStickerSets(ctx, &tg.MessagesToggleStickerSetsRequest{
Stickersets: []tg.InputStickerSetClass{&tg.InputStickerSetShortName{ShortName: "emoji_fun"}},
}); err != nil || !ok {
t.Fatalf("toggle emoji install = %v %v", ok, err)
}
if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindEmoji, nil); len(got) != 1 || got[0] != 30 {
t.Fatalf("emoji installed sets = %v, want [30]", got)
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindEmoji, nil)
}
func TestMessagesGetMyStickersEmptyUntilCreatorStore(t *testing.T) {
r, _, _ := userStickerSetRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
out, err := r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: 50})
if err != nil {
t.Fatalf("get my stickers: %v", err)
}
if out.Count != 0 || len(out.Sets) != 0 {
t.Fatalf("get my stickers = count %d sets %d, want empty creator-owned page", out.Count, len(out.Sets))
}
out, err = r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: domain.MaxInstalledStickerSets + 1})
if out != nil || !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("get my stickers over limit = %T %v, want LIMIT_INVALID", out, err)
}
}
func installedStickerSetIDs(t *testing.T, store *memory.PasswordStore, ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool) []int64 {
t.Helper()
items, _, err := store.ListUserStickerSets(ctx, userID, kind, archived, 0, 10)
if err != nil {
t.Fatalf("list installed sticker sets: %v", err)
}
out := make([]int64, 0, len(items))
for _, item := range items {
out = append(out, item.StickerSetID)
}
return out
}
func assertStickerSetsUpdate(t *testing.T, push any, kind domain.StickerSetKind, order []int64) {
t.Helper()
updates, ok := push.(*tg.Updates)
if !ok {
t.Fatalf("push = %T, want *tg.Updates", push)
}
if len(updates.Updates) != 1 {
t.Fatalf("push updates = %d, want 1", len(updates.Updates))
}
if order != nil {
update, ok := updates.Updates[0].(*tg.UpdateStickerSetsOrder)
if !ok {
t.Fatalf("push update = %T, want *tg.UpdateStickerSetsOrder", updates.Updates[0])
}
if len(update.Order) != len(order) {
t.Fatalf("order update = %v, want %v", update.Order, order)
}
for i := range order {
if update.Order[i] != order[i] {
t.Fatalf("order update = %v, want %v", update.Order, order)
}
}
if update.Masks != (kind == domain.StickerSetKindMasks) || update.Emojis != (kind == domain.StickerSetKindEmoji) {
t.Fatalf("order flags masks=%v emojis=%v for kind %s", update.Masks, update.Emojis, kind)
}
return
}
update, ok := updates.Updates[0].(*tg.UpdateStickerSets)
if !ok {
t.Fatalf("push update = %T, want *tg.UpdateStickerSets", updates.Updates[0])
}
if update.Masks != (kind == domain.StickerSetKindMasks) || update.Emojis != (kind == domain.StickerSetKindEmoji) {
t.Fatalf("update flags masks=%v emojis=%v for kind %s", update.Masks, update.Emojis, kind)
}
}

View file

@ -169,6 +169,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router {
r.registerUpdates(d)
r.registerAccount(d)
r.registerMessages(d)
r.registerStickers(d)
r.registerChannels(d)
r.registerUpload(d)
r.registerPhotos(d)

View file

@ -3,6 +3,8 @@ package rpc
import (
"context"
"errors"
"sort"
"strings"
"testing"
"github.com/gotd/td/clock"
@ -87,6 +89,9 @@ func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Docum
func (f *fakeFiles) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) {
for _, sets := range f.sets {
for _, set := range sets {
if set.Deleted {
continue
}
match := false
switch ref.Kind {
case domain.StickerSetRefByID:
@ -114,6 +119,411 @@ func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKin
sets := f.sets[kind]
return append([]domain.StickerSet(nil), sets...), nil
}
func (f *fakeFiles) CheckStickerSetShortName(_ context.Context, shortName string) (bool, error) {
if !validTestStickerShortName(shortName) {
return false, domain.ErrStickerSetShortNameInvalid
}
for _, sets := range f.sets {
for _, set := range sets {
if set.ShortName != "" && strings.EqualFold(set.ShortName, shortName) && !set.Deleted {
return false, nil
}
}
}
return true, nil
}
func validTestStickerShortName(shortName string) bool {
shortName = strings.ToLower(strings.TrimSpace(shortName))
if len(shortName) < domain.MinStickerSetShortNameLen || len(shortName) > domain.MaxStickerSetShortNameLen {
return false
}
for i := 0; i < len(shortName); i++ {
ch := shortName[i]
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9' && i > 0) || (ch == '_' && i > 0 && i < len(shortName)-1) {
continue
}
return false
}
return true
}
func (f *fakeFiles) SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) {
base := strings.ToLower(strings.TrimSpace(title))
base = strings.ReplaceAll(base, " ", "_")
if base == "" {
base = "stickers"
}
if len(base) < domain.MinStickerSetShortNameLen {
base += "_pack"
}
if len(base) > domain.MaxStickerSetShortNameLen {
base = strings.Trim(base[:domain.MaxStickerSetShortNameLen], "_")
}
candidates := []string{base, base + "_pack", base + "_2"}
for _, c := range candidates {
if ok, err := f.CheckStickerSetShortName(ctx, c); err != nil {
continue
} else if ok {
return c, nil
}
}
return "", domain.ErrStickerSetShortNameOccupied
}
func (f *fakeFiles) CreateStickerSet(_ context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
if f.sets == nil {
f.sets = map[domain.StickerSetKind][]domain.StickerSet{}
}
if f.docs == nil {
f.docs = map[int64]domain.Document{}
}
if strings.TrimSpace(req.Title) == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetTitleInvalid
}
if len(req.Items) == 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty
}
shortName := strings.ToLower(strings.TrimSpace(req.ShortName))
if shortName == "" {
shortName = "created_pack"
}
if ok, err := f.CheckStickerSetShortName(context.Background(), shortName); err != nil {
return domain.StickerSet{}, nil, err
} else if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied
}
kind := req.Kind
if kind == "" {
kind = domain.StickerSetKindStickers
}
docIDs := make([]int64, 0, len(req.Items))
packs := []domain.StickerPack{}
keywords := []domain.StickerKeyword{}
docs := make([]domain.Document, 0, len(req.Items))
for _, item := range req.Items {
doc, ok := f.docs[item.DocumentID]
if !ok || doc.AccessHash != item.DocumentAccessHash || !doc.IsStickerSetMaterial() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if strings.TrimSpace(item.Emoji) == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmojiInvalid
}
docIDs = append(docIDs, item.DocumentID)
packs = append(packs, domain.StickerPack{Emoticon: item.Emoji, DocumentIDs: []int64{item.DocumentID}})
if item.Keywords != "" {
keywords = append(keywords, domain.StickerKeyword{DocumentID: item.DocumentID, Keywords: []string{strings.TrimSpace(item.Keywords)}})
}
doc.Attributes = []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: item.Emoji, StickerSetID: 9000, StickerSetAccessHash: 9001}}
if kind == domain.StickerSetKindEmoji {
doc.Attributes[0].Kind = domain.DocAttrCustomEmoji
doc.Attributes[0].TextColor = req.TextColor
}
f.docs[item.DocumentID] = doc
docs = append(docs, doc)
}
set := domain.StickerSet{
ID: 9000 + int64(len(f.sets[kind])),
AccessHash: 9001 + int64(len(f.sets[kind])),
ShortName: shortName,
Title: req.Title,
Kind: kind,
Emojis: kind == domain.StickerSetKindEmoji,
Masks: kind == domain.StickerSetKindMasks,
TextColor: kind == domain.StickerSetKindEmoji && req.TextColor,
Creator: true,
CreatorUserID: req.CreatorUserID,
Count: len(docIDs),
Hash: 77 + len(f.sets[kind]),
DocumentIDs: docIDs,
Packs: packs,
Keywords: keywords,
}
f.sets[kind] = append(f.sets[kind], set)
return set, docs, nil
}
func (f *fakeFiles) ListCreatedStickerSets(_ context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
var all []domain.StickerSet
for _, sets := range f.sets {
for _, set := range sets {
if set.CreatorUserID == userID && !set.Deleted {
set.Creator = true
all = append(all, set)
}
}
}
sort.Slice(all, func(i, j int) bool { return all[i].ID > all[j].ID })
total := len(all)
if offsetID != 0 {
filtered := all[:0]
for _, set := range all {
if set.ID < offsetID {
filtered = append(filtered, set)
}
}
all = filtered
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, total, nil
}
func (f *fakeFiles) AddStickerToSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error) {
kind, idx, ok := f.fakeStickerSetIndex(ref)
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set := f.sets[kind][idx]
if set.CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned
}
doc, ok := f.docs[item.DocumentID]
if !ok || doc.AccessHash != item.DocumentAccessHash || !doc.IsStickerSetMaterial() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if setID, _, ok := doc.StickerSetRef(); ok && setID != 0 && setID != set.ID {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if fakeContainsInt64(set.DocumentIDs, doc.ID) {
return set, f.fakeStickerSetDocs(set), nil
}
emoji := strings.TrimSpace(item.Emoji)
if emoji == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmojiInvalid
}
doc = fakeAttachStickerSet(doc, set, emoji)
f.docs[doc.ID] = doc
set.DocumentIDs = append(set.DocumentIDs, doc.ID)
set.Count = len(set.DocumentIDs)
set.Packs = fakeAddStickerPackDoc(set.Packs, emoji, doc.ID)
if kw := strings.TrimSpace(item.Keywords); kw != "" {
set.Keywords = fakeUpsertStickerKeyword(set.Keywords, domain.StickerKeyword{DocumentID: doc.ID, Keywords: []string{kw}})
}
set.Hash++
f.sets[kind][idx] = set
return set, f.fakeStickerSetDocs(set), nil
}
func (f *fakeFiles) RemoveStickerFromSet(_ context.Context, actorUserID int64, documentID int64, accessHash int64) (domain.StickerSet, []domain.Document, error) {
doc, ok := f.docs[documentID]
if !ok || doc.AccessHash != accessHash || !doc.IsStickerLike() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
setID, setAccessHash, ok := doc.StickerSetRef()
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
kind, idx, ok := f.fakeStickerSetIndex(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash})
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set := f.sets[kind][idx]
if set.CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned
}
pos := fakeIndexInt64(set.DocumentIDs, documentID)
if pos < 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
set.DocumentIDs = append(append([]int64(nil), set.DocumentIDs[:pos]...), set.DocumentIDs[pos+1:]...)
set.Count = len(set.DocumentIDs)
set.Packs = fakeRemoveStickerPackDoc(set.Packs, documentID)
set.Keywords = fakeRemoveStickerKeyword(set.Keywords, documentID)
set.Hash++
doc = fakeDetachStickerSet(doc)
f.docs[doc.ID] = doc
f.sets[kind][idx] = set
return set, f.fakeStickerSetDocs(set), nil
}
func (f *fakeFiles) ChangeStickerPosition(_ context.Context, actorUserID int64, documentID int64, accessHash int64, position int) (domain.StickerSet, []domain.Document, error) {
doc, ok := f.docs[documentID]
if !ok || doc.AccessHash != accessHash || !doc.IsStickerLike() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
setID, setAccessHash, ok := doc.StickerSetRef()
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
kind, idx, ok := f.fakeStickerSetIndex(domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID, AccessHash: setAccessHash})
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set := f.sets[kind][idx]
if set.CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned
}
from := fakeIndexInt64(set.DocumentIDs, documentID)
if from < 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if position < 0 || position >= len(set.DocumentIDs) {
return domain.StickerSet{}, nil, domain.ErrStickerSetPositionInvalid
}
set.DocumentIDs = fakeMoveInt64(set.DocumentIDs, from, position)
set.Hash++
f.sets[kind][idx] = set
return set, f.fakeStickerSetDocs(set), nil
}
func (f *fakeFiles) RenameStickerSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef, title string) (domain.StickerSet, []domain.Document, error) {
kind, idx, ok := f.fakeStickerSetIndex(ref)
if !ok {
return domain.StickerSet{}, nil, domain.ErrStickerSetInvalid
}
set := f.sets[kind][idx]
if set.CreatorUserID != actorUserID {
return domain.StickerSet{}, nil, domain.ErrStickerSetNotOwned
}
title = strings.TrimSpace(title)
if title == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetTitleInvalid
}
set.Title = title
set.Hash++
f.sets[kind][idx] = set
return set, f.fakeStickerSetDocs(set), nil
}
func (f *fakeFiles) DeleteStickerSet(_ context.Context, actorUserID int64, ref domain.StickerSetRef) (domain.StickerSetKind, error) {
kind, idx, ok := f.fakeStickerSetIndex(ref)
if !ok {
return "", domain.ErrStickerSetInvalid
}
set := f.sets[kind][idx]
if set.CreatorUserID != actorUserID {
return "", domain.ErrStickerSetNotOwned
}
set.Deleted = true
f.sets[kind][idx] = set
return kind, nil
}
func (f *fakeFiles) fakeStickerSetIndex(ref domain.StickerSetRef) (domain.StickerSetKind, int, bool) {
for kind, sets := range f.sets {
for idx, set := range sets {
if set.Deleted {
continue
}
switch ref.Kind {
case domain.StickerSetRefByID:
if set.ID == ref.ID && (ref.AccessHash == 0 || set.AccessHash == ref.AccessHash) {
return kind, idx, true
}
case domain.StickerSetRefByShortName:
if strings.EqualFold(set.ShortName, ref.ShortName) {
return kind, idx, true
}
case domain.StickerSetRefBySystem:
if set.SystemKey == ref.SystemKey {
return kind, idx, true
}
}
}
}
return "", 0, false
}
func (f *fakeFiles) fakeStickerSetDocs(set domain.StickerSet) []domain.Document {
out := make([]domain.Document, 0, len(set.DocumentIDs))
for _, id := range set.DocumentIDs {
if doc, ok := f.docs[id]; ok {
out = append(out, doc)
}
}
return out
}
func fakeAttachStickerSet(doc domain.Document, set domain.StickerSet, emoji string) domain.Document {
want := domain.DocAttrSticker
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
want = domain.DocAttrCustomEmoji
}
attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...)
replaced := false
for i := range attrs {
if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji {
continue
}
attrs[i].Kind = want
attrs[i].Alt = emoji
attrs[i].StickerSetID = set.ID
attrs[i].StickerSetAccessHash = set.AccessHash
attrs[i].TextColor = set.TextColor
replaced = true
break
}
if !replaced {
attrs = append(attrs, domain.DocumentAttribute{Kind: want, Alt: emoji, StickerSetID: set.ID, StickerSetAccessHash: set.AccessHash, TextColor: set.TextColor})
}
doc.Attributes = attrs
return doc
}
func fakeDetachStickerSet(doc domain.Document) domain.Document {
attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...)
for i := range attrs {
if attrs[i].Kind == domain.DocAttrSticker || attrs[i].Kind == domain.DocAttrCustomEmoji {
attrs[i].StickerSetID = 0
attrs[i].StickerSetAccessHash = 0
attrs[i].TextColor = false
break
}
}
doc.Attributes = attrs
return doc
}
func fakeAddStickerPackDoc(packs []domain.StickerPack, emoji string, documentID int64) []domain.StickerPack {
out := append([]domain.StickerPack(nil), packs...)
for i := range out {
out[i].DocumentIDs = append([]int64(nil), out[i].DocumentIDs...)
if out[i].Emoticon == emoji {
if !fakeContainsInt64(out[i].DocumentIDs, documentID) {
out[i].DocumentIDs = append(out[i].DocumentIDs, documentID)
}
return out
}
}
return append(out, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{documentID}})
}
func fakeRemoveStickerPackDoc(packs []domain.StickerPack, documentID int64) []domain.StickerPack {
out := make([]domain.StickerPack, 0, len(packs))
for _, pack := range packs {
ids := make([]int64, 0, len(pack.DocumentIDs))
for _, id := range pack.DocumentIDs {
if id != documentID {
ids = append(ids, id)
}
}
if len(ids) != 0 {
out = append(out, domain.StickerPack{Emoticon: pack.Emoticon, DocumentIDs: ids})
}
}
return out
}
func fakeUpsertStickerKeyword(in []domain.StickerKeyword, keyword domain.StickerKeyword) []domain.StickerKeyword {
out := fakeRemoveStickerKeyword(in, keyword.DocumentID)
return append(out, keyword)
}
func fakeRemoveStickerKeyword(in []domain.StickerKeyword, documentID int64) []domain.StickerKeyword {
out := make([]domain.StickerKeyword, 0, len(in))
for _, kw := range in {
if kw.DocumentID != documentID {
out = append(out, kw)
}
}
return out
}
func fakeContainsInt64(in []int64, value int64) bool {
return fakeIndexInt64(in, value) >= 0
}
func fakeIndexInt64(in []int64, value int64) int {
for i, v := range in {
if v == value {
return i
}
}
return -1
}
func fakeMoveInt64(in []int64, from, to int) []int64 {
out := append([]int64(nil), in...)
value := out[from]
out = append(out[:from], out[from+1:]...)
if to >= len(out) {
return append(out, value)
}
out = append(out[:to], append([]int64{value}, out[to:]...)...)
return out
}
func (f *fakeFiles) CreatePhotoFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) {
photo := domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}}
return f.putPhoto(photo), nil

View file

@ -52,3 +52,10 @@ func (r *Router) stickerCatalogSets(ctx context.Context, kind domain.StickerSetK
}
return sets
}
func (r *Router) invalidateStickerCatalog(kind domain.StickerSetKind) {
if r.stickerCatalog == nil || r.stickerCatalog.cache == nil {
return
}
r.stickerCatalog.cache.Invalidate(kind)
}

View file

@ -0,0 +1,330 @@
package rpc
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
botsapp "telesrv/internal/app/bots"
appmessages "telesrv/internal/app/messages"
apppolls "telesrv/internal/app/polls"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"telesrv/internal/web/stickerlinks"
)
func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
alice, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"})
bob, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Bob"})
dialogStore := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogStore)
pollStore := memory.NewPollStore()
messageStore.AttachPollStore(pollStore)
passwordStore := memory.NewPasswordStore()
files := &fakeFiles{
docs: map[int64]domain.Document{
101: {
ID: 101,
AccessHash: 1101,
DCID: 2,
MimeType: "image/webp",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
},
},
photos: map[int64]domain.Photo{},
sets: map[domain.StickerSetKind][]domain.StickerSet{},
}
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)),
Users: appusers.NewService(userStore),
Messages: appmessages.NewService(messageStore, dialogStore),
Files: files,
Polls: apppolls.NewService(pollStore),
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
created, err := r.onStickersCreateStickerSet(WithUserID(ctx, alice.ID), &tg.StickersCreateStickerSetRequest{
UserID: &tg.InputUserSelf{},
Title: "Alice Fresh Pack",
ShortName: "alice_fresh_pack",
Stickers: []tg.InputStickerSetItem{{
Document: &tg.InputDocument{ID: 101, AccessHash: 1101},
Emoji: "🙂",
Keywords: "fresh",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
createdFull, ok := created.(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("created = %T, want *tg.MessagesStickerSet", created)
}
web := stickerlinks.NewHandler(files, "https://telesrv.net")
rr := httptest.NewRecorder()
web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_fresh_pack", nil))
if rr.Code != http.StatusOK {
t.Fatalf("sticker link status = %d body=%q, want 200", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{"https://telesrv.net/addstickers/alice_fresh_pack", "telesrv://addstickers?set=alice_fresh_pack", "tg://addstickers?set=alice_fresh_pack"} {
if !strings.Contains(body, want) {
t.Fatalf("sticker link body missing %q:\n%s", want, body)
}
}
if strings.Contains(body, `window.location.href = "tg://`) {
t.Fatalf("sticker link must auto-open telesrv://, not tg://:\n%s", body)
}
preview, err := r.onMessagesGetStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_fresh_pack"},
Hash: 0,
})
if err != nil {
t.Fatalf("bob preview sticker set: %v", err)
}
previewFull, ok := preview.(*tg.MessagesStickerSet)
if !ok || previewFull.Set.ID != createdFull.Set.ID || len(previewFull.Documents) != 1 {
t.Fatalf("preview = %T %+v, want created set with one document", preview, preview)
}
if _, err := r.onMessagesInstallStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_fresh_pack"},
}); err != nil {
t.Fatalf("bob install sticker set: %v", err)
}
if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != createdFull.Set.ID {
t.Fatalf("bob installed sets = %v, want [%d]", got, createdFull.Set.ID)
}
if _, err := r.onMessagesSendMedia(WithUserID(ctx, bob.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: 101, AccessHash: 1101}},
RandomID: 7001,
}); err != nil {
t.Fatalf("bob send sticker: %v", err)
}
historyReq := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Limit: 10,
}
var raw bin.Buffer
if err := historyReq.Encode(&raw); err != nil {
t.Fatalf("encode history request: %v", err)
}
enc, err := r.Dispatch(WithUserID(ctx, alice.ID), [8]byte{}, 0, &raw)
if err != nil {
t.Fatalf("alice get history: %v", err)
}
box, ok := enc.(*tg.MessagesMessagesBox)
if !ok {
t.Fatalf("history response = %T, want *tg.MessagesMessagesBox", enc)
}
messages, ok := box.Messages.(*tg.MessagesMessages)
if !ok {
t.Fatalf("history payload = %T, want *tg.MessagesMessages", box.Messages)
}
if len(messages.Messages) != 1 {
t.Fatalf("history messages = %d, want 1", len(messages.Messages))
}
msg, ok := messages.Messages[0].(*tg.Message)
if !ok {
t.Fatalf("history message = %T, want *tg.Message", messages.Messages[0])
}
media, ok := msg.Media.(*tg.MessageMediaDocument)
if !ok {
t.Fatalf("history media = %T, want *tg.MessageMediaDocument", msg.Media)
}
if got := tgDocumentID(media.Document); got != 101 {
t.Fatalf("history document id = %d, want 101", got)
}
}
func TestStickersBotCreatePackLinkInstallIsolationSmoke(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
alice, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Alice"})
bob, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009102", FirstName: "Bob"})
dialogStore := memory.NewDialogStore()
messageStore := memory.NewMessageStore(dialogStore)
pollStore := memory.NewPollStore()
messageStore.AttachPollStore(pollStore)
passwordStore := memory.NewPasswordStore()
accountService := appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore))
botStore := memory.NewBotStore(userStore)
files := &fakeFiles{
docs: map[int64]domain.Document{
401: {
ID: 401,
AccessHash: 4401,
DCID: 2,
MimeType: "image/webp",
Size: 4096,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "alice.webp"}},
},
},
photos: map[int64]domain.Photo{},
sets: map[domain.StickerSetKind][]domain.StickerSet{},
}
botsService := botsapp.NewService(userStore, botStore, messageStore,
botsapp.WithStickerSetCreator(files),
botsapp.WithUserStickerSets(accountService))
messagesService := appmessages.NewService(messageStore, dialogStore,
appmessages.WithBotResponder(botsService))
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
Account: accountService,
Users: appusers.NewService(userStore),
Messages: messagesService,
Files: files,
Polls: apppolls.NewService(pollStore),
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
botsService.SetRouterHooks(r)
sendStickersBotText(t, r, alice, "/newpack", 9101)
waitForStickersReply(t, messageStore, alice.ID, "sticker pack")
sendStickersBotText(t, r, alice, "Alice Bot Pack", 9102)
waitForStickersReply(t, messageStore, alice.ID, "Lottie JSON")
sendStickersBotDocument(t, r, alice, 401, 4401, 9103)
waitForStickersReply(t, messageStore, alice.ID, "emoji")
sendStickersBotText(t, r, alice, "🙂", 9104)
waitForStickersReply(t, messageStore, alice.ID, "Added")
sendStickersBotText(t, r, alice, "/publish", 9105)
waitForStickersReply(t, messageStore, alice.ID, "short name")
sendStickersBotText(t, r, alice, "alice_bot_pack", 9106)
waitForStickersReply(t, messageStore, alice.ID, "https://telesrv.net/addstickers/alice_bot_pack")
created := files.sets[domain.StickerSetKindStickers]
if len(created) != 1 || created[0].ShortName != "alice_bot_pack" || created[0].CreatorUserID != alice.ID {
t.Fatalf("created sets = %+v, want Alice alice_bot_pack", created)
}
setID := created[0].ID
if got := installedStickerSetIDs(t, passwordStore, ctx, alice.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != setID {
t.Fatalf("alice installed sets = %v, want [%d]", got, setID)
}
if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 0 {
t.Fatalf("bob installed sets before link = %v, want empty", got)
}
if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindStickers); len(got) != 0 {
t.Fatalf("bob getAllStickers before install = %v, want empty", got)
}
web := stickerlinks.NewHandler(files, "https://telesrv.net")
rr := httptest.NewRecorder()
web.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/addstickers/alice_bot_pack", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "https://telesrv.net/addstickers/alice_bot_pack") {
t.Fatalf("sticker bot link response = %d %q", rr.Code, rr.Body.String())
}
preview, err := r.onMessagesGetStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_bot_pack"},
})
if err != nil {
t.Fatalf("bob preview bot-created sticker set: %v", err)
}
previewFull, ok := preview.(*tg.MessagesStickerSet)
if !ok || previewFull.Set.ID != setID || previewFull.Set.InstalledDate != 0 {
t.Fatalf("bob preview = %T %+v, want uninstalled created set", preview, preview)
}
if _, err := r.onMessagesInstallStickerSet(WithUserID(ctx, bob.ID), &tg.MessagesInstallStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "alice_bot_pack"},
}); err != nil {
t.Fatalf("bob install bot-created sticker set: %v", err)
}
if got := installedStickerSetIDs(t, passwordStore, ctx, bob.ID, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != setID {
t.Fatalf("bob installed sets after link = %v, want [%d]", got, setID)
}
if got := allStickerSetIDs(t, r, WithUserID(ctx, alice.ID), domain.StickerSetKindStickers); len(got) != 1 || got[0] != setID {
t.Fatalf("alice getAllStickers = %v, want [%d]", got, setID)
}
if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindStickers); len(got) != 1 || got[0] != setID {
t.Fatalf("bob getAllStickers after install = %v, want [%d]", got, setID)
}
if got := allStickerSetIDs(t, r, WithUserID(ctx, bob.ID), domain.StickerSetKindEmoji); len(got) != 0 {
t.Fatalf("bob getEmojiStickers = %v, want empty", got)
}
}
func sendStickersBotText(t *testing.T, r *Router, user domain.User, text string, randomID int64) {
t.Helper()
if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), user.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: domain.StickersBotUserID, AccessHash: domain.StickersBotAccessHash},
Message: text,
RandomID: randomID,
}); err != nil {
t.Fatalf("send @Stickers text %q: %v", text, err)
}
}
func sendStickersBotDocument(t *testing.T, r *Router, user domain.User, docID, accessHash, randomID int64) {
t.Helper()
if _, err := r.onMessagesSendMedia(WithUserID(context.Background(), user.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: domain.StickersBotUserID, AccessHash: domain.StickersBotAccessHash},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{ID: docID, AccessHash: accessHash}},
RandomID: randomID,
}); err != nil {
t.Fatalf("send @Stickers document %d: %v", docID, err)
}
}
func waitForStickersReply(t *testing.T, messages *memory.MessageStore, userID int64, want string) string {
t.Helper()
deadline := time.Now().Add(time.Second)
for {
list, err := messages.ListByUser(context.Background(), userID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.StickersBotUserID},
Limit: 100,
})
if err != nil {
t.Fatalf("list @Stickers history: %v", err)
}
for _, msg := range list.Messages {
if msg.From.ID == domain.StickersBotUserID && strings.Contains(msg.Body, want) {
return msg.Body
}
}
if time.Now().After(deadline) {
t.Fatalf("no @Stickers reply containing %q; history=%+v", want, list.Messages)
}
time.Sleep(5 * time.Millisecond)
}
}
func allStickerSetIDs(t *testing.T, r *Router, ctx context.Context, kind domain.StickerSetKind) []int64 {
t.Helper()
var (
out tg.MessagesAllStickersClass
err error
)
if kind == domain.StickerSetKindEmoji {
out, err = r.onMessagesGetEmojiStickers(ctx, 0)
} else {
out, err = r.onMessagesGetAllStickers(ctx, 0)
}
if err != nil {
t.Fatalf("get sticker sets for kind %s: %v", kind, err)
}
full, ok := out.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("get sticker sets for kind %s = %T, want *tg.MessagesAllStickers", kind, out)
}
ids := make([]int64, 0, len(full.Sets))
for _, set := range full.Sets {
ids = append(ids, set.ID)
}
return ids
}

View file

@ -90,6 +90,10 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe
if fallbackSet, fallbackDocs, fallbackFound, fallbackErr := r.resolvePlaceholderStickerSet(ctx, ref); fallbackErr != nil {
return nil, internalErr()
} else if fallbackFound {
fallbackSet, fallbackErr = r.stickerSetWithViewerInstallState(ctx, fallbackSet)
if fallbackErr != nil {
return nil, fallbackErr
}
if r.log != nil {
r.log.Debug("getStickerSet placeholder fallback",
zap.String("short_name", ref.ShortName),
@ -109,6 +113,10 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe
if req.Hash != 0 && req.Hash == set.Hash {
return &tg.MessagesStickerSetNotModified{}, nil
}
set, err = r.stickerSetWithViewerInstallState(ctx, set)
if err != nil {
return nil, err
}
return tgMessagesStickerSet(set, docs), nil
}
@ -191,8 +199,14 @@ func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain
if r.deps.Files == nil {
return messagesAllStickersEmpty(hash), nil
}
// perf从目录缓存读集TTL 内 hash 命中不打 PG
sets := r.stickerCatalogSets(ctx, kind)
sets, handled, err := r.installedStickerSetsForViewer(ctx, kind)
if err != nil {
return nil, err
}
if !handled {
// 兼容无 per-user 安装态的测试/旧内存路径:从目录缓存读全局 installed 标志。
sets = installedGlobalStickerSets(r.stickerCatalogSets(ctx, kind))
}
if len(sets) == 0 {
return messagesAllStickersEmpty(hash), nil
}
@ -203,6 +217,107 @@ func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain
return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil
}
func (r *Router) installedStickerSetsForViewer(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, bool, error) {
svc, ok := r.userStickerSetSvc()
if !ok {
return nil, false, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil || userID == 0 {
if err != nil {
return nil, true, internalErr()
}
return nil, true, nil
}
userSets, _, err := svc.ListUserStickerSets(ctx, userID, kind, nil, 0, domain.MaxInstalledStickerSets)
if err != nil {
return nil, true, internalErr()
}
out := make([]domain.StickerSet, 0, len(userSets))
for _, item := range userSets {
if item.Archived || item.StickerSetID == 0 {
continue
}
set, _, found, err := r.deps.Files.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: item.StickerSetID})
if err != nil {
return nil, true, internalErr()
}
if !found || set.ID == 0 || set.Deleted || userStickerSetKind(set) != kind {
continue
}
set = stickerSetWithoutViewerInstallState(set)
out = append(out, stickerSetWithViewerInstallItem(set, item))
}
return out, true, nil
}
func (r *Router) stickerSetsWithViewerInstallState(ctx context.Context, kind domain.StickerSetKind, sets []domain.StickerSet) ([]domain.StickerSet, error) {
out := make([]domain.StickerSet, 0, len(sets))
byID := make(map[int64]int, len(sets))
for _, set := range sets {
set = stickerSetWithoutViewerInstallState(set)
byID[set.ID] = len(out)
out = append(out, set)
}
svc, ok := r.userStickerSetSvc()
if !ok {
return out, nil
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return out, nil
}
userSets, _, err := svc.ListUserStickerSets(ctx, userID, kind, nil, 0, domain.MaxInstalledStickerSets)
if err != nil {
return nil, internalErr()
}
for _, item := range userSets {
i, ok := byID[item.StickerSetID]
if !ok {
continue
}
out[i] = stickerSetWithViewerInstallItem(out[i], item)
}
return out, nil
}
func (r *Router) stickerSetWithViewerInstallState(ctx context.Context, set domain.StickerSet) (domain.StickerSet, error) {
sets, err := r.stickerSetsWithViewerInstallState(ctx, userStickerSetKind(set), []domain.StickerSet{set})
if err != nil {
return domain.StickerSet{}, err
}
if len(sets) == 0 {
return stickerSetWithoutViewerInstallState(set), nil
}
return sets[0], nil
}
func stickerSetWithoutViewerInstallState(set domain.StickerSet) domain.StickerSet {
set.Installed = false
set.InstalledDate = 0
return set
}
func stickerSetWithViewerInstallItem(set domain.StickerSet, item domain.UserStickerSet) domain.StickerSet {
set.Installed = true
set.Archived = item.Archived
set.InstalledDate = item.InstalledDate
return set
}
func installedGlobalStickerSets(sets []domain.StickerSet) []domain.StickerSet {
out := make([]domain.StickerSet, 0, len(sets))
for _, set := range sets {
if set.Installed && !set.Archived {
out = append(out, set)
}
}
return out
}
// featuredCoversPerSet 限制每个 featured 集解析的封面贴纸数量trending 预览用)。
const featuredCoversPerSet = 5
@ -233,7 +348,12 @@ func (r *Router) featuredStickersForKind(ctx context.Context, hash int64, kind d
if len(visible) == 0 {
return messagesFeaturedStickersEmpty(hash), nil
}
catalogHash := stickerSetsCatalogHash(visible)
var err error
visible, err = r.stickerSetsWithViewerInstallState(ctx, kind, visible)
if err != nil {
return nil, err
}
catalogHash := featuredStickerSetsHash(visible)
if hash != 0 && hash == catalogHash {
// 关键 perf 短路:目录未变直接返回,不解析任何封面文档。
return &tg.MessagesFeaturedStickersNotModified{Count: len(visible)}, nil
@ -333,6 +453,20 @@ func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {
return int64(tdesktopCountHash(values))
}
func featuredStickerSetsHash(sets []domain.StickerSet) int64 {
values := make([]int64, 0, len(sets))
for _, set := range sets {
if set.ID == 0 {
return 0
}
if set.Archived {
continue
}
values = append(values, set.ID)
}
return int64(tdesktopCountHash(values))
}
func boolHashValue(v bool) int64 {
if v {
return 1

View file

@ -0,0 +1,351 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) registerStickers(d *tg.ServerDispatcher) {
d.OnStickersCreateStickerSet(r.onStickersCreateStickerSet)
d.OnStickersCheckShortName(r.onStickersCheckShortName)
d.OnStickersSuggestShortName(r.onStickersSuggestShortName)
d.OnStickersAddStickerToSet(r.onStickersAddStickerToSet)
d.OnStickersRemoveStickerFromSet(r.onStickersRemoveStickerFromSet)
d.OnStickersChangeStickerPosition(r.onStickersChangeStickerPosition)
d.OnStickersRenameStickerSet(r.onStickersRenameStickerSet)
d.OnStickersDeleteStickerSet(r.onStickersDeleteStickerSet)
}
func (r *Router) onStickersCreateStickerSet(ctx context.Context, req *tg.StickersCreateStickerSetRequest) (tg.MessagesStickerSetClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
if r.deps.Files == nil {
return nil, internalErr()
}
if req.Masks && req.Emojis {
return nil, packTypeInvalidErr()
}
userID, err := r.stickerSetCreatorUserID(ctx, req.UserID)
if err != nil {
return nil, err
}
items := make([]domain.StickerSetItemInput, 0, len(req.Stickers))
for _, item := range req.Stickers {
id, accessHash, ok := inputDocumentRef(item.Document)
if !ok {
return nil, stickerFileInvalidErr()
}
items = append(items, domain.StickerSetItemInput{
DocumentID: id,
DocumentAccessHash: accessHash,
Emoji: item.Emoji,
Keywords: item.Keywords,
})
}
thumbID, thumbAccessHash, ok := inputDocumentRef(req.Thumb)
if req.Thumb != nil && !ok {
return nil, stickerFileInvalidErr()
}
kind := domain.StickerSetKindStickers
if req.Emojis {
kind = domain.StickerSetKindEmoji
} else if req.Masks {
kind = domain.StickerSetKindMasks
}
set, docs, err := r.deps.Files.CreateStickerSet(ctx, domain.CreateStickerSetRequest{
CreatorUserID: userID,
Title: req.Title,
ShortName: req.ShortName,
Kind: kind,
TextColor: req.TextColor,
ThumbDocumentID: thumbID,
ThumbAccessHash: thumbAccessHash,
Items: items,
Software: req.Software,
Date: int(r.clock.Now().Unix()),
})
if err != nil {
return nil, stickerSetCreateErr(err)
}
if svc, ok := r.userStickerSetSvc(); ok {
if err := svc.InstallUserStickerSet(ctx, userID, set.ID, userStickerSetKind(set), false, int(r.clock.Now().Unix())); err != nil {
return nil, internalErr()
}
set.Installed = true
set.InstalledDate = int(r.clock.Now().Unix())
}
r.invalidateStickerCatalog(userStickerSetKind(set))
r.pushStickerSetsUpdate(ctx, userID, userStickerSetKind(set))
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onStickersCheckShortName(ctx context.Context, shortName string) (bool, error) {
if r.deps.Files == nil {
return false, internalErr()
}
if _, _, err := r.currentUserID(ctx); err != nil {
return false, internalErr()
}
available, err := r.deps.Files.CheckStickerSetShortName(ctx, shortName)
if err != nil {
return false, stickerSetShortNameCheckErr(err)
}
return available, nil
}
func (r *Router) onStickersSuggestShortName(ctx context.Context, title string) (*tg.StickersSuggestedShortName, error) {
if r.deps.Files == nil {
return nil, internalErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
shortName, err := r.deps.Files.SuggestStickerSetShortName(ctx, title, userID)
if err != nil {
return nil, stickerSetSuggestShortNameErr(err)
}
return &tg.StickersSuggestedShortName{ShortName: shortName}, nil
}
func (r *Router) onStickersAddStickerToSet(ctx context.Context, req *tg.StickersAddStickerToSetRequest) (tg.MessagesStickerSetClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, err := r.stickerSetActorUserID(ctx)
if err != nil {
return nil, err
}
ref, ok := stickerSetRefFromInput(req.Stickerset)
if !ok {
return nil, stickersetInvalidErr()
}
documentID, accessHash, ok := inputDocumentRef(req.Sticker.Document)
if !ok {
return nil, stickerFileInvalidErr()
}
set, docs, err := r.deps.Files.AddStickerToSet(ctx, userID, ref, domain.StickerSetItemInput{
DocumentID: documentID,
DocumentAccessHash: accessHash,
Emoji: req.Sticker.Emoji,
Keywords: req.Sticker.Keywords,
})
if err != nil {
return nil, stickerSetManagementErr(err)
}
r.notifyStickerSetMutated(ctx, userID, set)
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onStickersRemoveStickerFromSet(ctx context.Context, input tg.InputDocumentClass) (tg.MessagesStickerSetClass, error) {
userID, err := r.stickerSetActorUserID(ctx)
if err != nil {
return nil, err
}
documentID, accessHash, ok := inputDocumentRef(input)
if !ok {
return nil, stickerFileInvalidErr()
}
set, docs, err := r.deps.Files.RemoveStickerFromSet(ctx, userID, documentID, accessHash)
if err != nil {
return nil, stickerSetManagementErr(err)
}
r.notifyStickerSetMutated(ctx, userID, set)
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onStickersChangeStickerPosition(ctx context.Context, req *tg.StickersChangeStickerPositionRequest) (tg.MessagesStickerSetClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, err := r.stickerSetActorUserID(ctx)
if err != nil {
return nil, err
}
documentID, accessHash, ok := inputDocumentRef(req.Sticker)
if !ok {
return nil, stickerFileInvalidErr()
}
set, docs, err := r.deps.Files.ChangeStickerPosition(ctx, userID, documentID, accessHash, req.Position)
if err != nil {
return nil, stickerSetManagementErr(err)
}
r.notifyStickerSetMutated(ctx, userID, set)
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onStickersRenameStickerSet(ctx context.Context, req *tg.StickersRenameStickerSetRequest) (tg.MessagesStickerSetClass, error) {
if req == nil {
return nil, inputRequestInvalidErr()
}
userID, err := r.stickerSetActorUserID(ctx)
if err != nil {
return nil, err
}
ref, ok := stickerSetRefFromInput(req.Stickerset)
if !ok {
return nil, stickersetInvalidErr()
}
set, docs, err := r.deps.Files.RenameStickerSet(ctx, userID, ref, req.Title)
if err != nil {
return nil, stickerSetManagementErr(err)
}
r.notifyStickerSetMutated(ctx, userID, set)
return tgMessagesStickerSet(set, docs), nil
}
func (r *Router) onStickersDeleteStickerSet(ctx context.Context, input tg.InputStickerSetClass) (bool, error) {
userID, err := r.stickerSetActorUserID(ctx)
if err != nil {
return false, err
}
ref, ok := stickerSetRefFromInput(input)
if !ok {
return false, stickersetInvalidErr()
}
kind, err := r.deps.Files.DeleteStickerSet(ctx, userID, ref)
if err != nil {
return false, stickerSetManagementErr(err)
}
r.invalidateStickerCatalog(kind)
r.pushStickerSetsUpdate(ctx, userID, kind)
return true, nil
}
func (r *Router) stickerSetActorUserID(ctx context.Context) (int64, error) {
if r.deps.Files == nil {
return 0, internalErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, internalErr()
}
return userID, nil
}
func (r *Router) notifyStickerSetMutated(ctx context.Context, userID int64, set domain.StickerSet) {
kind := userStickerSetKind(set)
r.invalidateStickerCatalog(kind)
r.pushStickerSetsUpdate(ctx, userID, kind)
}
func (r *Router) stickerSetCreatorUserID(ctx context.Context, input tg.InputUserClass) (int64, error) {
currentUserID, _, err := r.currentUserID(ctx)
if err != nil {
return 0, internalErr()
}
if r.deps.Users == nil {
switch v := input.(type) {
case *tg.InputUserSelf:
return currentUserID, nil
case *tg.InputUser:
if v != nil && v.UserID == currentUserID {
return currentUserID, nil
}
}
return 0, userIDInvalidErr()
}
user, found, err := r.userFromInput(ctx, currentUserID, input)
if err != nil {
return 0, internalErr()
}
if !found || user.ID != currentUserID {
return 0, userIDInvalidErr()
}
return currentUserID, nil
}
func inputDocumentRef(input tg.InputDocumentClass) (int64, int64, bool) {
doc, ok := input.(*tg.InputDocument)
if !ok || doc == nil || doc.ID == 0 || doc.AccessHash == 0 {
return 0, 0, false
}
return doc.ID, doc.AccessHash, true
}
func packShortNameInvalidErr() error { return tgerr400("PACK_SHORT_NAME_INVALID") }
func packShortNameOccupiedErr() error { return tgerr400("PACK_SHORT_NAME_OCCUPIED") }
func packTitleInvalidErr() error { return tgerr400("PACK_TITLE_INVALID") }
func packTypeInvalidErr() error { return tgerr400("PACK_TYPE_INVALID") }
func stickersEmptyErr() error { return tgerr400("STICKERS_EMPTY") }
func stickersTooMuchErr() error { return tgerr400("STICKERS_TOO_MUCH") }
func stickerEmojiInvalidErr() error { return tgerr400("STICKER_EMOJI_INVALID") }
func stickerFileInvalidErr() error { return tgerr400("STICKER_FILE_INVALID") }
func shortNameInvalidErr() error { return tgerr400("SHORT_NAME_INVALID") }
func titleInvalidErr() error { return tgerr400("TITLE_INVALID") }
func positionInvalidErr() error { return tgerr400("POSITION_INVALID") }
func stickerSetCreateErr(err error) error {
switch {
case errors.Is(err, domain.ErrStickerSetTitleInvalid):
return packTitleInvalidErr()
case errors.Is(err, domain.ErrStickerSetShortNameInvalid):
return packShortNameInvalidErr()
case errors.Is(err, domain.ErrStickerSetShortNameOccupied):
return packShortNameOccupiedErr()
case errors.Is(err, domain.ErrStickerSetTypeInvalid):
return packTypeInvalidErr()
case errors.Is(err, domain.ErrStickerSetEmpty):
return stickersEmptyErr()
case errors.Is(err, domain.ErrStickerSetTooMuch):
return stickersTooMuchErr()
case errors.Is(err, domain.ErrStickerSetEmojiInvalid):
return stickerEmojiInvalidErr()
case errors.Is(err, domain.ErrStickerSetFileInvalid), errors.Is(err, domain.ErrDocumentInvalid):
return stickerFileInvalidErr()
case errors.Is(err, domain.ErrStickerSetCreatorInvalid):
return userIDInvalidErr()
default:
return internalErr()
}
}
func stickerSetShortNameCheckErr(err error) error {
switch {
case errors.Is(err, domain.ErrStickerSetShortNameInvalid):
return shortNameInvalidErr()
default:
return internalErr()
}
}
func stickerSetSuggestShortNameErr(err error) error {
switch {
case errors.Is(err, domain.ErrStickerSetTitleInvalid):
return titleInvalidErr()
case errors.Is(err, domain.ErrStickerSetShortNameOccupied):
return packShortNameOccupiedErr()
case errors.Is(err, domain.ErrStickerSetCreatorInvalid):
return userIDInvalidErr()
default:
return internalErr()
}
}
func stickerSetManagementErr(err error) error {
switch {
case errors.Is(err, domain.ErrStickerSetTitleInvalid):
return packTitleInvalidErr()
case errors.Is(err, domain.ErrStickerSetEmpty):
return stickersEmptyErr()
case errors.Is(err, domain.ErrStickerSetTooMuch):
return stickersTooMuchErr()
case errors.Is(err, domain.ErrStickerSetEmojiInvalid):
return stickerEmojiInvalidErr()
case errors.Is(err, domain.ErrStickerSetFileInvalid), errors.Is(err, domain.ErrDocumentInvalid):
return stickerFileInvalidErr()
case errors.Is(err, domain.ErrStickerSetCreatorInvalid):
return userIDInvalidErr()
case errors.Is(err, domain.ErrStickerSetPositionInvalid):
return positionInvalidErr()
case errors.Is(err, domain.ErrStickerSetInvalid), errors.Is(err, domain.ErrStickerSetNotOwned):
return stickersetInvalidErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,253 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func stickerCreatorRouter(t *testing.T) (*Router, *fakeFiles, *memory.PasswordStore, *captureSessions) {
t.Helper()
files := &fakeFiles{
docs: map[int64]domain.Document{
101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
103: {ID: 103, AccessHash: 13, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}},
},
sets: map[domain.StickerSetKind][]domain.StickerSet{},
}
passwordStore := memory.NewPasswordStore()
sessions := &captureSessions{}
router := New(Config{}, Deps{
Account: appaccount.NewService(passwordStore, appaccount.WithUserStickerSets(passwordStore)),
Files: files,
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
return router, files, passwordStore, sessions
}
func TestStickersCreateStickerSetInstallsAndInvalidatesCatalog(t *testing.T) {
r, _, store, sessions := stickerCreatorRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
before, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("get all before create: %v", err)
}
if full, ok := before.(*tg.MessagesAllStickers); ok && len(full.Sets) != 0 {
t.Fatalf("all stickers before create = %+v, want empty", full.Sets)
}
out, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{
UserID: &tg.InputUserSelf{},
Title: "Fresh Pack",
ShortName: "fresh_pack",
Stickers: []tg.InputStickerSetItem{{
Document: &tg.InputDocument{ID: 101, AccessHash: 11},
Emoji: "🙂",
Keywords: "fresh,happy",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
full, ok := out.(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("create result = %T, want *tg.MessagesStickerSet", out)
}
if full.Set.ShortName != "fresh_pack" || !full.Set.Creator || full.Set.InstalledDate == 0 {
t.Fatalf("created set = %+v, want creator installed fresh_pack", full.Set)
}
if len(full.Packs) != 1 || len(full.Keywords) != 1 || len(full.Documents) != 1 {
t.Fatalf("created payload packs=%d keywords=%d docs=%d, want 1/1/1", len(full.Packs), len(full.Keywords), len(full.Documents))
}
if got := installedStickerSetIDs(t, store, ctx, 1000000001, domain.StickerSetKindStickers, nil); len(got) != 1 || got[0] != full.Set.ID {
t.Fatalf("installed created set ids = %v, want [%d]", got, full.Set.ID)
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil)
owned, err := r.onMessagesGetMyStickers(ctx, &tg.MessagesGetMyStickersRequest{Limit: 10})
if err != nil {
t.Fatalf("get my stickers: %v", err)
}
if owned.Count != 1 || len(owned.Sets) != 1 {
t.Fatalf("my stickers = count %d sets %d, want one created set", owned.Count, len(owned.Sets))
}
after, err := r.onMessagesGetAllStickers(ctx, 0)
if err != nil {
t.Fatalf("get all after create: %v", err)
}
all, ok := after.(*tg.MessagesAllStickers)
if !ok {
t.Fatalf("all after create = %T, want *tg.MessagesAllStickers", after)
}
if len(all.Sets) != 1 || all.Sets[0].ID != full.Set.ID {
t.Fatalf("all after create = %+v, want created set", all.Sets)
}
available, err := r.onStickersCheckShortName(ctx, "fresh_pack")
if err != nil {
t.Fatalf("check short name: %v", err)
}
if available {
t.Fatalf("fresh_pack available = true, want false after create")
}
}
func TestStickersCreateStickerSetRejectsBadDocumentAccessHash(t *testing.T) {
r, _, _, _ := stickerCreatorRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
out, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{
UserID: &tg.InputUserSelf{},
Title: "Fresh Pack",
ShortName: "fresh_pack",
Stickers: []tg.InputStickerSetItem{{
Document: &tg.InputDocument{ID: 101, AccessHash: 999},
Emoji: "🙂",
}},
})
if out != nil || !tgerr.Is(err, "STICKER_FILE_INVALID") {
t.Fatalf("create with bad document hash = %T %v, want STICKER_FILE_INVALID", out, err)
}
}
func TestStickersSuggestAndCheckShortNameValidation(t *testing.T) {
r, _, _, _ := stickerCreatorRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
suggested, err := r.onStickersSuggestShortName(ctx, "Fresh Pack")
if err != nil {
t.Fatalf("suggest short name: %v", err)
}
if suggested.ShortName == "" {
t.Fatalf("suggested short name empty")
}
if ok, err := r.onStickersCheckShortName(ctx, "bad!"); ok || !tgerr.Is(err, "SHORT_NAME_INVALID") {
t.Fatalf("check invalid short name = %v %v, want SHORT_NAME_INVALID", ok, err)
}
}
func TestStickersManageCreatedStickerSetRPCs(t *testing.T) {
r, files, _, sessions := stickerCreatorRouter(t)
ctx := WithUserID(context.Background(), 1000000001)
created, err := r.onStickersCreateStickerSet(ctx, &tg.StickersCreateStickerSetRequest{
UserID: &tg.InputUserSelf{},
Title: "Fresh Pack",
ShortName: "fresh_pack",
Stickers: []tg.InputStickerSetItem{{
Document: &tg.InputDocument{ID: 101, AccessHash: 11},
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
full := created.(*tg.MessagesStickerSet)
setInput := &tg.InputStickerSetID{ID: full.Set.ID, AccessHash: full.Set.AccessHash}
added, err := r.onStickersAddStickerToSet(ctx, &tg.StickersAddStickerToSetRequest{
Stickerset: setInput,
Sticker: tg.InputStickerSetItem{
Document: &tg.InputDocument{ID: 102, AccessHash: 12},
Emoji: "😄",
Keywords: "smile",
},
})
if err != nil {
t.Fatalf("add sticker: %v", err)
}
addedFull := added.(*tg.MessagesStickerSet)
if addedFull.Set.Count != 2 || len(addedFull.Documents) != 2 || len(addedFull.Keywords) != 1 {
t.Fatalf("after add count=%d docs=%d keywords=%d, want 2/2/1", addedFull.Set.Count, len(addedFull.Documents), len(addedFull.Keywords))
}
assertStickerSetsUpdate(t, sessions.lastUserPush(), domain.StickerSetKindStickers, nil)
moved, err := r.onStickersChangeStickerPosition(ctx, &tg.StickersChangeStickerPositionRequest{
Sticker: &tg.InputDocument{ID: 102, AccessHash: 12},
Position: 0,
})
if err != nil {
t.Fatalf("change sticker position: %v", err)
}
movedFull := moved.(*tg.MessagesStickerSet)
if len(movedFull.Documents) != 2 || tgDocumentID(movedFull.Documents[0]) != 102 {
t.Fatalf("documents after move = %+v, want doc 102 first", movedFull.Documents)
}
renamed, err := r.onStickersRenameStickerSet(ctx, &tg.StickersRenameStickerSetRequest{
Stickerset: &tg.InputStickerSetShortName{ShortName: "fresh_pack"},
Title: "Renamed Pack",
})
if err != nil {
t.Fatalf("rename sticker set: %v", err)
}
renamedFull := renamed.(*tg.MessagesStickerSet)
if renamedFull.Set.Title != "Renamed Pack" {
t.Fatalf("renamed title = %q, want Renamed Pack", renamedFull.Set.Title)
}
removed, err := r.onStickersRemoveStickerFromSet(ctx, &tg.InputDocument{ID: 102, AccessHash: 12})
if err != nil {
t.Fatalf("remove sticker: %v", err)
}
removedFull := removed.(*tg.MessagesStickerSet)
if removedFull.Set.Count != 1 || len(removedFull.Documents) != 1 || tgDocumentID(removedFull.Documents[0]) != 101 {
t.Fatalf("after remove count=%d docs=%+v, want only doc 101", removedFull.Set.Count, removedFull.Documents)
}
ok, err := r.onStickersDeleteStickerSet(ctx, setInput)
if err != nil || !ok {
t.Fatalf("delete sticker set = %v %v, want true nil", ok, err)
}
if _, _, found, err := files.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: "fresh_pack"}); err != nil || found {
t.Fatalf("resolve deleted set = found %v err %v, want miss", found, err)
}
}
func TestStickersManageCreatedStickerSetRejectsNonCreator(t *testing.T) {
r, _, _, _ := stickerCreatorRouter(t)
ownerCtx := WithUserID(context.Background(), 1000000001)
created, err := r.onStickersCreateStickerSet(ownerCtx, &tg.StickersCreateStickerSetRequest{
UserID: &tg.InputUserSelf{},
Title: "Fresh Pack",
ShortName: "fresh_pack",
Stickers: []tg.InputStickerSetItem{{
Document: &tg.InputDocument{ID: 101, AccessHash: 11},
Emoji: "🙂",
}},
})
if err != nil {
t.Fatalf("create sticker set: %v", err)
}
full := created.(*tg.MessagesStickerSet)
otherCtx := WithUserID(context.Background(), 1000000002)
out, err := r.onStickersAddStickerToSet(otherCtx, &tg.StickersAddStickerToSetRequest{
Stickerset: &tg.InputStickerSetID{ID: full.Set.ID, AccessHash: full.Set.AccessHash},
Sticker: tg.InputStickerSetItem{
Document: &tg.InputDocument{ID: 102, AccessHash: 12},
Emoji: "😄",
},
})
if out != nil || !tgerr.Is(err, "STICKERSET_INVALID") {
t.Fatalf("non-creator add = %T %v, want STICKERSET_INVALID", out, err)
}
}
func tgDocumentID(doc tg.DocumentClass) int64 {
if d, ok := doc.(*tg.Document); ok && d != nil {
return d.ID
}
return 0
}

View file

@ -26,6 +26,21 @@ func TestStickerSetsCatalogHashMatchesTDesktopFormula(t *testing.T) {
}
}
func TestFeaturedStickerSetsHashMatchesTDesktopFormula(t *testing.T) {
sets := []domain.StickerSet{
{ID: 10, Hash: 123},
{ID: 11, Hash: 456},
}
got := featuredStickerSetsHash(sets)
const want int64 = 365072220181
if got != want {
t.Fatalf("featuredStickerSetsHash() = %d, want %d", got, want)
}
if catalog := stickerSetsCatalogHash(sets); catalog == got {
t.Fatalf("test fixture no longer distinguishes featured hash from catalog hash: %d", got)
}
}
func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) {
ctx := context.Background()
files := &fakeFiles{