diff --git a/data/langpack/weba/weba_en_v12000002.strings b/data/langpack/weba/weba_en_v12000002.strings new file mode 100644 index 00000000..9e4f4330 --- /dev/null +++ b/data/langpack/weba/weba_en_v12000002.strings @@ -0,0 +1,66 @@ +"AccDescrMentionDown" = "Go to next mention"; +"AccDescrPageDown" = "Go to bottom"; +"AccDescrPollVoteDown" = "Go to next unread poll vote"; +"AccDescrReactionMentionDown" = "Go to next unread reactions"; +"ArchivedChats" = "Archived Chats"; +"MenuArchivedChats" = "Archived Chats"; +"NewMessageTitle" = "New Message"; +"NewChannel" = "New Channel"; +"NewGroup" = "New Group"; +"MessageUnsupported" = "Unsupported message"; + +"AttachTodo" = "Checklist"; +"TitleNewToDoList" = "New Checklist"; +"TitleEditToDoList" = "Edit Checklist"; +"TitleAppendToDoList" = "Add Task"; +"TitleToDoList" = "Checklist"; +"TitleTask" = "Task"; +"TitleAddTask" = "Add a task"; +"AllowOthersAddTasks" = "Allow Others to Add Tasks"; +"AriaToDoCancel" = "Cancel checklist creation"; +"TitleGroupToDoList" = "Group Checklist"; +"TitleUserToDoList" = "{peer}'s Checklist"; +"TitleYourToDoList" = "Your Checklist"; +"DescriptionCompletedToDoTasks" = "{number} of {count} completed"; +"MessageActionTodoCompletionsAsDone" = "{peer} marked \"{task}\" as done"; +"MessageActionTodoCompletionsAsDoneYou" = "You marked \"{task}\" as done"; +"MessageActionTodoCompletionsAsDoneMultiple" = "{peer} marked {tasks} as done"; +"MessageActionTodoCompletionsAsDoneMultipleYou" = "You marked {tasks} as done"; +"MessageActionTodoCompletionsAsNotDone" = "{peer} marked \"{task}\" as not done"; +"MessageActionTodoCompletionsAsNotDoneYou" = "You marked \"{task}\" as not done"; +"MessageActionTodoCompletionsAsNotDoneMultiple" = "{peer} marked {tasks} as not done"; +"MessageActionTodoCompletionsAsNotDoneMultipleYou" = "You marked {tasks} as not done"; +"MessageActionTodoTaskCount#one" = "{count} task"; +"MessageActionTodoTaskCount#other" = "{count} tasks"; +"MenuButtonAppendTodoList" = "Add a Task"; +"MessageActionAppendTodo" = "{peer} added a new task \"{task}\" to {list}"; +"MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}"; +"MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}"; +"MessageActionAppendTodoMultipleYou" = "You added {tasks} to {list}"; +"SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks"; +"SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists"; +"SubscribeToTelegramPremiumForAppendToDo" = "Subscribe to **Telegram Premium** to add tasks"; +"HintTodoListTasksCount2#one" = "You can add {count} more task"; +"HintTodoListTasksCount2#other" = "You can add {count} more tasks"; +"ToDoListErrorChooseTitle" = "Please enter a title."; +"ToDoListErrorChooseTasks" = "Please enter at least one task."; +"PremiumPreviewTodo" = "Checklists"; +"PremiumPreviewTodoDescription" = "Plan, assign, and complete tasks - seamlessly and efficiently."; + +"Weekday.ShortSunday" = "Sun"; +"Weekday.ShortMonday" = "Mon"; +"Weekday.ShortTuesday" = "Tue"; +"Weekday.ShortWednesday" = "Wed"; +"Weekday.ShortThursday" = "Thu"; +"Weekday.ShortFriday" = "Fri"; +"Weekday.ShortSaturday" = "Sat"; + +"Weekday.Sunday" = "Sunday"; +"Weekday.Monday" = "Monday"; +"Weekday.Tuesday" = "Tuesday"; +"Weekday.Wednesday" = "Wednesday"; +"Weekday.Thursday" = "Thursday"; +"Weekday.Friday" = "Friday"; +"Weekday.Saturday" = "Saturday"; +"Weekday.Today" = "Today"; +"Weekday.Yesterday" = "Yesterday"; diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go index 1161ed35..5a216188 100644 --- a/internal/app/langpack/service.go +++ b/internal/app/langpack/service.go @@ -2,6 +2,7 @@ package langpack import ( "context" + "strings" "telesrv/internal/domain" "telesrv/internal/store" @@ -24,18 +25,41 @@ func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (d // GetDifference 返回从 fromVersion 到当前版本的语言包差异。 func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + packName := normalizePack(langPack) + code := normalizeCode(langCode) if s == nil || s.packs == nil { - return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil } - return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion) + pack, err := s.packs.GetPack(ctx, packName, code, fromVersion) + if err != nil { + return domain.LangPack{}, err + } + return s.overlayWebAStrings(ctx, pack, packName, code, fromVersion) } // GetStrings 返回指定 key 的语言包字符串。 func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + packName := normalizePack(langPack) + code := normalizeCode(langCode) if s == nil || s.packs == nil { - return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + return domain.LangPack{LangPack: packName, LangCode: code}, nil } - return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys) + pack, err := s.packs.GetStrings(ctx, packName, code, keys) + if err != nil { + return domain.LangPack{}, err + } + if len(keys) == 0 { + return s.overlayWebAStrings(ctx, pack, packName, code, 0) + } + missing := missingLangPackKeys(keys, pack.Strings) + if len(missing) == 0 || !shouldOverlayWebA(packName) { + return pack, nil + } + overlay, err := s.packs.GetStrings(ctx, "weba", code, missing) + if err != nil { + return domain.LangPack{}, err + } + return mergeMissingLangPackStrings(pack, overlay), nil } func normalizePack(langPack string) string { @@ -46,8 +70,76 @@ func normalizePack(langPack string) string { } func normalizeCode(langCode string) string { - if langCode == "" { + code := strings.ToLower(strings.TrimSpace(langCode)) + if code == "" { return "en" } - return langCode + return strings.TrimSuffix(code, "-raw") +} + +func shouldOverlayWebA(langPack string) bool { + switch strings.ToLower(langPack) { + case "android", "ios", "tdesktop", "macos": + return true + default: + return false + } +} + +func (s *Service) overlayWebAStrings(ctx context.Context, pack domain.LangPack, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + if fromVersion != 0 || !shouldOverlayWebA(langPack) { + return pack, nil + } + overlay, err := s.packs.GetPack(ctx, "weba", langCode, fromVersion) + if err != nil { + return domain.LangPack{}, err + } + return mergeMissingLangPackStrings(pack, overlay), nil +} + +func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack { + if len(overlay.Strings) == 0 { + return pack + } + if pack.LangCode == "" { + pack.LangCode = overlay.LangCode + } + if overlay.Version > pack.Version { + pack.Version = overlay.Version + } + seen := make(map[string]struct{}, len(pack.Strings)+len(overlay.Strings)) + for _, item := range pack.Strings { + seen[item.Key] = struct{}{} + } + for _, item := range overlay.Strings { + if _, ok := seen[item.Key]; ok { + continue + } + pack.Strings = append(pack.Strings, item) + seen[item.Key] = struct{}{} + } + return pack +} + +func missingLangPackKeys(keys []string, strings []domain.LangPackString) []string { + if len(keys) == 0 { + return nil + } + have := make(map[string]struct{}, len(strings)) + for _, item := range strings { + have[item.Key] = struct{}{} + } + missing := make([]string, 0) + seenMissing := make(map[string]struct{}, len(keys)) + for _, key := range keys { + if _, ok := have[key]; ok { + continue + } + if _, ok := seenMissing[key]; ok { + continue + } + missing = append(missing, key) + seenMissing[key] = struct{}{} + } + return missing } diff --git a/internal/app/langpack/service_test.go b/internal/app/langpack/service_test.go new file mode 100644 index 00000000..1c220ed4 --- /dev/null +++ b/internal/app/langpack/service_test.go @@ -0,0 +1,81 @@ +package langpack + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceNormalizesWebARawLangCode(t *testing.T) { + ctx := context.Background() + packs := memory.NewLangPackStore() + svc := NewService(packs) + seed := domain.LangPack{ + LangPack: "android", + LangCode: "en", + Version: 7, + Strings: []domain.LangPackString{ + {Key: "LogOutTitle", Value: "Log Out"}, + {Key: "NewMessageTitle", Value: "New Message"}, + }, + } + if err := packs.UpsertPack(ctx, seed); err != nil { + t.Fatalf("seed langpack: %v", err) + } + webASeed := domain.LangPack{ + LangPack: "weba", + LangCode: "en", + Version: 12, + Strings: []domain.LangPackString{ + {Key: "AccDescrPollVoteDown", Value: "Go to next unread poll vote"}, + {Key: "NewMessageTitle", Value: "New Message from WebA"}, + }, + } + if err := packs.UpsertPack(ctx, webASeed); err != nil { + t.Fatalf("seed weba langpack: %v", err) + } + + pack, err := svc.GetLangPack(ctx, "android", "EN-raw") + if err != nil { + t.Fatalf("get langpack: %v", err) + } + if pack.LangCode != "en" || pack.Version != webASeed.Version || len(pack.Strings) != len(seed.Strings)+1 { + t.Fatalf("pack = %+v, want normalized en pack", pack) + } + if got := stringValue(pack.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" { + t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got) + } + if got := stringValue(pack.Strings, "NewMessageTitle"); got != "New Message" { + t.Fatalf("NewMessageTitle = %q, want source pack to keep precedence", got) + } + + selected, err := svc.GetStrings(ctx, "android", "en-raw", []string{"LogOutTitle", "AccDescrPollVoteDown"}) + if err != nil { + t.Fatalf("get strings: %v", err) + } + if got := stringValue(selected.Strings, "LogOutTitle"); got != "Log Out" { + t.Fatalf("LogOutTitle = %q, want source pack value", got) + } + if got := stringValue(selected.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" { + t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got) + } + + notModified, err := svc.GetDifference(ctx, "android", "en-raw", seed.Version) + if err != nil { + t.Fatalf("get difference: %v", err) + } + if notModified.LangCode != "en" || len(notModified.Strings) != 0 { + t.Fatalf("difference = %+v, want normalized not-modified source pack", notModified) + } +} + +func stringValue(strings []domain.LangPackString, key string) string { + for _, item := range strings { + if item.Key == key { + return item.Value + } + } + return "" +} diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go index fc0846a4..09dc677a 100644 --- a/internal/rpc/langpack.go +++ b/internal/rpc/langpack.go @@ -72,7 +72,7 @@ func (r *Router) langpackLanguage(ctx context.Context, langPack, langCode string langCode = "en" } } - langCode = strings.ToLower(langCode) + langCode = normalizeLangpackCode(langCode) languages := r.langpackLanguages(ctx, langPack) for _, lang := range languages { if strings.ToLower(lang.LangCode) == langCode { @@ -150,3 +150,11 @@ func langPackFromClient(ctx context.Context) string { } return "tdesktop" } + +func normalizeLangpackCode(langCode string) string { + code := strings.ToLower(strings.TrimSpace(langCode)) + if code == "" { + return "en" + } + return strings.TrimSuffix(code, "-raw") +} diff --git a/internal/rpc/langpack_compat_test.go b/internal/rpc/langpack_compat_test.go index cc080f54..b5ad1c16 100644 --- a/internal/rpc/langpack_compat_test.go +++ b/internal/rpc/langpack_compat_test.go @@ -68,6 +68,11 @@ func TestLangpackGetLanguage(t *testing.T) { if lang.LangCode != "zh-hans" || lang.PluralCode != "zh" { t.Fatalf("language = %+v, want zh-hans", lang) } + + raw := r.langpackLanguage(context.Background(), "tdesktop", "en-raw") + if raw.LangCode != "en" { + t.Fatalf("language(en-raw) = %+v, want en", raw) + } } func TestLangpackAndroidPersianLanguage(t *testing.T) { diff --git a/internal/rpc/messages_constants.go b/internal/rpc/messages_constants.go index ad9ec030..2d50bffa 100644 --- a/internal/rpc/messages_constants.go +++ b/internal/rpc/messages_constants.go @@ -30,9 +30,9 @@ const ( maxPollVotesOffsetLength = 128 maxTodoItems = 30 maxTodoTitleLength = 200 - // maxTodoItemID 是清单项 id 的防御上限:协议只要求列表内唯一正整数(客户端通常 - // 顺序分配),不能用条目数上限当 id 边界,否则非顺序分配的合法 id 被误拒。 - maxTodoItemID = 1 << 16 + // maxTodoItemID 是清单项 id 的防御上限:协议字段是 int32,WebA 会用 8 位左右 + // 的稀疏本地 id,不能用条目数上限或顺序分配假设当 id 边界。 + maxTodoItemID = 1<<31 - 1 maxVenueTitleLength = 256 maxVenueAddressLength = 512 maxVenueProviderLength = 64 diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index 7c38b98d..6fe26f0b 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -30,6 +30,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages if !topMsgIDSet && req.TopMsgID != 0 { topMsgID, topMsgIDSet = req.TopMsgID, true } + if topMsgIDSet && topMsgID == -1 { + topMsgID, topMsgIDSet = 0, false + } if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { return nil, replyMessageIDInvalidErr() } diff --git a/internal/rpc/messages_forward_rpc_test.go b/internal/rpc/messages_forward_rpc_test.go index 6e0429b3..82be8a42 100644 --- a/internal/rpc/messages_forward_rpc_test.go +++ b/internal/rpc/messages_forward_rpc_test.go @@ -91,6 +91,52 @@ func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) { } } +func TestMessagesForwardMessagesTreatsMainThreadTopMsgSentinelAsAbsent(t *testing.T) { + const ( + ownerID = int64(1000000101) + fromID = int64(1000000102) + toID = int64(1000000103) + ) + ctx := context.Background() + messages := &captureMessages{list: domain.MessageList{Messages: []domain.Message{ + { + ID: 8, + OwnerUserID: ownerID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, + Date: 1700000108, + Body: "main thread source", + }, + }}} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, FirstName: "Owner"}, + fromID: {ID: fromID, FirstName: "From"}, + toID: {ID: toID, FirstName: "To"}, + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: fromID}, + ToPeer: &tg.InputPeerUser{UserID: toID}, + ID: []int{8}, + RandomID: []int64{8001}, + } + req.SetTopMsgID(-1) + + updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req) + if err != nil { + t.Fatalf("forward with main thread top_msg_id sentinel: %v", err) + } + if messages.sendReq.ReplyTo != nil { + t.Fatalf("reply = %+v, want nil for main thread sentinel", messages.sendReq.ReplyTo) + } + updates, ok := updatesClass.(*tg.Updates) + if !ok || len(updates.Updates) != 2 { + t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass) + } +} + func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -312,6 +358,30 @@ func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testi } } +func TestMessagesForwardMessagesRejectsOtherNegativeTopMsgID(t *testing.T) { + const ownerID = int64(1780243210) + ctx := context.Background() + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: 1780243211}, + ToPeer: &tg.InputPeerUser{UserID: 1780243212}, + ID: []int{1}, + RandomID: []int64{10001}, + } + req.SetTopMsgID(-2) + + _, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req) + if err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("forward negative top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + if messages.getMessagesCalls != 0 { + t.Fatalf("GetMessages calls = %d, want no source lookup for invalid top_msg_id", messages.getMessagesCalls) + } +} + func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) { const ( ownerID = int64(1780243210) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index e93de0c3..ed74deca 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -201,6 +201,9 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d return nil, inputConstructorInvalidErr() } } + if reply.Zero() { + return nil, nil + } if _, ok := reply.GetMonoforumPeerID(); ok { return nil, replyToMonoforumPeerInvalidErr() } diff --git a/internal/rpc/messages_send_rpc_test.go b/internal/rpc/messages_send_rpc_test.go index 639584ff..bbd76bdb 100644 --- a/internal/rpc/messages_send_rpc_test.go +++ b/internal/rpc/messages_send_rpc_test.go @@ -244,6 +244,37 @@ func TestMessageReplyFromInputStorySucceedsAndProjectsStoryHeader(t *testing.T) } } +func TestMessageReplyFromInputEmptyMessageIsAbsent(t *testing.T) { + const userID = int64(1000000001) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + reply, err := r.messageReplyFromInput(ctx, userID, peer, &tg.InputReplyToMessage{}) + if err != nil { + t.Fatalf("empty reply err = %v, want nil", err) + } + if reply != nil { + t.Fatalf("empty reply = %+v, want nil", reply) + } + + topicReply := &tg.InputReplyToMessage{} + topicReply.SetTopMsgID(123) + reply, err = r.messageReplyFromInput(ctx, userID, peer, topicReply) + if err != nil { + t.Fatalf("topic-only reply err = %v, want nil", err) + } + if reply == nil || reply.MessageID != 0 || reply.TopMessageID != 123 { + t.Fatalf("topic-only reply = %+v, want top_msg_id=123", reply) + } + + quoteOnly := &tg.InputReplyToMessage{} + quoteOnly.SetQuoteText("orphan quote") + if _, err := r.messageReplyFromInput(ctx, userID, peer, quoteOnly); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("quote-only reply err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { const userID = int64(1000000001) ctx := WithUserID(context.Background(), userID) diff --git a/internal/rpc/messages_todos_rpc_test.go b/internal/rpc/messages_todos_rpc_test.go index ea397707..a561cf51 100644 --- a/internal/rpc/messages_todos_rpc_test.go +++ b/internal/rpc/messages_todos_rpc_test.go @@ -53,6 +53,59 @@ func TestSendMediaTodoEcho(t *testing.T) { } } +func TestSendMediaTodoWithEmptyReplyToTreatsReplyAsAbsent(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + req := &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaTodo{Todo: tg.TodoList{ + Title: tg.TextWithEntities{Text: "web checklist", Entities: []tg.MessageEntityClass{}}, + List: []tg.TodoItem{ + {ID: 1, Title: tg.TextWithEntities{Text: "send item", Entities: []tg.MessageEntityClass{}}}, + }, + }}, + RandomID: 7005, + } + req.SetReplyTo(&tg.InputReplyToMessage{}) + + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), req) + if err != nil { + t.Fatalf("sendMedia todo with empty reply: %v", err) + } + msg := newMessageFromUpdates(t, updates) + if msg.ReplyTo != nil { + t.Fatalf("reply_to = %T, want nil for empty input reply", msg.ReplyTo) + } + if media, ok := msg.Media.(*tg.MessageMediaToDo); !ok || media.Todo.Title.Text != "web checklist" { + t.Fatalf("media = %#v, want todo checklist", msg.Media) + } +} + +func TestSendMediaTodoAcceptsSparseLargeItemIDs(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaTodo{Todo: tg.TodoList{ + Title: tg.TextWithEntities{Text: "web sparse ids", Entities: []tg.MessageEntityClass{}}, + List: []tg.TodoItem{ + {ID: 56151290, Title: tg.TextWithEntities{Text: "first", Entities: []tg.MessageEntityClass{}}}, + {ID: 56151305, Title: tg.TextWithEntities{Text: "second", Entities: []tg.MessageEntityClass{}}}, + }, + }}, + RandomID: 7006, + }) + if err != nil { + t.Fatalf("sendMedia todo sparse ids: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaToDo) + if !ok { + t.Fatalf("media = %T, want MessageMediaToDo", msg.Media) + } + if got := media.Todo.List[0].ID; got != 56151290 { + t.Fatalf("first todo id = %d, want 56151290", got) + } +} + func TestToggleTodoCompletedAndAppend(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t)