owpengram-server/internal/rpc/account_themes.go
2026-07-30 20:13:22 +08:00

324 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package rpc
import (
"context"
"strings"
"github.com/iamxvbaba/td/tg"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
// onAccountGetThemes 返回内置默认主题(emoji 预览条用)+ 当前用户「创建 安装」的
// 自定义云主题(跨设备同步:换设备登录同账号即可看到自建主题)。哈希按整体主题集合计算,
// 集合变化即驱动客户端重取。
//
// 按 req.Format 分流(这正是官方服务端的做法):
// - Android(format="android")把 account.getThemes 里 is_default 的主题当作 emoji
// 预览条素材,直接从 settings 渲染,不需要 document(见 DrKLO
// Theme.loadRemoteThemes / MediaDataController.generateEmojiPreviewThemes)。
// - TDesktop(format="tdesktop")把 account.getThemes 的结果当作「云主题」网格,点击
// 应用时要求主题携带 .tdesktop-theme document,否则弹 lng_theme_no_desktop
// 「doesn't include a version for Telegram Desktop」(见 data_cloud_themes.cpp
// CloudThemes::showPreview:documentId==0 且非 creator → 报错)。
//
// 我们合成的 emoji 聊天主题只有 settings、没有 document,只适合 Android 的预览条。因此对
// tdesktop 不下发这些默认主题,只返回用户自建/安装(带 document)的云主题——对全新账号即
// 空网格,与官方 TDesktop 行为一致;TDesktop 的 emoji 聊天主题走 account.getChatThemes
// 的「单聊主题选择器」(基于 settings,无需 document)。
func (r *Router) onAccountGetThemes(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) {
var themes []tg.Theme
format := ""
if req != nil {
format = req.GetFormat()
}
if !strings.EqualFold(format, "tdesktop") {
themes = tdesktop.DefaultThemeList()
}
if r.deps.Themes != nil {
if userID, _, err := r.currentUserID(ctx); err == nil && userID != 0 {
if userThemes, err := r.deps.Themes.ListForUser(ctx, userID); err == nil {
for _, t := range userThemes {
themes = append(themes, *r.tgTheme(ctx, t, userID))
}
}
}
}
if ClientTypeFrom(ctx) == ClientTypeIOS {
themes = ioscompat.ProjectThemes(themes)
}
hash, err := themesListHash(themes)
if err != nil {
return nil, internalErr()
}
if req != nil && req.GetHash() == hash {
return &tg.AccountThemesNotModified{}, nil
}
return &tg.AccountThemes{Hash: hash, Themes: themes}, nil
}
// onAccountGetChatThemes applies the same iOS ARGB projection as account.getThemes.
// The projected content hash is intentionally distinct from the historical
// Android/TDesktop catalog hash, forcing clients with the transparent-color
// response cached to fetch the corrected payload once.
func (r *Router) onAccountGetChatThemes(ctx context.Context, hash int64) (tg.AccountThemesClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
if ClientTypeFrom(ctx) != ClientTypeIOS {
return tdesktop.ChatThemes(hash), nil
}
base, ok := tdesktop.ChatThemes(0).(*tg.AccountThemes)
if !ok {
return nil, internalErr()
}
themes := ioscompat.ProjectThemes(base.Themes)
projectedHash, err := themesListHash(themes)
if err != nil {
return nil, internalErr()
}
if hash == projectedHash {
return &tg.AccountThemesNotModified{}, nil
}
return &tg.AccountThemes{Hash: projectedHash, Themes: themes}, nil
}
// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。
// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。
func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) {
if req == nil {
return nil, tgerr400("THEME_FILE_INVALID")
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Files == nil {
return nil, notImplementedErr()
}
if req.File == nil {
return nil, tgerr400("THEME_FILE_INVALID")
}
if !strings.HasPrefix(req.MimeType, "application/x-tgtheme-") {
return nil, tgerr400("THEME_MIME_INVALID")
}
ref, ok := uploadedFileRef(userID, req.File)
if !ok {
return nil, tgerr400("THEME_FILE_INVALID")
}
spec := domain.DocumentSpec{
MimeType: req.MimeType,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: req.FileName}},
}
if thumb, ok := req.GetThumb(); ok {
if tref, ok2 := uploadedFileRef(userID, thumb); ok2 {
spec.Thumb = &tref
}
}
doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec)
if err != nil {
return nil, mediaUploadErr(err)
}
return tgDocument(doc), nil
}
// onAccountCreateTheme 创建一份新的自定义云主题。空 slug 由服务端自动分配;返回的
// tg.Theme 设 creator=true,使客户端下次重传走 updateTheme 而非再次 create。
func (r *Router) onAccountCreateTheme(ctx context.Context, req *tg.AccountCreateThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
spec := domain.ThemeSpec{
CreatorUserID: userID,
Slug: req.Slug,
Title: req.Title,
}
if doc, ok := req.GetDocument(); ok {
if d, ok2 := doc.(*tg.InputDocument); ok2 {
spec.DocumentID = d.ID
}
}
if settings, ok := req.GetSettings(); ok {
spec.Settings = domainThemeSettingsFromInput(settings)
}
t, err := r.deps.Themes.Create(ctx, spec)
if err != nil {
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。
func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdateThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return nil, themeInvalidErr()
}
var upd domain.ThemeUpdate
if v, ok := req.GetSlug(); ok {
upd.Slug = &v
}
if v, ok := req.GetTitle(); ok {
upd.Title = &v
}
if doc, ok := req.GetDocument(); ok {
if d, ok2 := doc.(*tg.InputDocument); ok2 {
id := d.ID
upd.DocumentID = &id
}
}
if settings, ok := req.GetSettings(); ok {
s := domainThemeSettingsFromInput(settings)
upd.Settings = &s
}
t, err := r.deps.Themes.Update(ctx, userID, ref, upd)
if err != nil {
return nil, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
// onAccountSaveTheme 把主题加入/移出用户的已存列表。
func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThemeRequest) (bool, error) {
if req == nil {
return false, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 {
return false, authKeyUnregisteredErr()
}
if _, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
// Default catalog themes are always present in account.getThemes. Saving
// or unsaving one is therefore an idempotent signal and must not create a
// synthetic custom-theme row or user install.
return true, nil
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return false, themeInvalidErr()
}
if req.Unsave {
err = r.deps.Themes.Unsave(ctx, userID, ref)
} else {
err = r.deps.Themes.Save(ctx, userID, ref)
}
if err != nil {
return false, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
// onAccountInstallTheme 应用主题(计数 +1,加入已安装列表)。无 theme 引用时为
// 「安装本地/基础主题」的 no-op。
func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInstallThemeRequest) (bool, error) {
if req == nil {
return false, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if userID == 0 {
return false, authKeyUnregisteredErr()
}
dark := req.GetDark()
theme, ok := req.GetTheme()
if !ok {
return true, nil // 无 theme 引用:基础主题 no-op 安装
}
if _, ok := tdesktop.LookupDefaultTheme(theme); ok {
// The immutable defaults were issued by account.getThemes but do not
// live in the custom theme store. Applying one is a successful signal;
// the Android client owns the active day/night choice locally.
return true, nil
}
if r.deps.Themes == nil {
return false, notImplementedErr()
}
ref, ok := themeRefFromInput(theme)
if !ok {
return false, themeInvalidErr()
}
if err := r.deps.Themes.Install(ctx, userID, ref, dark); err != nil {
return false, themeErr(err)
}
r.invalidateRPCProjectionForUser(userID)
return true, nil
}
// onAccountGetTheme 按 id 或 slug(公开 addtheme 深链)取主题。
func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) {
if req == nil {
return nil, themeInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if t, ok := tdesktop.LookupDefaultTheme(req.Theme); ok {
return projectThemeForClient(ctx, &t), nil
}
if r.deps.Themes == nil {
return nil, notImplementedErr()
}
ref, ok := themeRefFromInput(req.Theme)
if !ok {
return nil, themeInvalidErr()
}
t, ok, err := r.deps.Themes.Get(ctx, ref)
if err != nil {
return nil, themeErr(err)
}
if !ok {
return nil, themeInvalidErr()
}
return projectThemeForClient(ctx, r.tgTheme(ctx, t, userID)), nil
}
func projectThemeForClient(ctx context.Context, theme *tg.Theme) *tg.Theme {
if theme == nil || ClientTypeFrom(ctx) != ClientTypeIOS {
return theme
}
projected := ioscompat.ProjectThemes([]tg.Theme{*theme})
if len(projected) == 0 {
return theme
}
return &projected[0]
}