fix: sync WebK compatibility paths

This commit is contained in:
A 2026-07-15 16:07:43 +08:00
parent 766c5db992
commit 76bfc5100f
12 changed files with 387 additions and 8 deletions

View file

@ -14,7 +14,7 @@ import (
// (记录于 docs/compatibility-matrix.md
func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL string) *tg.Config {
meURLPrefix := links.NormalizeBaseURL(publicBaseURL) + "/"
return &tg.Config{
config := &tg.Config{
Date: int(now.Unix()),
Expires: int(now.Add(time.Hour).Unix()),
TestMode: false,
@ -55,6 +55,8 @@ func BuildConfig(dc int, ip string, port int, now time.Time, publicBaseURL strin
MessageLengthMax: 4096,
WebfileDCID: dc,
}
config.SetReactionsDefault(&tg.ReactionEmoji{Emoticon: DefaultReactionEmoticon})
return config
}
// NearestDC 构造 help.getNearestDc 返回值。

View file

@ -0,0 +1,20 @@
package tdesktop
import (
"testing"
"time"
"github.com/iamxvbaba/td/tg"
)
func TestBuildConfigIncludesDefaultReaction(t *testing.T) {
config := BuildConfig(2, "127.0.0.1", 2398, time.Unix(1, 0), "https://telesrv.net")
reaction, ok := config.GetReactionsDefault()
if !ok {
t.Fatal("reactions_default is absent")
}
emoji, ok := reaction.(*tg.ReactionEmoji)
if !ok || emoji.Emoticon != DefaultReactionEmoticon {
t.Fatalf("reactions_default = %#v, want %q emoji", reaction, DefaultReactionEmoticon)
}
}

View file

@ -194,7 +194,7 @@ func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass {
}
}
// WallPapers returns the read-only Default wallpaper catalog. User wallpaper
// WallPapers returns the read-only default wallpaper catalog. User wallpaper
// upload/save/install remains outside the current TDesktop compatibility scope.
func WallPapers(hash int64) tg.AccountWallPapersClass {
if hash == wallPapersHash {
@ -256,13 +256,17 @@ func DefaultGroupPhotoEmojis() tg.EmojiListClass {
const availableReactionsHash = 20260602
const emptyStickerSetHash = 20260602
// DefaultReactionEmoticon is the account fallback used by config and the
// built-in available-reactions catalog.
const DefaultReactionEmoticon = "\U0001f44d"
type defaultReaction struct {
emoticon string
title string
}
var defaultAvailableReactions = []defaultReaction{
{emoticon: "\U0001f44d", title: "Thumbs Up"},
{emoticon: DefaultReactionEmoticon, title: "Thumbs Up"},
{emoticon: "\u2764\ufe0f", title: "Red Heart"},
{emoticon: "\U0001f602", title: "Face With Tears of Joy"},
{emoticon: "\U0001f62e", title: "Face With Open Mouth"},

View file

@ -1100,7 +1100,7 @@ func privacyErr(err error) error {
}
type accountReactionSettingsService interface {
GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error)
accountReactionSettingsReader
SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error)
}

View file

@ -0,0 +1,106 @@
package rpc
import (
"context"
"strings"
"testing"
"unicode/utf8"
)
func TestNormalizeClientInfoBoundsMetadataAfterClassification(t *testing.T) {
raw := ClientInfo{
APIID: 2040,
DeviceModel: "Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 Safari/604.1 " + strings.Repeat("x", 160),
SystemVersion: strings.Repeat("iOS", 30),
AppVersion: strings.Repeat("2.2", 30),
SystemLangCode: strings.Repeat("zh-", 30),
LangPack: "webk" + strings.Repeat("x", 80),
LangCode: strings.Repeat("zh-", 30),
}
got := normalizeClientInfo(raw)
if got.ClientType() != ClientTypeTWeb {
t.Fatalf("client type = %s, want %s", got.ClientType(), ClientTypeTWeb)
}
assertClientMetadataRunes(t, "device model", got.DeviceModel, maxClientDeviceModelRunes)
assertClientMetadataRunes(t, "system version", got.SystemVersion, maxClientMetadataRunes)
assertClientMetadataRunes(t, "app version", got.AppVersion, maxClientMetadataRunes)
assertClientMetadataRunes(t, "system language", got.SystemLangCode, maxClientMetadataRunes)
assertClientMetadataRunes(t, "language pack", got.LangPack, maxClientMetadataRunes)
assertClientMetadataRunes(t, "language code", got.LangCode, maxClientMetadataRunes)
}
func TestNormalizeClientInfoUsesRuneLimitsAndProducesValidUTF8(t *testing.T) {
got := normalizeClientInfo(ClientInfo{
DeviceModel: strings.Repeat("\u754c", maxClientDeviceModelRunes+1),
SystemVersion: strings.Repeat("\U0001f4f1", maxClientMetadataRunes+1),
AppVersion: "2.2" + string([]byte{0xff}),
LangPack: "webk",
})
assertClientMetadataRunes(t, "device model", got.DeviceModel, maxClientDeviceModelRunes)
assertClientMetadataRunes(t, "system version", got.SystemVersion, maxClientMetadataRunes)
if !utf8.ValidString(got.AppVersion) {
t.Fatalf("app version is not valid UTF-8: %q", got.AppVersion)
}
}
func TestNormalizedClientInfoIsSharedByAuthPersistencePaths(t *testing.T) {
authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8}
ctx := WithLayer(context.Background(), 227)
ctx = WithAuthKeyID(ctx, authKeyID)
ctx = WithClientInfo(ctx, ClientInfo{
APIID: 2040,
DeviceModel: "Mozilla/5.0 AppleWebKit/605.1.15 " + strings.Repeat("x", 160),
SystemVersion: strings.Repeat("iOS", 30),
AppVersion: strings.Repeat("2.2", 30),
LangPack: "webk",
})
info, ok := ClientInfoFrom(ctx)
if !ok {
t.Fatal("normalized client info missing from context")
}
authz := (&Router{}).authzFromCtx(ctx)
authKeyInfo := domainAuthKeyClientInfo(clientSessionInfo{
layer: LayerFrom(ctx),
hasClientInfo: true,
clientInfo: info,
})
if authz.DeviceModel != authKeyInfo.DeviceModel ||
authz.SystemVersion != authKeyInfo.SystemVersion ||
authz.AppVersion != authKeyInfo.AppVersion ||
authz.Platform != authKeyInfo.Platform ||
authz.APIID != authKeyInfo.APIID ||
authz.Layer != authKeyInfo.Layer {
t.Fatalf("authorization metadata %+v differs from auth-key metadata %+v", authz, authKeyInfo)
}
}
func TestNormalizeClientInfoPreservesMetadataWithinLimits(t *testing.T) {
raw := ClientInfo{
APIID: 4,
DeviceModel: "Google Pixel 9",
SystemVersion: "SDK 36",
AppVersion: "12.7.3",
SystemLangCode: "en-US",
LangPack: "android",
LangCode: "en",
}
got := normalizeClientInfo(raw)
if got.DeviceModel != raw.DeviceModel || got.SystemVersion != raw.SystemVersion ||
got.AppVersion != raw.AppVersion || got.SystemLangCode != raw.SystemLangCode ||
got.LangPack != raw.LangPack || got.LangCode != raw.LangCode {
t.Fatalf("metadata within limits changed: got %+v, want %+v", got, raw)
}
}
func assertClientMetadataRunes(t *testing.T, field, value string, want int) {
t.Helper()
if got := utf8.RuneCountInString(value); got != want {
t.Fatalf("%s rune count = %d, want %d", field, got, want)
}
if !utf8.ValidString(value) {
t.Fatalf("%s is not valid UTF-8", field)
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"regexp"
"strings"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
)
@ -35,6 +36,11 @@ func inboundRPCBytesFrom(ctx context.Context) int {
const currentClientLayer = tg.Layer
const (
maxClientDeviceModelRunes = 128
maxClientMetadataRunes = 64
)
var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`)
type ClientType string
@ -98,11 +104,33 @@ func ClientTypeFrom(ctx context.Context) ClientType {
}
func normalizeClientInfo(info ClientInfo) ClientInfo {
// Classify against the complete wire metadata before bounding the values
// shared by auth-key and authorization persistence.
info.Type = detectClientType(info)
info.DeviceModel = truncateClientMetadata(info.DeviceModel, maxClientDeviceModelRunes)
info.SystemVersion = truncateClientMetadata(info.SystemVersion, maxClientMetadataRunes)
info.AppVersion = truncateClientMetadata(info.AppVersion, maxClientMetadataRunes)
info.SystemLangCode = truncateClientMetadata(info.SystemLangCode, maxClientMetadataRunes)
info.LangPack = truncateClientMetadata(info.LangPack, maxClientMetadataRunes)
info.LangCode = truncateClientMetadata(info.LangCode, maxClientMetadataRunes)
info.typeResolved = true
return info
}
func truncateClientMetadata(value string, maxRunes int) string {
if maxRunes <= 0 || value == "" {
return ""
}
if utf8.ValidString(value) && utf8.RuneCountInString(value) <= maxRunes {
return value
}
runes := []rune(value)
if len(runes) > maxRunes {
runes = runes[:maxRunes]
}
return string(runes)
}
func (info ClientInfo) ClientType() ClientType {
if info.typeResolved {
if knownClientType(info.Type) {

View file

@ -13,9 +13,7 @@ import (
// registerHelp 注册 help.* RPC handlerDC 配置、最近 DC
func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetConfig(func(ctx context.Context) (*tg.Config, error) {
return tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL), nil
})
d.OnHelpGetConfig(r.onHelpGetConfig)
d.OnHelpGetNearestDC(func(ctx context.Context) (*tg.NearestDC, error) {
return tdesktop.NearestDC(r.cfg.DC), nil
})
@ -81,6 +79,29 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetPremiumPromo(r.onHelpGetPremiumPromo)
}
func (r *Router) onHelpGetConfig(ctx context.Context) (*tg.Config, error) {
config := tdesktop.BuildConfig(r.cfg.DC, r.cfg.IP, r.cfg.Port, r.clock.Now(), r.cfg.PublicBaseURL)
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return config, nil
}
if svc, ok := r.deps.Account.(accountReactionSettingsReader); ok {
settings, err := svc.GetReactionSettings(ctx, userID)
if err != nil {
return nil, internalErr()
}
reaction := tgMessageReaction(settings.DefaultReaction)
if reaction == nil {
return nil, internalErr()
}
config.SetReactionsDefault(reaction)
}
return config, nil
}
// onHelpDismissSuggestion 为 DrKLO 改号成功后的 suggestion 清理提供有界兼容。
// Android 会先把 suggestion 从本地状态删除,再发送该 RPC且 generic 500 会被
// 连接层持续重试。当前 server 不发布 pending suggestions故非空 dismissal

View file

@ -10,8 +10,12 @@ type accountDefaultReactionService interface {
SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error)
}
type accountPaidReactionPrivacyService interface {
type accountReactionSettingsReader interface {
GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error)
}
type accountPaidReactionPrivacyService interface {
accountReactionSettingsReader
SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error)
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
@ -18,6 +19,9 @@ func (r *Router) onMessagesSetDefaultReaction(ctx context.Context, reaction tg.R
if err != nil {
return false, err
}
if err := r.validateDefaultReaction(ctx, parsed); err != nil {
return false, err
}
if svc, ok := r.deps.Account.(accountDefaultReactionService); ok {
if _, err := svc.SetDefaultReaction(ctx, userID, parsed); err != nil {
return false, internalErr()
@ -26,6 +30,37 @@ func (r *Router) onMessagesSetDefaultReaction(ctx context.Context, reaction tg.R
return true, nil
}
func (r *Router) validateDefaultReaction(ctx context.Context, reaction domain.MessageReaction) error {
if reaction.Type == domain.MessageReactionCustomEmoji {
return nil
}
if reaction.Type != domain.MessageReactionEmoji {
return reactionInvalidErr()
}
if r.deps.Files != nil {
catalog, err := r.deps.Files.ListAvailableReactions(ctx)
if err != nil {
return internalErr()
}
if len(catalog) > 0 {
for _, item := range catalog {
if !item.Inactive && strings.TrimSpace(item.Reaction) == reaction.Emoticon {
return nil
}
}
return reactionInvalidErr()
}
}
for _, item := range staticReactionCatalog() {
if item.Key() == reaction.Key() {
return nil
}
}
return reactionInvalidErr()
}
func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {

View file

@ -0,0 +1,25 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
)
// onMessagesReceivedMessages acknowledges the client's highest observed
// message id. telesrv does not enqueue mobile PUSH notifications, so there are
// no pending notifications to cancel and the exact cancellation set is empty.
// This acknowledgement must never advance read boundaries or update state.
func (r *Router) onMessagesReceivedMessages(ctx context.Context, _ int) ([]tg.ReceivedNotifyMessage, error) {
userID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || userID == 0 {
return nil, authKeyUnregisteredErr()
}
if r.userIsBot(ctx, userID) {
return nil, botMethodInvalidErr()
}
return []tg.ReceivedNotifyMessage{}, nil
}

View file

@ -0,0 +1,133 @@
package rpc
import (
"context"
"errors"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"go.uber.org/zap/zaptest"
"telesrv/internal/domain"
)
type reactionSettingsAccountStub struct {
AccountService
settings domain.AccountReactionSettings
getErr error
setCalls int
}
func (s *reactionSettingsAccountStub) GetReactionSettings(context.Context, int64) (domain.AccountReactionSettings, error) {
return s.settings, s.getErr
}
func (s *reactionSettingsAccountStub) SetDefaultReaction(_ context.Context, _ int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) {
s.setCalls++
s.settings.DefaultReaction = reaction
return s.settings, nil
}
type reactionCatalogErrorFiles struct {
FilesService
}
func (reactionCatalogErrorFiles) ListAvailableReactions(context.Context) ([]domain.AvailableReaction, error) {
return nil, errors.New("catalog unavailable")
}
func TestHelpGetConfigReturnsDefaultAndAccountReaction(t *testing.T) {
account := &reactionSettingsAccountStub{settings: domain.DefaultAccountReactionSettings()}
r := New(Config{DC: 2, PublicBaseURL: "https://telesrv.net"}, Deps{Account: account}, zaptest.NewLogger(t), clock.System)
preAuth, err := r.onHelpGetConfig(context.Background())
if err != nil {
t.Fatalf("pre-auth help.getConfig: %v", err)
}
if emoji, ok := preAuth.ReactionsDefault.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" {
t.Fatalf("pre-auth reactions_default = %#v, want thumbs up", preAuth.ReactionsDefault)
}
account.settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 7001}
authorized, err := r.onHelpGetConfig(WithUserID(context.Background(), 42))
if err != nil {
t.Fatalf("authorized help.getConfig: %v", err)
}
if custom, ok := authorized.ReactionsDefault.(*tg.ReactionCustomEmoji); !ok || custom.DocumentID != 7001 {
t.Fatalf("authorized reactions_default = %#v, want custom emoji 7001", authorized.ReactionsDefault)
}
}
func TestSetDefaultReactionRequiresActiveCatalogEmoji(t *testing.T) {
account := &reactionSettingsAccountStub{settings: domain.DefaultAccountReactionSettings()}
files := &fakeFiles{reactions: []domain.AvailableReaction{
{Reaction: "\U0001f525"},
{Reaction: "\U0001f602", Inactive: true},
}}
r := New(Config{}, Deps{Account: account, Files: files}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 42)
if ok, err := r.onMessagesSetDefaultReaction(ctx, &tg.ReactionEmoji{Emoticon: "\U0001f525"}); err != nil || !ok {
t.Fatalf("set active reaction = %v, %v", ok, err)
}
if account.setCalls != 1 {
t.Fatalf("set calls = %d, want 1", account.setCalls)
}
for _, emoticon := range []string{"\U0001f602", "\U0001f680"} {
if _, err := r.onMessagesSetDefaultReaction(ctx, &tg.ReactionEmoji{Emoticon: emoticon}); !tgerr.Is(err, "REACTION_INVALID") {
t.Fatalf("set reaction %q err = %v, want REACTION_INVALID", emoticon, err)
}
}
if account.setCalls != 1 {
t.Fatalf("invalid reactions reached persistence: set calls = %d", account.setCalls)
}
}
func TestSetDefaultReactionFailsClosedWhenCatalogReadFails(t *testing.T) {
account := &reactionSettingsAccountStub{settings: domain.DefaultAccountReactionSettings()}
r := New(Config{}, Deps{Account: account, Files: reactionCatalogErrorFiles{}}, zaptest.NewLogger(t), clock.System)
if _, err := r.onMessagesSetDefaultReaction(WithUserID(context.Background(), 42), &tg.ReactionEmoji{Emoticon: "\U0001f44d"}); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
t.Fatalf("set reaction err = %v, want INTERNAL_SERVER_ERROR", err)
}
if account.setCalls != 0 {
t.Fatalf("failed catalog read reached persistence: set calls = %d", account.setCalls)
}
}
func TestMessagesReceivedMessagesIsAuthorizedNoop(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
for _, maxID := range []int{-1, 0, 1, int(^uint32(0) >> 1)} {
got, err := r.onMessagesReceivedMessages(WithUserID(context.Background(), 42), maxID)
if err != nil {
t.Fatalf("max_id %d: %v", maxID, err)
}
if got == nil || len(got) != 0 {
t.Fatalf("max_id %d result = %#v, want non-nil empty cancellation set", maxID, got)
}
}
if _, err := r.onMessagesReceivedMessages(context.Background(), 1); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") {
t.Fatalf("unauthorized err = %v, want AUTH_KEY_UNREGISTERED", err)
}
}
func TestMessagesReceivedMessagesIsRegistered(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
var request bin.Buffer
if err := (&tg.MessagesReceivedMessagesRequest{MaxID: 99}).Encode(&request); err != nil {
t.Fatalf("encode messages.receivedMessages: %v", err)
}
result, method, err := r.DispatchWithMethod(WithUserID(context.Background(), 42), [8]byte{1}, 7, &request)
if err != nil {
t.Fatalf("dispatch messages.receivedMessages: %v", err)
}
if method != "messages.receivedMessages" {
t.Fatalf("method = %q, want messages.receivedMessages", method)
}
vector, ok := result.(*tg.ReceivedNotifyMessageVector)
if !ok || vector == nil || vector.Elems == nil || len(vector.Elems) != 0 {
t.Fatalf("result = %#v (%T), want non-nil empty ReceivedNotifyMessageVector", result, result)
}
}

View file

@ -10,6 +10,7 @@ import (
// registerMessages 注册 messages.* RPC handler。
func (r *Router) registerMessages(d *tg.ServerDispatcher) {
d.OnMessagesReceivedMessages(r.onMessagesReceivedMessages)
d.OnMessagesSetTyping(r.onMessagesSetTyping)
d.OnMessagesSaveDraft(r.onMessagesSaveDraft)
d.OnMessagesSaveDefaultSendAs(r.onMessagesSaveDefaultSendAs)