feat: sync visible product branding

Sync telesrv c779c48 (feat(branding): unify visible product naming).

Skipped private www files and preserved public README files; mapped orange appearance seed names to public default names.
This commit is contained in:
A 2026-07-20 16:49:17 +08:00
parent da6a57e1a3
commit 2848ff0987
34 changed files with 449 additions and 91 deletions

View file

@ -1,5 +1,5 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图, // Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
// 生成 telesrv 外观 seed(Default_appearance_seed.json + Default_wallpapers/{documents,thumbs/m}/*.dat)。 // 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。 // 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
// //
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。 // 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
} }
api := client.API() api := client.API()
docsDir := filepath.Join(outDir, "Default_wallpapers", "documents") docsDir := filepath.Join(outDir, "default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m") thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil { if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err return err
} }
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
return appearance.Document{}, err return appearance.Document{}, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
out.Path = "Default_wallpapers/documents/" + name out.Path = "default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:]) out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图 // "m" 缩略图
for _, t := range doc.Thumbs { for _, t := range doc.Thumbs {
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
tsum := sha256.Sum256(tdata) tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{ out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size, Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
Path: "Default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]), Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
}) })
break break
} }
@ -373,7 +373,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors)) fmt.Printf("[peer_colors] %d / [peer_profile_colors] %d\n", len(peerColors), len(peerProfileColors))
catalog := appearance.Catalog{ catalog := appearance.Catalog{
Source: "official telegram (appearancefetch)", Source: "upstream appearance snapshot",
ExportedAt: time.Now().UTC().Format(time.RFC3339), ExportedAt: time.Now().UTC().Format(time.RFC3339),
Notes: appearance.Notes{ Notes: appearance.Notes{
Server: "official", Server: "official",
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
if err != nil { if err != nil {
return err return err
} }
jsonPath := filepath.Join(outDir, "Default_appearance_seed.json") jsonPath := filepath.Join(outDir, "default_appearance_seed.json")
if err := os.WriteFile(jsonPath, out, 0o644); err != nil { if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err return err
} }

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title> <title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-DKmJO2ZY.js"></script> <script type="module" crossorigin src="/assets/index-D9dH2J7N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css"> <link rel="stylesheet" crossorigin href="/assets/index-DHdrFM5j.css">
</head> </head>
<body> <body>

View file

@ -328,7 +328,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.backdrop": "Backdrop", "collectibles.backdrop": "Backdrop",
"collectibles.rarity": "Rarity ‰", "collectibles.rarity": "Rarity ‰",
"collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.", "collectibles.rarityHint": "Permille values are relative regular-upgrade weights; their total does not need to equal 1000.",
"collectibles.colorHint": "Colors are stored as Telegram 24-bit RGB values.", "collectibles.colorHint": "Colors are stored as 24-bit RGB values.",
"collectibles.addAttribute": "Add", "collectibles.addAttribute": "Add",
"collectibles.remove": "Remove attribute", "collectibles.remove": "Remove attribute",
"collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.", "collectibles.fileRequired": "Every model and pattern needs a TGS or Lottie file.",
@ -698,7 +698,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.backdrop": "背景", "collectibles.backdrop": "背景",
"collectibles.rarity": "稀有度 ‰", "collectibles.rarity": "稀有度 ‰",
"collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。", "collectibles.rarityHint": "Permille 是普通升级的相对权重,不要求每类合计正好为 1000。",
"collectibles.colorHint": "颜色会按 Telegram 24 位 RGB 数值保存。", "collectibles.colorHint": "颜色会按 24 位 RGB 数值保存。",
"collectibles.addAttribute": "添加", "collectibles.addAttribute": "添加",
"collectibles.remove": "删除属性", "collectibles.remove": "删除属性",
"collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。", "collectibles.fileRequired": "每个模型和图案都必须选择 TGS 或 Lottie 文件。",
@ -1068,7 +1068,7 @@ const translations: Record<Language, Record<string, string>> = {
"collectibles.backdrop": "Фон", "collectibles.backdrop": "Фон",
"collectibles.rarity": "Редкость ‰", "collectibles.rarity": "Редкость ‰",
"collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.", "collectibles.rarityHint": "Значения permille — это относительные веса обычного улучшения; их сумма не обязана равняться 1000.",
"collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения Telegram.", "collectibles.colorHint": "Цвета сохраняются как 24-битные RGB-значения.",
"collectibles.addAttribute": "Добавить", "collectibles.addAttribute": "Добавить",
"collectibles.remove": "Удалить атрибут", "collectibles.remove": "Удалить атрибут",
"collectibles.fileRequired": "Для каждой модели и узора требуется файл TGS или Lottie.", "collectibles.fileRequired": "Для каждой модели и узора требуется файл TGS или Lottie.",

View file

@ -495,7 +495,7 @@ func run(logger *zap.Logger) error {
cfg.UploadPartGCInterval, cfg.UploadPartGCInterval,
cfg.UploadPartGCBatch, cfg.UploadPartGCBatch,
).Run(ctx) ).Run(ctx)
langPackService := langpack.NewService(langPackStore) langPackService := langpack.NewService(langPackStore, langpack.WithPublicBaseURL(cfg.PublicBaseURL))
privacyService := privacyapp.NewService(privacyStore, contactStore) privacyService := privacyapp.NewService(privacyStore, contactStore)
contactsService := contacts.NewService(contactStore, userStore).Configure( contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos), contacts.WithPhotoProvider(cachedPhotos),

View file

@ -55,7 +55,7 @@ type appServer struct {
func main() { func main() {
cfg := config{} cfg := config{}
flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address") flag.StringVar(&cfg.listen, "listen", envOr("TELESRV_WALLET_LISTEN", "127.0.0.1:8091"), "wallet mini app HTTP listen address")
flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telegram menu button") flag.StringVar(&cfg.publicURL, "public-url", os.Getenv("TELESRV_WALLET_PUBLIC_URL"), "public HTTPS URL used in the Telesrv menu button")
flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL") flag.StringVar(&cfg.botAPI, "bot-api", envOr("TELESRV_BOT_API_URL", "http://127.0.0.1:8081"), "telesrv Bot API base URL")
flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>") flag.StringVar(&cfg.token, "token", os.Getenv("TELESRV_BOT_TOKEN"), "bot token <bot_id>:<secret>")
flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label") flag.StringVar(&cfg.menuText, "menu-text", envOr("TELESRV_WALLET_MENU_TEXT", "Wallet"), "menu button label")

View file

@ -5554,7 +5554,7 @@ SET row_security = off;
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: - -- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: -
-- --
INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0); INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telesrv', '', 'telesrv', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0);
INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (93372553, 7421896403922962293, '', 'BotFather', '', 'BotFather', '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', true, false, '', 0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0); INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (93372553, 7421896403922962293, '', 'BotFather', '', 'BotFather', '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', true, false, '', 0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0);

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/otpdelivery" "telesrv/internal/otpdelivery"
"telesrv/internal/store" "telesrv/internal/store"
@ -79,7 +80,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]
} }
executeAt := now.Add(accountDeletionDelay) executeAt := now.Add(accountDeletionDelay)
message := fmt.Sprintf( message := fmt.Sprintf(
"A request was made to delete your Telegram account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s", "A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken), url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
) )
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{

View file

@ -17,6 +17,7 @@ import (
"github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/bin"
mtcrypto "github.com/iamxvbaba/td/crypto" mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/otpdelivery" "telesrv/internal/otpdelivery"
"telesrv/internal/store" "telesrv/internal/store"
@ -1435,9 +1436,9 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
return found && settings.HasPassword, nil return found && settings.HasPassword, nil
} }
const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your Telegram account. We never ask it for anything else. This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.` If you didn't request this code by trying to log in on another device, simply ignore this message.`

View file

@ -12,6 +12,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -42,7 +43,7 @@ const (
botFatherDraftBotUsername = "bot_username" botFatherDraftBotUsername = "bot_username"
) )
const botFatherHelpText = `I can help you create and manage Telegram bots. const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
You can control me by sending these commands: You can control me by sending these commands:

View file

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"hash" "hash"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/seed/appearance" "telesrv/internal/seed/appearance"
) )
@ -253,7 +254,7 @@ func appearanceDocumentAttributes(in []appearance.DocumentAttribute) []domain.Do
if attr.FileName != "" { if attr.FileName != "" {
out = append(out, domain.DocumentAttribute{ out = append(out, domain.DocumentAttribute{
Kind: domain.DocAttrFilename, Kind: domain.DocAttrFilename,
FileName: attr.FileName, FileName: branding.UserVisibleText(attr.FileName, ""),
}) })
} }
} }

View file

@ -7,6 +7,7 @@ import (
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
"golang.org/x/text/unicode/bidi" "golang.org/x/text/unicode/bidi"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
) )
@ -18,16 +19,34 @@ type Service struct {
languageCache *languageListCache languageCache *languageListCache
packLoads singleflight.Group packLoads singleflight.Group
languageLoads singleflight.Group languageLoads singleflight.Group
publicBaseURL string
}
// Option configures user-visible language-pack projection.
type Option func(*Service)
// WithPublicBaseURL replaces official public hosts embedded in upstream
// language-pack values with this deployment's public link root.
func WithPublicBaseURL(value string) Option {
return func(s *Service) {
if strings.TrimSpace(value) != "" {
s.publicBaseURL = value
}
}
} }
// NewService 创建 langpack 服务。 // NewService 创建 langpack 服务。
func NewService(packs store.LangPackStore) *Service { func NewService(packs store.LangPackStore, opts ...Option) *Service {
return newServiceWithCacheLimits( s := newServiceWithCacheLimits(
packs, packs,
defaultLangPackCacheMaxBytes, defaultLangPackCacheMaxBytes,
defaultLangPackCacheMaxEntries, defaultLangPackCacheMaxEntries,
defaultLanguageListCacheMaxEntries, defaultLanguageListCacheMaxEntries,
) )
for _, opt := range opts {
opt(s)
}
return s
} }
func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service { func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEntries, languageEntries int) *Service {
@ -35,6 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
packs: packs, packs: packs,
packCache: newLangPackCache(maxBytes, maxEntries), packCache: newLangPackCache(maxBytes, maxEntries),
languageCache: newLanguageListCache(languageEntries), languageCache: newLanguageListCache(languageEntries),
publicBaseURL: branding.DefaultPublicURL,
} }
} }
@ -135,7 +155,11 @@ func shouldOverlayWebA(langPack string) bool {
func (s *Service) rawPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) { func (s *Service) rawPack(ctx context.Context, langPack, langCode string) (domain.LangPack, error) {
key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheRaw} key := langPackCacheKey{pack: langPack, code: langCode, kind: langPackCacheRaw}
return s.cachedPack(ctx, key, func() (domain.LangPack, error) { return s.cachedPack(ctx, key, func() (domain.LangPack, error) {
return s.packs.GetPack(ctx, langPack, langCode, 0) pack, err := s.packs.GetPack(ctx, langPack, langCode, 0)
if err != nil {
return domain.LangPack{}, err
}
return s.brandPack(pack), nil
}) })
} }
@ -218,6 +242,7 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
} }
for i := range languages { for i := range languages {
languages[i] = completeLanguageMetadata(langPack, languages[i]) languages[i] = completeLanguageMetadata(langPack, languages[i])
languages[i] = s.brandLanguage(languages[i])
} }
return cachedLanguagesLoadResult{ return cachedLanguagesLoadResult{
languages: languages, languages: languages,
@ -237,6 +262,27 @@ func (s *Service) cachedLanguages(ctx context.Context, langPack string) ([]domai
} }
} }
func (s *Service) brandPack(pack domain.LangPack) domain.LangPack {
for i := range pack.Strings {
item := &pack.Strings[i]
item.Value = branding.UserVisibleText(item.Value, s.publicBaseURL)
item.ZeroValue = branding.UserVisibleText(item.ZeroValue, s.publicBaseURL)
item.OneValue = branding.UserVisibleText(item.OneValue, s.publicBaseURL)
item.TwoValue = branding.UserVisibleText(item.TwoValue, s.publicBaseURL)
item.FewValue = branding.UserVisibleText(item.FewValue, s.publicBaseURL)
item.ManyValue = branding.UserVisibleText(item.ManyValue, s.publicBaseURL)
item.OtherValue = branding.UserVisibleText(item.OtherValue, s.publicBaseURL)
}
return pack
}
func (s *Service) brandLanguage(lang domain.LangPackLanguage) domain.LangPackLanguage {
lang.Name = branding.UserVisibleText(lang.Name, s.publicBaseURL)
lang.NativeName = branding.UserVisibleText(lang.NativeName, s.publicBaseURL)
lang.TranslationsURL = branding.UserVisibleText(lang.TranslationsURL, s.publicBaseURL)
return lang
}
func (s *Service) flushCaches() { func (s *Service) flushCaches() {
if s == nil { if s == nil {
return return

View file

@ -74,6 +74,66 @@ func TestServiceNormalizesWebARawLangCode(t *testing.T) {
} }
} }
func TestServiceRebrandsEveryLanguagePackProjection(t *testing.T) {
ctx := context.Background()
base := memory.NewLangPackStore()
seed := domain.LangPack{
LangPack: "weba",
LangCode: "en",
Version: 7,
Strings: []domain.LangPackString{
{Key: "AppName", Value: "Telegram", Pluralized: true, ZeroValue: "No Telegram accounts", OneValue: "One Telegram account", TwoValue: "Two Telegram accounts", FewValue: "Few Telegram accounts", ManyValue: "Many Telegram accounts", OtherValue: "Other Telegram accounts"},
{Key: "TranslationLink", Value: "https://translations.telegram.org/en"},
{Key: "RuntimeIdentifier", Value: "org.telegram.messenger"},
},
}
if err := base.UpsertPack(ctx, seed); err != nil {
t.Fatalf("seed langpack: %v", err)
}
storeWithMetadata := &metadataLangPackStore{
LangPackStore: base,
languages: []domain.LangPackLanguage{{
LangPack: "weba",
LangCode: "en",
Name: "Telegram English",
NativeName: "Telegram English",
TranslationsURL: "https://translations.telegram.org/en",
}},
}
svc := NewService(storeWithMetadata, WithPublicBaseURL("https://chat.example/root/"))
for name, load := range map[string]func() (domain.LangPack, error){
"full": func() (domain.LangPack, error) { return svc.GetLangPack(ctx, "weba", "en") },
"difference": func() (domain.LangPack, error) { return svc.GetDifference(ctx, "weba", "en", 1) },
"keys": func() (domain.LangPack, error) {
return svc.GetStrings(ctx, "weba", "en", []string{"AppName", "TranslationLink", "RuntimeIdentifier"})
},
} {
pack, err := load()
if err != nil {
t.Fatalf("%s projection: %v", name, err)
}
appName := findLangPackString(pack.Strings, "AppName")
if appName == nil || appName.Value != "Telesrv" || appName.ZeroValue != "No Telesrv accounts" || appName.OneValue != "One Telesrv account" || appName.TwoValue != "Two Telesrv accounts" || appName.FewValue != "Few Telesrv accounts" || appName.ManyValue != "Many Telesrv accounts" || appName.OtherValue != "Other Telesrv accounts" {
t.Fatalf("%s AppName = %+v, want all value forms rebranded", name, appName)
}
if got := stringValue(pack.Strings, "TranslationLink"); got != "https://chat.example/root/en" {
t.Fatalf("%s TranslationLink = %q", name, got)
}
if got := stringValue(pack.Strings, "RuntimeIdentifier"); got != "org.telegram.messenger" {
t.Fatalf("%s RuntimeIdentifier = %q, want protocol identifier unchanged", name, got)
}
}
languages, err := svc.ListLanguages(ctx, "weba")
if err != nil {
t.Fatalf("list languages: %v", err)
}
if len(languages) != 1 || languages[0].Name != "Telesrv English" || languages[0].NativeName != "Telesrv English" || languages[0].TranslationsURL != "https://chat.example/root/en" {
t.Fatalf("languages = %+v, want branded metadata", languages)
}
}
func TestListLanguagesUsesSeededPacks(t *testing.T) { func TestListLanguagesUsesSeededPacks(t *testing.T) {
ctx := context.Background() ctx := context.Background()
packs := memory.NewLangPackStore() packs := memory.NewLangPackStore()
@ -286,6 +346,15 @@ type countingLangPackStore struct {
listLanguages int listLanguages int
} }
type metadataLangPackStore struct {
store.LangPackStore
languages []domain.LangPackLanguage
}
func (s *metadataLangPackStore) ListLanguages(context.Context, string) ([]domain.LangPackLanguage, error) {
return append([]domain.LangPackLanguage(nil), s.languages...), nil
}
func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { func (s *countingLangPackStore) GetPack(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) {
s.mu.Lock() s.mu.Lock()
s.getPack++ s.getPack++
@ -334,3 +403,12 @@ func stringValue(strings []domain.LangPackString, key string) string {
} }
return "" return ""
} }
func findLangPackString(strings []domain.LangPackString, key string) *domain.LangPackString {
for i := range strings {
if strings[i].Key == key {
return &strings[i]
}
}
return nil
}

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
"telesrv/internal/webauthn" "telesrv/internal/webauthn"
@ -40,7 +41,7 @@ type Option func(*Service)
func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } } func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } }
// WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin // WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin
//(服务端通常不预知 Android apk-key-hash origin)。 // (服务端通常不预知 Android apk-key-hash origin)。
func WithAllowedOrigins(origins []string) Option { func WithAllowedOrigins(origins []string) Option {
return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) } return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) }
} }
@ -70,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
creds: creds, creds: creds,
challenges: challenges, challenges: challenges,
rpID: rpID, rpID: rpID,
rpName: "Telegram", rpName: branding.ProductName,
dcID: dcID, dcID: dcID,
challengeTTL: defaultChallengeTTL, challengeTTL: defaultChallengeTTL,
now: time.Now, now: time.Now,

View file

@ -68,7 +68,7 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{ first, err := svc.CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "First", Animation: animation, Stars: 50, ConvertStars: 25, Enabled: true, SortOrder: 1, Title: "Telegram Pin", Animation: animation,
}) })
if err != nil { if err != nil {
t.Fatalf("create first: %v", err) t.Fatalf("create first: %v", err)
@ -84,7 +84,7 @@ func TestCreateCatalogRevisionPreservesHistoricalRevision(t *testing.T) {
t.Fatalf("current=%+v found=%v", current, found) t.Fatalf("current=%+v found=%v", current, found)
} }
historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID) historical, found, _ := svc.GiftRevisionByID(ctx, first.Gift.RevisionID)
if !found || historical.Stars != 50 || historical.Title != "First" { if !found || historical.Stars != 50 || historical.Title != "Telesrv Pin" {
t.Fatalf("historical=%+v found=%v", historical, found) t.Fatalf("historical=%+v found=%v", historical, found)
} }
if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) { if _, err := svc.SetCatalogEnabled(ctx, first.Gift.ID+999, false); !errors.Is(err, domain.ErrStarGiftNotFound) {

View file

@ -13,6 +13,7 @@ import (
"sync" "sync"
"time" "time"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
) )
@ -173,7 +174,7 @@ func (s *Service) CreateCatalogRevision(ctx context.Context, write domain.StarGi
if s == nil || s.store == nil || s.blobs == nil { if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured") return domain.StarGiftCatalogEntry{}, fmt.Errorf("star gift catalog importer is not configured")
} }
write.Title = strings.TrimSpace(write.Title) write.Title = branding.UserVisibleText(strings.TrimSpace(write.Title), "")
if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars || if write.Stars <= 0 || write.ConvertStars < 0 || write.ConvertStars > write.Stars ||
write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 || write.Animation.Width != 512 || write.Animation.Height != 512 || len(write.Animation.TGS) == 0 ||
len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes { len([]rune(write.Title)) > domain.MaxStarGiftTitleRunes {
@ -239,7 +240,8 @@ func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGift
if s == nil || s.store == nil || s.blobs == nil { if s == nil || s.store == nil || s.blobs == nil {
return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured") return domain.StarGiftCatalogBundleResult{}, fmt.Errorf("star gift catalog importer is not configured")
} }
write.Catalog.Title = strings.TrimSpace(write.Catalog.Title) write.Catalog.Title = branding.UserVisibleText(strings.TrimSpace(write.Catalog.Title), "")
write.Catalog.AuctionSlug = branding.UserVisibleText(strings.TrimSpace(write.Catalog.AuctionSlug), "")
if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars || if write.Catalog.Stars <= 0 || write.Catalog.ConvertStars < 0 || write.Catalog.ConvertStars > write.Catalog.Stars ||
write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 || write.Catalog.Animation.Width != 512 || write.Catalog.Animation.Height != 512 || len(write.Catalog.Animation.TGS) == 0 ||
len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes { len([]rune(write.Catalog.Title)) > domain.MaxStarGiftTitleRunes {
@ -258,6 +260,9 @@ func (s *Service) CreateCatalogBundle(ctx context.Context, write domain.StarGift
} }
if write.Collectible != nil { if write.Collectible != nil {
write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix)) write.Collectible.SlugPrefix = strings.ToLower(strings.TrimSpace(write.Collectible.SlugPrefix))
brandCollectibleAttributes(write.Collectible.Models)
brandCollectibleAttributes(write.Collectible.Patterns)
brandCollectibleAttributes(write.Collectible.Backdrops)
if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID || if write.Collectible.OfficialGiftID != write.Catalog.OfficialGiftID ||
!bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) { !bytes.Equal(write.Collectible.SourceManifestSHA256, write.Catalog.SourceManifestSHA256) {
return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid return domain.StarGiftCatalogBundleResult{}, domain.ErrStarGiftCollectibleInvalid
@ -315,6 +320,9 @@ func (s *Service) PublishCollectibleRevision(ctx context.Context, write domain.S
if s == nil || s.store == nil { if s == nil || s.store == nil {
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured") return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible store is not configured")
} }
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
revision, err := s.store.PublishCollectibleRevision(ctx, write) revision, err := s.store.PublishCollectibleRevision(ctx, write)
if err == nil { if err == nil {
s.InvalidateStarGiftCatalog() s.InvalidateStarGiftCatalog()
@ -330,6 +338,9 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St
return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured") return domain.StarGiftCollectibleRevision{}, fmt.Errorf("star gift collectible importer is not configured")
} }
write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix)) write.SlugPrefix = strings.ToLower(strings.TrimSpace(write.SlugPrefix))
brandCollectibleAttributes(write.Models)
brandCollectibleAttributes(write.Patterns)
brandCollectibleAttributes(write.Backdrops)
if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil { if err := domain.ValidateStarGiftCollectibleDraft(write); err != nil {
return domain.StarGiftCollectibleRevision{}, err return domain.StarGiftCollectibleRevision{}, err
} }
@ -342,6 +353,12 @@ func (s *Service) CreateCollectibleRevision(ctx context.Context, write domain.St
return s.PublishCollectibleRevision(ctx, write) return s.PublishCollectibleRevision(ctx, write)
} }
func brandCollectibleAttributes(attributes []domain.StarGiftCollectibleAttribute) {
for i := range attributes {
attributes[i].Name = branding.UserVisibleText(strings.TrimSpace(attributes[i].Name), "")
}
}
func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error { func (s *Service) materializeCollectibleAttributes(ctx context.Context, attributes []domain.StarGiftCollectibleAttribute) error {
for i := range attributes { for i := range attributes {
animation := attributes[i].Animation animation := attributes[i].Animation

View file

@ -0,0 +1,95 @@
// Package branding owns the user-visible telesrv product identity.
//
// Protocol identifiers, client detection tokens and third-party compatibility
// headers do not belong here: callers must only pass text that is rendered to
// an end user.
package branding
import (
"net/url"
"regexp"
"strings"
)
const (
ProductName = "Telesrv"
ProductUsername = "telesrv"
DesktopAppName = "Telesrv Desktop"
AndroidAppName = "Telesrv Android"
IOSAppName = "Telesrv iOS"
MacOSAppName = "Telesrv macOS"
WebAAppName = "Telesrv Web A"
WebKAppName = "Telesrv Web K"
PremiumName = "Telesrv Premium"
StarsName = "Telesrv Stars"
DefaultPublicURL = "https://telesrv.net"
)
// ClientAppName returns the branded display name for a stored client platform.
// Stored detection tokens remain unchanged; this is only used at presentation
// boundaries such as account.getAuthorizations.
func ClientAppName(platform string) string {
switch strings.ToLower(strings.TrimSpace(platform)) {
case "android":
return AndroidAppName
case "ios":
return IOSAppName
case "macos":
return MacOSAppName
case "telegram-tt", "weba":
return WebAAppName
case "tweb", "webk":
return WebKAppName
case "tdesktop", "desktop", "windows":
return DesktopAppName
default:
return ProductName
}
}
// UserVisibleClientPlatform hides internal compatibility tokens from the
// authorization UI without changing their durable representation.
func UserVisibleClientPlatform(platform string) string {
if strings.EqualFold(strings.TrimSpace(platform), "telegram-tt") {
return "weba"
}
return UserVisibleText(platform, "")
}
var (
officialHTTPHostRE = regexp.MustCompile(`(?i)https?://(?:[a-z0-9-]+\.)*(?:telegram\.(?:org|me|com|dog)|t\.me)([^a-z0-9]|$)`)
officialBareHostRE = regexp.MustCompile(`(?i)(?:(?:[a-z0-9-]+\.)*telegram\.(?:org|me|com|dog)|\bt\.me)([^a-z0-9]|$)`)
officialBrandRE = regexp.MustCompile(`(?i)telegram|телеграм[\p{L}]*|تيليجرام|تلگرام|텔레그램|טלגרם`)
technicalIDRE = regexp.MustCompile(`^[A-Za-z0-9-]+(?:[._][A-Za-z0-9-]+)+$`)
)
// UserVisibleText replaces the official product brand and its public hosts in
// text returned to clients. Placeholder syntax, markup and string keys are
// deliberately untouched by callers; only values should pass through here.
func UserVisibleText(value, publicBaseURL string) string {
if value == "" {
return ""
}
baseURL, publicHost := publicDestination(publicBaseURL)
value = officialHTTPHostRE.ReplaceAllString(value, baseURL+"${1}")
value = officialBareHostRE.ReplaceAllString(value, publicHost+"${1}")
// Some platform packs carry dotted or underscored runtime identifiers as
// values. They are not copy and changing them can break client navigation.
if technicalIDRE.MatchString(value) {
return value
}
return officialBrandRE.ReplaceAllString(value, ProductName)
}
func publicDestination(raw string) (string, string) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
raw = DefaultPublicURL
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
raw = DefaultPublicURL
parsed, _ = url.Parse(raw)
}
return raw, parsed.Host
}

View file

@ -0,0 +1,64 @@
package branding
import "testing"
func TestUserVisibleTextRebrandsWordsAndOfficialHosts(t *testing.T) {
got := UserVisibleText(
"Telegram telegram TELEGRAM Telegram-like https://translations.telegram.org/en t.me/example desktop.telegram.org",
"https://chat.example/root/",
)
want := "Telesrv Telesrv Telesrv Telesrv-like https://chat.example/root/en chat.example/example chat.example"
if got != want {
t.Fatalf("UserVisibleText() = %q, want %q", got, want)
}
}
func TestUserVisibleTextPreservesTechnicalIdentifiers(t *testing.T) {
for _, value := range []string{
"org.telegram.messenger",
"telegram_antispam_user_id",
"telegram_aicomposetone",
} {
if got := UserVisibleText(value, ""); got != value {
t.Fatalf("UserVisibleText(%q) = %q, want unchanged", value, got)
}
}
}
func TestUserVisibleTextRebrandsBareOfficialHostsWithoutTouchingDottedIdentifiers(t *testing.T) {
for input, want := range map[string]string{
"telegram.org": "telesrv.net",
"desktop.telegram.org": "telesrv.net",
"t.me/example": "telesrv.net/example",
"org.telegram.messenger": "org.telegram.messenger",
} {
if got := UserVisibleText(input, ""); got != want {
t.Fatalf("UserVisibleText(%q) = %q, want %q", input, got, want)
}
}
}
func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
got := UserVisibleText("Телеграмом تيليجرام تلگرام 텔레그램 טלגרם", "")
if want := "Telesrv Telesrv Telesrv Telesrv Telesrv"; got != want {
t.Fatalf("UserVisibleText() = %q, want %q", got, want)
}
}
func TestClientPresentationNames(t *testing.T) {
for platform, want := range map[string]string{
"tdesktop": DesktopAppName,
"android": AndroidAppName,
"ios": IOSAppName,
"macos": MacOSAppName,
"telegram-tt": WebAAppName,
"tweb": WebKAppName,
} {
if got := ClientAppName(platform); got != want {
t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want)
}
}
if got := UserVisibleClientPlatform("telegram-tt"); got != "weba" {
t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got)
}
}

View file

@ -0,0 +1,20 @@
package domain
import (
"strings"
"testing"
)
func TestServiceIdentityAndLoginMessageUseTelesrvBrand(t *testing.T) {
serviceUser := OfficialSystemUser()
if serviceUser.FirstName != "Telesrv" || serviceUser.Username != "telesrv" {
t.Fatalf("service user = %+v, want Telesrv identity", serviceUser)
}
message, err := OfficialLoginCodeMessage(42, "12345", 1)
if err != nil {
t.Fatalf("build login message: %v", err)
}
if !strings.Contains(message.Body, "Telesrv") || strings.Contains(strings.ToLower(message.Body), "telegram") {
t.Fatalf("login message exposes wrong brand: %q", message.Body)
}
}

View file

@ -4,11 +4,13 @@ import (
"fmt" "fmt"
"math" "math"
"strings" "strings"
"telesrv/internal/branding"
) )
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram! const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your Telegram account. We never ask it for anything else. This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.` If you didn't request this code by trying to log in on another device, simply ignore this message.`

View file

@ -1,5 +1,7 @@
package domain package domain
import "telesrv/internal/branding"
const ( const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。 // OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
OfficialSystemUserID int64 = 777000 OfficialSystemUserID int64 = 777000
@ -27,8 +29,8 @@ func OfficialSystemUser() User {
ID: OfficialSystemUserID, ID: OfficialSystemUserID,
AccessHash: 6599886787491911851, AccessHash: 6599886787491911851,
Phone: "42777", Phone: "42777",
FirstName: "Telegram", FirstName: branding.ProductName,
Username: "telegram", Username: branding.ProductUsername,
Verified: true, Verified: true,
Support: true, Support: true,
} }

View file

@ -16,6 +16,8 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"telesrv/internal/branding"
) )
const manifestSchema = 2 const manifestSchema = 2
@ -299,7 +301,7 @@ func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) {
gift := snap.gifts[id] gift := snap.gifts[id]
doc := snap.documents[gift.DocumentIDs[0]] doc := snap.documents[gift.DocumentIDs[0]]
summary := GiftSummary{ summary := GiftSummary{
ID: id, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars, ID: id, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale, AvailabilityRemains: gift.AvailabilityRemains, AvailabilityResale: gift.AvailabilityResale,
Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday, Limited: gift.Limited, SoldOut: gift.SoldOut, Birthday: gift.Birthday,
@ -308,7 +310,7 @@ func (c *Catalog) List(ctx context.Context) ([]GiftSummary, error) {
FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate, FirstSaleDate: gift.FirstSaleDate, LastSaleDate: gift.LastSaleDate,
ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal, ResellMinStars: gift.ResellMinStars, PerUserTotal: gift.PerUserTotal,
PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate, PerUserRemains: gift.PerUserRemains, LockedUntilDate: gift.LockedUntilDate,
AuctionSlug: gift.AuctionSlug, GiftsPerRound: gift.GiftsPerRound, AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""), GiftsPerRound: gift.GiftsPerRound,
AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants, AuctionStartDate: gift.AuctionStartDate, UpgradeVariants: gift.UpgradeVariants,
Background: cloneBackground(gift.Background), DocumentID: doc.ID64, Background: cloneBackground(gift.Background), DocumentID: doc.ID64,
AnimationValidated: doc.AnimationValidated, AnimationValidated: doc.AnimationValidated,
@ -342,7 +344,7 @@ func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible b
out := Bundle{ out := Bundle{
ManifestSHA256: append([]byte(nil), snap.manifestSHA...), ManifestSHA256: append([]byte(nil), snap.manifestSHA...),
SourceJSON: append([]byte(nil), gift.SourceJSON...), SourceJSON: append([]byte(nil), gift.SourceJSON...),
Gift: Gift{ID: gift.ID, Title: gift.Title, Stars: gift.Stars, ConvertStars: gift.ConvertStars, Gift: Gift{ID: gift.ID, Title: branding.UserVisibleText(gift.Title, ""), Stars: gift.Stars, ConvertStars: gift.ConvertStars,
UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal, UpgradeStars: gift.UpgradeStars, AvailabilityTotal: gift.AvailabilityTotal,
AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut, AvailabilityRemains: gift.AvailabilityRemains, Limited: gift.Limited, SoldOut: gift.SoldOut,
Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser, Birthday: gift.Birthday, RequirePremium: gift.RequirePremium, LimitedPerUser: gift.LimitedPerUser,
@ -350,7 +352,7 @@ func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible b
AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate, AvailabilityResale: gift.AvailabilityResale, FirstSaleDate: gift.FirstSaleDate,
LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars, LastSaleDate: gift.LastSaleDate, ResellMinStars: gift.ResellMinStars,
PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains, PerUserTotal: gift.PerUserTotal, PerUserRemains: gift.PerUserRemains,
LockedUntilDate: gift.LockedUntilDate, AuctionSlug: gift.AuctionSlug, LockedUntilDate: gift.LockedUntilDate, AuctionSlug: branding.UserVisibleText(gift.AuctionSlug, ""),
GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate, GiftsPerRound: gift.GiftsPerRound, AuctionStartDate: gift.AuctionStartDate,
UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background), UpgradeVariants: gift.UpgradeVariants, Background: cloneBackground(gift.Background),
DocumentID: gift.DocumentIDs[0]}, DocumentID: gift.DocumentIDs[0]},
@ -372,17 +374,17 @@ func (c *Catalog) Bundle(ctx context.Context, giftID int64, includeCollectible b
if err != nil { if err != nil {
return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err) return Bundle{}, fmt.Errorf("model %q document %d: %w", value.Name, value.DocumentID, err)
} }
collectible.Models = append(collectible.Models, Model{Name: value.Name, DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc}) collectible.Models = append(collectible.Models, Model{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Crafted: value.Crafted, Rarity: value.Rarity, Document: doc})
} }
for _, value := range set.Patterns { for _, value := range set.Patterns {
doc, err := c.readDocument(ctx, snap.documents[value.DocumentID]) doc, err := c.readDocument(ctx, snap.documents[value.DocumentID])
if err != nil { if err != nil {
return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err) return Bundle{}, fmt.Errorf("pattern %q document %d: %w", value.Name, value.DocumentID, err)
} }
collectible.Patterns = append(collectible.Patterns, Pattern{Name: value.Name, DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc}) collectible.Patterns = append(collectible.Patterns, Pattern{Name: branding.UserVisibleText(value.Name, ""), DocumentID: value.DocumentID, Rarity: value.Rarity, Document: doc})
} }
for _, value := range set.Backdrops { for _, value := range set.Backdrops {
collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: value.Name, BackdropID: value.BackdropID, collectible.Backdrops = append(collectible.Backdrops, Backdrop{Name: branding.UserVisibleText(value.Name, ""), BackdropID: value.BackdropID,
CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor, CenterColor: value.CenterColor, EdgeColor: value.EdgeColor, PatternColor: value.PatternColor,
TextColor: value.TextColor, Rarity: value.Rarity}) TextColor: value.TextColor, Rarity: value.Rarity})
} }

View file

@ -21,7 +21,7 @@ func TestCatalogVerifiesSelectedDocument(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
value := manifest{Schema: manifestSchema, GiftCount: 1, value := manifest{Schema: manifestSchema, GiftCount: 1,
Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}}, Gifts: []giftManifest{{Index: 0, Kind: "regular", ID: 1, Title: "Telegram Pin", Stars: 10, ConvertStars: 5, DocumentIDs: []int64{10}}},
Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}}, Documents: []documentManifest{{ID64: 10, FileName: "gift.tgs", File: fileArtifact{Path: "documents/10.tgs", Size: int64(len(data)), SHA256: hex.EncodeToString(sum[:])}}},
} }
raw, err := json.Marshal(value) raw, err := json.Marshal(value)
@ -33,11 +33,11 @@ func TestCatalogVerifiesSelectedDocument(t *testing.T) {
} }
catalog := New(root) catalog := New(root)
items, err := catalog.List(context.Background()) items, err := catalog.List(context.Background())
if err != nil || len(items) != 1 || items[0].ID != 1 { if err != nil || len(items) != 1 || items[0].ID != 1 || items[0].Title != "Telesrv Pin" {
t.Fatalf("items=%+v err=%v", items, err) t.Fatalf("items=%+v err=%v", items, err)
} }
bundle, err := catalog.Bundle(context.Background(), 1, false) bundle, err := catalog.Bundle(context.Background(), 1, false)
if err != nil || string(bundle.BaseDocument.Data) != string(data) { if err != nil || string(bundle.BaseDocument.Data) != string(data) || bundle.Gift.Title != "Telesrv Pin" {
t.Fatalf("bundle=%+v err=%v", bundle, err) t.Fatalf("bundle=%+v err=%v", bundle, err)
} }
if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "documents", "10.tgs"), []byte("tampered---"), 0o644); err != nil {

View file

@ -8,6 +8,7 @@ import (
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/branding"
ioscompat "telesrv/internal/compat/ios" ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop" "telesrv/internal/compat/tdesktop"
"telesrv/internal/domain" "telesrv/internal/domain"
@ -1889,12 +1890,12 @@ func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int)
Current: a.AuthKeyID == currentAuthKeyID, Current: a.AuthKeyID == currentAuthKeyID,
OfficialApp: true, OfficialApp: true,
Hash: a.Hash, Hash: a.Hash,
DeviceModel: a.DeviceModel, DeviceModel: branding.UserVisibleText(a.DeviceModel, ""),
Platform: a.Platform, Platform: branding.UserVisibleClientPlatform(a.Platform),
SystemVersion: a.SystemVersion, SystemVersion: branding.UserVisibleText(a.SystemVersion, ""),
APIID: a.APIID, APIID: a.APIID,
AppName: "Telegram Desktop", AppName: branding.ClientAppName(a.Platform),
AppVersion: a.AppVersion, AppVersion: branding.UserVisibleText(a.AppVersion, ""),
DateCreated: created, DateCreated: created,
DateActive: active, DateActive: active,
IP: a.IP, IP: a.IP,

View file

@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -42,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
Hash: aiComposeToneWebPageHash(tone), Hash: aiComposeToneWebPageHash(tone),
Date: int(now.Unix()), Date: int(now.Unix()),
Type: aiComposeToneWebPageType, Type: aiComposeToneWebPageType,
SiteName: "Telegram", SiteName: branding.ProductName,
Title: tone.Title, Title: tone.Title,
Description: tone.Prompt, Description: tone.Prompt,
ComposeToneEmojiID: tone.EmojiID, ComposeToneEmojiID: tone.EmojiID,

View file

@ -15,6 +15,7 @@ import (
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/app/auth" "telesrv/internal/app/auth"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -926,13 +927,13 @@ func (r *Router) tgSignInServiceNotification(ctx context.Context, u domain.User,
if ci, ok := ClientInfoFrom(ctx); ok { if ci, ok := ClientInfoFrom(ctx); ok {
parts := []string{} parts := []string{}
if ci.DeviceModel != "" { if ci.DeviceModel != "" {
parts = append(parts, ci.DeviceModel) parts = append(parts, branding.UserVisibleText(ci.DeviceModel, ""))
} }
if ci.SystemVersion != "" { if ci.SystemVersion != "" {
parts = append(parts, ci.SystemVersion) parts = append(parts, branding.UserVisibleText(ci.SystemVersion, ""))
} }
if ci.AppVersion != "" { if ci.AppVersion != "" {
parts = append(parts, ci.AppVersion) parts = append(parts, branding.UserVisibleText(ci.AppVersion, ""))
} }
if len(parts) > 0 { if len(parts) > 0 {
client = strings.Join(parts, " / ") client = strings.Join(parts, " / ")

View file

@ -7,6 +7,7 @@ import (
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/branding"
androidcompat "telesrv/internal/compat/android" androidcompat "telesrv/internal/compat/android"
ioscompat "telesrv/internal/compat/ios" ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop" "telesrv/internal/compat/tdesktop"
@ -21,7 +22,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
return tdesktop.NearestDC(r.cfg.DC), nil return tdesktop.NearestDC(r.cfg.DC), nil
}) })
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) { registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
}) })
registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) { registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
return r.onHelpSaveAppLog(ctx) return r.onHelpSaveAppLog(ctx)
@ -182,7 +183,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。 // 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
promo := &tg.HelpPremiumPromo{ promo := &tg.HelpPremiumPromo{
StatusText: "Telegram Premium is not active on this account.", StatusText: branding.PremiumName + " is not active on this account.",
StatusEntities: []tg.MessageEntityClass{}, StatusEntities: []tg.MessageEntityClass{},
VideoSections: []string{}, VideoSections: []string{},
Videos: []tg.DocumentClass{}, Videos: []tg.DocumentClass{},
@ -199,7 +200,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
} }
if u.PremiumActiveAt(r.clock.Now().Unix()) { if u.PremiumActiveAt(r.clock.Now().Unix()) {
until := time.Unix(int64(u.PremiumUntil), 0) until := time.Unix(int64(u.PremiumUntil), 0)
promo.StatusText = "Telegram Premium is active until " + until.Format("2006-01-02") + "." promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
} }
return promo, nil return promo, nil
} }

View file

@ -213,7 +213,7 @@ func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t
if !ok { if !ok {
t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueAdsAccountURL", got) t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueAdsAccountURL", got)
} }
if url.URL != "https://ads.telegram.org/" { if url.URL != "https://telesrv.net" {
t.Fatalf("url = %q, want ads compat URL", url.URL) t.Fatalf("url = %q, want ads compat URL", url.URL)
} }

View file

@ -143,7 +143,7 @@ func (r *Router) registerPayments(d *tlprofile.Dispatcher) {
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
return nil, err return nil, err
} }
return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil return &tg.PaymentsStarsRevenueAdsAccountURL{URL: r.publicLink("")}, nil
}) })
registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) { registerRPC[*tg.PaymentsGetStarsRevenueStatsRequest](d, tlprofile.SemanticMethodPaymentsGetStarsRevenueStats, func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (any, error) {
return r.onPaymentsGetStarsRevenueStats(ctx, req) return r.onPaymentsGetStarsRevenueStats(ctx, req)

View file

@ -12,6 +12,7 @@ import (
"github.com/iamxvbaba/td/tgerr" "github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap" "go.uber.org/zap"
"telesrv/internal/branding"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -324,11 +325,11 @@ func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePayme
return &tg.PaymentsPaymentFormStars{ return &tg.PaymentsPaymentFormStars{
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount), FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
BotID: domain.OfficialSystemUserID, BotID: domain.OfficialSystemUserID,
Title: "Telegram Stars", Title: branding.StarsName,
Description: "telesrv dev Stars top-up", Description: "telesrv dev Stars top-up",
Invoice: tg.Invoice{ Invoice: tg.Invoice{
Currency: "XTR", Currency: "XTR",
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}}, Prices: []tg.LabeledPrice{{Label: branding.StarsName, Amount: purpose.Stars}},
}, },
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}), Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
} }

View file

@ -215,7 +215,7 @@ func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) {
if update.Popup || update.InboxDate == 0 || update.Media == nil { if update.Popup || update.InboxDate == 0 || update.Media == nil {
t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media) t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media)
} }
for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} { for _, want := range []string{"New login.", "Test User", "Telesrv Desktop", "Settings > Devices"} {
if !strings.Contains(update.Message, want) { if !strings.Contains(update.Message, want) {
t.Fatalf("notification message %q missing %q", update.Message, want) t.Fatalf("notification message %q missing %q", update.Message, want)
} }

View file

@ -1,5 +1,5 @@
{ {
"source": "official telegram (appearancefetch)", "source": "upstream appearance snapshot",
"exported_at": "2026-06-25T14:04:03Z", "exported_at": "2026-06-25T14:04:03Z",
"notes": { "notes": {
"server": "official", "server": "official",
@ -3788,7 +3788,7 @@
}, },
{ {
"kind": "filename", "kind": "filename",
"file_name": "background telegram 003.jpg" "file_name": "background telesrv 003.jpg"
} }
], ],
"thumbs": [ "thumbs": [
@ -5834,4 +5834,4 @@
} }
} }
] ]
} }

View file

@ -66,9 +66,29 @@ func ensureOfficialSystemUserWithDB(ctx context.Context, db sqlcgen.DBTX, msg do
return nil return nil
} }
if _, err := db.Exec(ctx, ` if _, err := db.Exec(ctx, `
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version) WITH upserted AS (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version)
ON CONFLICT (id) DO NOTHING VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
country_code = EXCLUDED.country_code,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = EXCLUDED.bot_info_version,
updated_at = now()
RETURNING id, lower(username) AS username_lower
), deleted_old_username AS (
DELETE FROM peer_usernames
WHERE peer_type = 'user' AND peer_id = (SELECT id FROM upserted)
)
INSERT INTO peer_usernames (username_lower, peer_type, peer_id)
SELECT username_lower, 'user', id FROM upserted
`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil { `, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil {
return fmt.Errorf("ensure official system user: %w", err) return fmt.Errorf("ensure official system user: %w", err)
} }

View file

@ -23,28 +23,30 @@ import (
"math/big" "math/big"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"telesrv/internal/branding"
) )
// 验证错误。调用方据此映射到 TL 错误码/拒绝登录。 // 验证错误。调用方据此映射到 TL 错误码/拒绝登录。
var ( var (
ErrClientDataInvalid = errors.New("webauthn: client data invalid") ErrClientDataInvalid = errors.New("webauthn: client data invalid")
ErrChallengeMismatch = errors.New("webauthn: challenge mismatch") ErrChallengeMismatch = errors.New("webauthn: challenge mismatch")
ErrOriginNotAllowed = errors.New("webauthn: origin not allowed") ErrOriginNotAllowed = errors.New("webauthn: origin not allowed")
ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch") ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch")
ErrUserNotPresent = errors.New("webauthn: user-present flag not set") ErrUserNotPresent = errors.New("webauthn: user-present flag not set")
ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid") ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid")
ErrAttestationInvalid = errors.New("webauthn: attestation object invalid") ErrAttestationInvalid = errors.New("webauthn: attestation object invalid")
ErrPublicKeyUnsupported = errors.New("webauthn: unsupported public key algorithm") ErrPublicKeyUnsupported = errors.New("webauthn: unsupported public key algorithm")
ErrSignatureInvalid = errors.New("webauthn: signature invalid") ErrSignatureInvalid = errors.New("webauthn: signature invalid")
ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)") ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)")
) )
// authenticatorData 标志位(WebAuthn §6.1)。 // authenticatorData 标志位(WebAuthn §6.1)。
const ( const (
flagUserPresent = 0x01 flagUserPresent = 0x01
flagUserVerified = 0x04 flagUserVerified = 0x04
flagAttestedCredData = 0x40 flagAttestedCredData = 0x40
flagExtensionData = 0x80 flagExtensionData = 0x80
) )
// COSE algorithm identifiers。 // COSE algorithm identifiers。
@ -55,8 +57,8 @@ const (
// COSE key type / curve。 // COSE key type / curve。
const ( const (
coseKtyOKP = 1 coseKtyOKP = 1
coseKtyEC2 = 2 coseKtyEC2 = 2
coseCrvP256 = 1 coseCrvP256 = 1
coseCrvEd25519 = 6 coseCrvEd25519 = 6
) )
@ -76,7 +78,7 @@ func decodeB64URL(s string) ([]byte, error) {
type RegistrationParams struct { type RegistrationParams struct {
RPID string RPID string
RPName string RPName string
UserID []byte // 通常是 "dcId:userId" 的字节 UserID []byte // 通常是 "dcId:userId" 的字节
UserName string UserName string
UserDisplay string UserDisplay string
Challenge []byte Challenge []byte
@ -99,7 +101,7 @@ func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) {
exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)}) exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)})
} }
pub := map[string]any{ pub := map[string]any{
"rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, "Telegram")}, "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName)},
"user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)}, "user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)},
"challenge": b64.EncodeToString(p.Challenge), "challenge": b64.EncodeToString(p.Challenge),
"pubKeyCredParams": []map[string]any{ "pubKeyCredParams": []map[string]any{
@ -199,12 +201,12 @@ func ChallengeFromClientData(clientDataJSON []byte) ([]byte, error) {
// parsedAuthData 是 authenticatorData 的解析结果。 // parsedAuthData 是 authenticatorData 的解析结果。
type parsedAuthData struct { type parsedAuthData struct {
rpIDHash []byte rpIDHash []byte
flags byte flags byte
signCount uint32 signCount uint32
aaguid []byte aaguid []byte
credID []byte credID []byte
credPubKey []byte // COSE 公钥原始字节(仅注册时存在) credPubKey []byte // COSE 公钥原始字节(仅注册时存在)
} }
func parseAuthData(authData []byte) (parsedAuthData, error) { func parseAuthData(authData []byte) (parsedAuthData, error) {
@ -351,8 +353,8 @@ func VerifyAssertion(in VerifyAssertionInput) (uint32, error) {
// publicKey 抽象 ES256/EdDSA 验签。 // publicKey 抽象 ES256/EdDSA 验签。
type publicKey struct { type publicKey struct {
ec *ecdsa.PublicKey ec *ecdsa.PublicKey
ed ed25519.PublicKey ed ed25519.PublicKey
} }
func (p publicKey) verify(signed, sig []byte) error { func (p publicKey) verify(signed, sig []byte) error {