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

@ -1,5 +1,10 @@
package domain
import (
"path/filepath"
"strings"
)
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。
//
@ -238,6 +243,91 @@ func (d Document) IsSticker() bool {
return false
}
func (d Document) IsCustomEmoji() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrCustomEmoji {
return true
}
}
return false
}
func (d Document) IsStickerLike() bool {
return d.IsSticker() || d.IsCustomEmoji()
}
// MaxStickerMaterialDocumentSize bounds uploaded documents that can be promoted
// into user-created sticker/custom emoji sets without server-side transcoding.
const MaxStickerMaterialDocumentSize int64 = 20 << 20
// IsStickerSetMaterial reports whether an existing Document can be used as an
// input item for stickers.createStickerSet/stickers.addStickerToSet. Already
// tagged sticker/custom emoji documents always pass. Untagged TGS/WebP/Lottie
// JSON uploads can be shaped later from their MIME/body; video uploads must
// already carry a video attribute because this layer does not transcode or
// probe containers.
func (d Document) IsStickerSetMaterial() bool {
if d.IsStickerLike() {
return true
}
if d.ID == 0 || d.AccessHash == 0 || d.Size > MaxStickerMaterialDocumentSize {
return false
}
switch d.stickerMaterialMimeOrExt() {
case "application/x-tgsticker", "image/webp", "application/json":
return true
case "video/webm", "video/mp4":
return d.hasDocumentAttribute(DocAttrVideo)
default:
return false
}
}
func (d Document) hasDocumentAttribute(kind DocumentAttributeKind) bool {
for _, attr := range d.Attributes {
if attr.Kind == kind {
return true
}
}
return false
}
// StickerSetMaterialMime returns the normalized sticker material MIME inferred
// from the document MIME type or a filename attribute.
func (d Document) StickerSetMaterialMime() string {
return d.stickerMaterialMimeOrExt()
}
func (d Document) stickerMaterialMimeOrExt() string {
mimeType := strings.ToLower(strings.TrimSpace(d.MimeType))
switch mimeType {
case "application/x-tgsticker", "image/webp", "video/webm", "video/mp4",
"application/json", "application/lottie+json", "text/json":
if mimeType == "application/lottie+json" || mimeType == "text/json" {
return "application/json"
}
return mimeType
}
for _, attr := range d.Attributes {
if attr.Kind != DocAttrFilename {
continue
}
switch strings.ToLower(filepath.Ext(strings.TrimSpace(attr.FileName))) {
case ".tgs":
return "application/x-tgsticker"
case ".webp":
return "image/webp"
case ".json":
return "application/json"
case ".webm":
return "video/webm"
case ".mp4":
return "video/mp4"
}
}
return mimeType
}
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
func (d Document) IsGif() bool {
for _, attr := range d.Attributes {
@ -558,6 +648,11 @@ type StickerPack struct {
DocumentIDs []int64 `json:"document_ids"`
}
type StickerKeyword struct {
DocumentID int64 `json:"document_id"`
Keywords []string `json:"keywords"`
}
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集
// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
@ -576,32 +671,67 @@ const (
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
type StickerSet struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
ShortName string `json:"short_name"`
Title string `json:"title"`
Count int `json:"count"`
Hash int `json:"hash"`
Kind StickerSetKind `json:"set_kind"`
Official bool `json:"official,omitempty"`
Animated bool `json:"animated,omitempty"`
Videos bool `json:"videos,omitempty"`
Emojis bool `json:"emojis,omitempty"`
Masks bool `json:"masks,omitempty"`
Installed bool `json:"installed,omitempty"`
Archived bool `json:"archived,omitempty"`
InstalledDate int `json:"installed_date,omitempty"`
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
Thumbs []PhotoSize `json:"thumbs,omitempty"`
ThumbDCID int `json:"thumb_dc_id,omitempty"`
ThumbVersion int `json:"thumb_version,omitempty"`
DocumentIDs []int64 `json:"document_ids,omitempty"`
Packs []StickerPack `json:"packs,omitempty"`
SortOrder int `json:"sort_order,omitempty"`
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
ShortName string `json:"short_name"`
Title string `json:"title"`
Count int `json:"count"`
Hash int `json:"hash"`
Kind StickerSetKind `json:"set_kind"`
Official bool `json:"official,omitempty"`
Animated bool `json:"animated,omitempty"`
Videos bool `json:"videos,omitempty"`
Emojis bool `json:"emojis,omitempty"`
Masks bool `json:"masks,omitempty"`
TextColor bool `json:"text_color,omitempty"`
Creator bool `json:"creator,omitempty"`
CreatorUserID int64 `json:"creator_user_id,omitempty"`
Installed bool `json:"installed,omitempty"`
Archived bool `json:"archived,omitempty"`
Deleted bool `json:"deleted,omitempty"`
InstalledDate int `json:"installed_date,omitempty"`
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
Thumbs []PhotoSize `json:"thumbs,omitempty"`
ThumbDCID int `json:"thumb_dc_id,omitempty"`
ThumbVersion int `json:"thumb_version,omitempty"`
DocumentIDs []int64 `json:"document_ids,omitempty"`
Packs []StickerPack `json:"packs,omitempty"`
Keywords []StickerKeyword `json:"keywords,omitempty"`
SortOrder int `json:"sort_order,omitempty"`
Software string `json:"software,omitempty"`
// SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。
SystemKey string `json:"system_key,omitempty"`
}
const (
MinStickerSetShortNameLen = 5
MaxStickerSetShortNameLen = 32
MaxStickerSetTitleLen = 64
MaxStickerSetItems = 120
MaxStickerSetKeywords = 20
MaxStickerSetKeywordLen = 32
)
type StickerSetItemInput struct {
DocumentID int64
DocumentAccessHash int64
Emoji string
Keywords string
}
type CreateStickerSetRequest struct {
CreatorUserID int64
Title string
ShortName string
Kind StickerSetKind
TextColor bool
ThumbDocumentID int64
ThumbAccessHash int64
Items []StickerSetItemInput
Software string
Date int
}
// ProfilePhotoKind distinguishes a user's real profile photo from the fallback
// public photo shown when privacy hides the real one.
type ProfilePhotoKind string

View file

@ -0,0 +1,74 @@
package domain
import "testing"
func TestDocumentIsStickerSetMaterialAcceptsUploadedFormats(t *testing.T) {
tests := []struct {
name string
doc Document
want bool
wantMime string
}{
{
name: "existing sticker",
doc: Document{ID: 1, AccessHash: 11, Size: MaxStickerMaterialDocumentSize + 1, Attributes: []DocumentAttribute{{Kind: DocAttrSticker}}},
want: true,
wantMime: "",
},
{
name: "tgs mime",
doc: Document{ID: 2, AccessHash: 22, MimeType: "application/x-tgsticker", Size: 4096},
want: true,
wantMime: "application/x-tgsticker",
},
{
name: "webp extension",
doc: Document{ID: 3, AccessHash: 33, MimeType: "application/octet-stream", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrFilename, FileName: "sticker.WEBP"}}},
want: true,
wantMime: "image/webp",
},
{
name: "lottie json mime",
doc: Document{ID: 31, AccessHash: 331, MimeType: "application/lottie+json", Size: 4096},
want: true,
wantMime: "application/json",
},
{
name: "lottie json extension",
doc: Document{ID: 32, AccessHash: 332, MimeType: "application/octet-stream", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrFilename, FileName: "wave.JSON"}}},
want: true,
wantMime: "application/json",
},
{
name: "mp4 mime",
doc: Document{ID: 4, AccessHash: 44, MimeType: "video/mp4", Size: 4096, Attributes: []DocumentAttribute{{Kind: DocAttrVideo, W: 512, H: 512, Duration: 1}}},
want: true,
wantMime: "video/mp4",
},
{
name: "mp4 without video attribute",
doc: Document{ID: 40, AccessHash: 440, MimeType: "video/mp4", Size: 4096},
wantMime: "video/mp4",
},
{
name: "arbitrary file",
doc: Document{ID: 5, AccessHash: 55, MimeType: "application/pdf", Size: 4096},
wantMime: "application/pdf",
},
{
name: "oversized upload",
doc: Document{ID: 6, AccessHash: 66, MimeType: "image/webp", Size: MaxStickerMaterialDocumentSize + 1},
wantMime: "image/webp",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.doc.IsStickerSetMaterial(); got != tt.want {
t.Fatalf("IsStickerSetMaterial() = %v, want %v", got, tt.want)
}
if got := tt.doc.StickerSetMaterialMime(); got != tt.wantMime {
t.Fatalf("StickerSetMaterialMime() = %q, want %q", got, tt.wantMime)
}
})
}
}

View file

@ -5,6 +5,21 @@ import "errors"
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。
var ErrStickerInvalid = errors.New("sticker document invalid")
var (
ErrStickerSetTitleInvalid = errors.New("sticker set title invalid")
ErrStickerSetShortNameInvalid = errors.New("sticker set short name invalid")
ErrStickerSetShortNameOccupied = errors.New("sticker set short name occupied")
ErrStickerSetTypeInvalid = errors.New("sticker set type invalid")
ErrStickerSetEmpty = errors.New("sticker set empty")
ErrStickerSetTooMuch = errors.New("sticker set too much")
ErrStickerSetEmojiInvalid = errors.New("sticker set emoji invalid")
ErrStickerSetFileInvalid = errors.New("sticker set file invalid")
ErrStickerSetCreatorInvalid = errors.New("sticker set creator invalid")
ErrStickerSetInvalid = errors.New("sticker set invalid")
ErrStickerSetNotOwned = errors.New("sticker set not owned")
ErrStickerSetPositionInvalid = errors.New("sticker set position invalid")
)
// StickerCollectionKind 区分一个用户的几类个人贴纸集合。
type StickerCollectionKind string
@ -22,9 +37,11 @@ const (
// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额;
// faved 不做 premium 分档(用户决策范围外),统一一个上界。
const (
MaxFavedStickers = 100
MaxRecentStickers = 30
MaxSavedGifs = 200
MaxFavedStickers = 100
MaxRecentStickers = 30
MaxSavedGifs = 200
MaxInstalledStickerSets = 200
MaxCreatedStickerSets = 200
)
// MaxStickerCollectionItems 返回某类集合的容量上界。
@ -44,3 +61,14 @@ type StickerCollectionItem struct {
DocumentID int64
Date int
}
// UserStickerSet 是某个账号安装/归档的一条 sticker set 状态。
// set 元数据、文档和 packs 仍由 media/files 模块维护;这里仅保存用户视角。
type UserStickerSet struct {
OwnerUserID int64
StickerSetID int64
Kind StickerSetKind
Archived bool
InstalledDate int
OrderValue int64
}

View file

@ -8,6 +8,12 @@ const (
BotFatherUserID int64 = 93372553
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
BotFatherAccessHash int64 = 7421896403922962293
// StickersBotUserID 是内置 @Stickers 账号。它是 server 内置 service bot,
// 不走外部 Bot API 进程。
StickersBotUserID int64 = 1063110917
// StickersBotAccessHash 固定不变;与 postgres 种子行双写,必须保持一致。
StickersBotAccessHash int64 = 5213187021149032991
)
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
@ -36,6 +42,19 @@ func BotFatherUser() User {
}
}
// StickersBotUser 返回内置 @Stickers 账号。username 不以 bot 结尾属种子例外(与官方一致)。
func StickersBotUser() User {
return User{
ID: StickersBotUserID,
AccessHash: StickersBotAccessHash,
FirstName: "Stickers",
Username: "Stickers",
Verified: true,
Bot: true,
BotInfoVersion: 2,
}
}
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
func SystemUserByID(id int64) (User, bool) {
@ -44,6 +63,8 @@ func SystemUserByID(id int64) (User, bool) {
return OfficialSystemUser(), true
case BotFatherUserID:
return BotFatherUser(), true
case StickersBotUserID:
return StickersBotUser(), true
}
return User{}, false
}
@ -55,7 +76,7 @@ func IsSystemUserID(id int64) bool {
func SystemUserByPhone(phone string) (User, bool) {
phone = NormalizePhone(phone)
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} {
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID, StickersBotUserID} {
u, ok := SystemUserByID(id)
if !ok || u.Phone == "" {
continue