feat: sync message translation support

This commit is contained in:
A 2026-07-12 14:20:26 +08:00
parent cbccd6a8d9
commit ea6cc72886
26 changed files with 1189 additions and 0 deletions

View file

@ -118,6 +118,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
return nil, channelInvalidErr(domain.ErrChannelPrivate)
}
full := cached.full
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, ref.ID, &full); err != nil {
return nil, err
}
r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full)
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full)
r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full)
@ -164,6 +167,9 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha
chats: append([]tg.ChatClass(nil), chats...),
userIDs: userIDs,
}, loadEpoch)
if err := r.applyTranslationDisabledToChannelFull(ctx, userID, view.Channel.ID, full); err != nil {
return nil, err
}
r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full)
r.applyAndroidChannelReactionEditorCompat(ctx, full, canChangeInfo)

View file

@ -456,6 +456,14 @@ type MessagesService interface {
DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error)
}
// TranslationService owns read-only translation and the durable per-account
// peer preference. It only exposes domain values to the RPC edge.
type TranslationService interface {
Translate(ctx context.Context, req domain.TranslationRequest) (domain.TranslationResult, error)
SetPeerDisabled(ctx context.Context, userID int64, peer domain.Peer, disabled bool) (bool, error)
PeerDisabled(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
}
// AlbumGroupService 是 MessagesService 的可选、生产必备能力:sendMultiMedia 在
// 解析任何媒体或落第一条消息前,持久预留整批 random_id 的 grouped_id。
// 单独定义可避免让不触发 sendMultiMedia 的轻量测试替身实现无关方法。
@ -731,6 +739,7 @@ type Deps struct {
Dialogs DialogsService
Chatlists ChatlistsService
Messages MessagesService
Translation TranslationService
Stories StoriesService
Channels ChannelsService
Files FilesService

View file

@ -276,6 +276,14 @@ func inputRequestInvalidErr() error { return tgerr.New(400, "INPUT_REQUEST_INVAL
func inputRequestTooLongErr() error { return tgerr.New(400, "INPUT_REQUEST_TOO_LONG") }
func inputTextEmptyErr() error { return tgerr.New(400, "INPUT_TEXT_EMPTY") }
func inputTextTooLongErr() error { return tgerr.New(400, "INPUT_TEXT_TOO_LONG") }
func toLangInvalidErr() error { return tgerr.New(400, "TO_LANG_INVALID") }
func translateReqFailedErr() error { return tgerr.New(500, "TRANSLATE_REQ_FAILED") }
func translateReqQuotaExceededErr() error { return tgerr.New(400, "TRANSLATE_REQ_QUOTA_EXCEEDED") }
func translationsDisabledErr() error { return tgerr.New(406, "TRANSLATIONS_DISABLED") }
func translationTimeoutErr() error { return tgerr.New(500, "TRANSLATION_TIMEOUT") }
func persistentTimestampInvalidErr() error { return tgerr.New(400, "PERSISTENT_TIMESTAMP_INVALID") }
func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSING") }

View file

@ -79,6 +79,8 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
d.OnMessagesReportMusicListen(r.onMessagesReportMusicListen)
d.OnMessagesReportSponsoredMessage(r.onMessagesReportSponsoredMessage)
d.OnMessagesReadMessageContents(r.onMessagesReadMessageContents)
d.OnMessagesTranslateText(r.onMessagesTranslateText)
d.OnMessagesTogglePeerTranslations(r.onMessagesTogglePeerTranslations)
d.OnMessagesGetMessagesViews(r.onMessagesGetMessagesViews)
d.OnMessagesGetUnreadMentions(r.onMessagesGetUnreadMentions)
d.OnMessagesReadMentions(r.onMessagesReadMentions)

View file

@ -0,0 +1,156 @@
package rpc
import (
"context"
"errors"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) onMessagesTranslateText(ctx context.Context, req *tg.MessagesTranslateTextRequest) (*tg.MessagesTranslateResult, error) {
if req == nil {
return nil, inputTextEmptyErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if r.deps.Translation == nil {
return nil, translationsDisabledErr()
}
if err := r.requireTranslationUser(ctx, userID); err != nil {
return nil, err
}
peerInput, idMode := req.GetPeer()
ids, idsSet := req.GetID()
texts, textMode := req.GetText()
if idMode != idsSet || idMode == textMode {
return nil, inputTextEmptyErr()
}
request := domain.TranslationRequest{
UserID: userID,
ToLang: req.ToLang,
Tone: req.Tone,
}
if idMode {
peer, err := r.checkedTranslationPeer(ctx, userID, peerInput)
if err != nil {
return nil, peerIDInvalidErr()
}
request.Peer = peer
request.IDs = append([]int(nil), ids...)
} else {
request.Texts = make([]domain.TranslationText, 0, len(texts))
for _, text := range texts {
request.Texts = append(request.Texts, domain.TranslationText{
Text: text.Text,
Entities: domainMessageEntitiesForViewer(userID, text.Entities),
})
}
}
result, err := r.deps.Translation.Translate(ctx, request)
if err != nil {
return nil, translationRPCErr(err)
}
out := &tg.MessagesTranslateResult{Result: make([]tg.TextWithEntities, 0, len(result.Texts))}
for _, text := range result.Texts {
out.Result = append(out.Result, tg.TextWithEntities{
Text: text.Text,
Entities: tgMessageEntities(text.Entities),
})
}
return out, nil
}
func (r *Router) onMessagesTogglePeerTranslations(ctx context.Context, req *tg.MessagesTogglePeerTranslationsRequest) (bool, error) {
if req == nil || req.Peer == nil {
return false, peerIDInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if r.deps.Translation == nil {
return false, translationsDisabledErr()
}
if err := r.requireTranslationUser(ctx, userID); err != nil {
return false, err
}
peer, err := r.checkedTranslationPeer(ctx, userID, req.Peer)
if err != nil {
return false, peerIDInvalidErr()
}
if _, err := r.deps.Translation.SetPeerDisabled(ctx, userID, peer, req.Disabled); err != nil {
return false, translationRPCErr(err)
}
return true, nil
}
func (r *Router) requireTranslationUser(ctx context.Context, userID int64) error {
if r.deps.Users == nil {
return nil
}
self, err := r.deps.Users.Self(ctx, userID)
if err != nil {
return internalErr()
}
if self.Bot {
return botMethodInvalidErr()
}
return nil
}
func (r *Router) checkedTranslationPeer(ctx context.Context, userID int64, input tg.InputPeerClass) (domain.Peer, error) {
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
if err != nil {
return domain.Peer{}, err
}
if peer.Type != domain.PeerTypeUser || r.deps.Users == nil {
return peer, nil
}
var userInput tg.InputUserClass
switch value := input.(type) {
case *tg.InputPeerSelf:
userInput = &tg.InputUserSelf{}
case *tg.InputPeerUser:
userInput = &tg.InputUser{UserID: value.UserID, AccessHash: value.AccessHash}
default:
return domain.Peer{}, peerIDInvalidErr()
}
_, found, err := r.userFromInput(ctx, userID, userInput)
if err != nil {
return domain.Peer{}, internalErr()
}
if !found {
return domain.Peer{}, peerIDInvalidErr()
}
return peer, nil
}
func translationRPCErr(err error) error {
switch {
case errors.Is(err, domain.ErrTranslationInputEmpty):
return inputTextEmptyErr()
case errors.Is(err, domain.ErrTranslationInputTooLong):
return inputTextTooLongErr()
case errors.Is(err, domain.ErrTranslationLanguageInvalid):
return toLangInvalidErr()
case errors.Is(err, domain.ErrTranslationMessageInvalid), errors.Is(err, domain.ErrMessageIDInvalid):
return msgIDInvalidErr()
case errors.Is(err, domain.ErrTranslationPeerInvalid), errors.Is(err, domain.ErrChannelInvalid), errors.Is(err, domain.ErrChannelPrivate):
return peerIDInvalidErr()
case errors.Is(err, domain.ErrTranslationRateLimited):
return translateReqQuotaExceededErr()
case errors.Is(err, domain.ErrTranslationDisabled):
return translationsDisabledErr()
case errors.Is(err, domain.ErrTranslationTimeout):
return translationTimeoutErr()
case errors.Is(err, domain.ErrTranslationProviderUnavailable):
return translateReqFailedErr()
default:
return internalErr()
}
}

View file

@ -0,0 +1,142 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
type captureTranslationService struct {
request domain.TranslationRequest
disabled map[[3]int64]bool
}
func (s *captureTranslationService) Translate(_ context.Context, req domain.TranslationRequest) (domain.TranslationResult, error) {
s.request = req
out := make([]domain.TranslationText, len(req.Texts))
for i := range req.Texts {
out[i].Text = "translated:" + req.Texts[i].Text
}
return domain.TranslationResult{Texts: out}, nil
}
func translationSettingKey(userID int64, peer domain.Peer) [3]int64 {
kind := int64(1)
if peer.Type == domain.PeerTypeChannel {
kind = 2
}
return [3]int64{userID, kind, peer.ID}
}
func (s *captureTranslationService) SetPeerDisabled(_ context.Context, userID int64, peer domain.Peer, disabled bool) (bool, error) {
if s.disabled == nil {
s.disabled = map[[3]int64]bool{}
}
key := translationSettingKey(userID, peer)
previous := s.disabled[key]
s.disabled[key] = disabled
return previous != disabled, nil
}
func (s *captureTranslationService) PeerDisabled(_ context.Context, userID int64, peer domain.Peer) (bool, error) {
return s.disabled[translationSettingKey(userID, peer)], nil
}
func TestMessagesTranslateTextDirectMode(t *testing.T) {
svc := &captureTranslationService{}
r := New(Config{}, Deps{Translation: svc}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1001)
req := &tg.MessagesTranslateTextRequest{ToLang: "zh"}
req.SetText([]tg.TextWithEntities{{Text: "hello"}, {Text: "world"}})
got, err := r.onMessagesTranslateText(ctx, req)
if err != nil {
t.Fatalf("translateText: %v", err)
}
if len(got.Result) != 2 || got.Result[0].Text != "translated:hello" || got.Result[1].Text != "translated:world" {
t.Fatalf("result = %#v", got.Result)
}
if svc.request.UserID != 1001 || svc.request.ToLang != "zh" || len(svc.request.Texts) != 2 {
t.Fatalf("domain request = %#v", svc.request)
}
}
func TestMessagesTranslateTextRejectsInvalidFlags(t *testing.T) {
r := New(Config{}, Deps{Translation: &captureTranslationService{}}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1001)
if _, err := r.onMessagesTranslateText(ctx, &tg.MessagesTranslateTextRequest{ToLang: "en"}); !tgerr.Is(err, "INPUT_TEXT_EMPTY") {
t.Fatalf("empty flags err = %v", err)
}
req := &tg.MessagesTranslateTextRequest{ToLang: "en"}
req.SetPeer(&tg.InputPeerUser{UserID: 2})
req.SetID([]int{1})
req.SetText([]tg.TextWithEntities{{Text: "both"}})
if _, err := r.onMessagesTranslateText(ctx, req); !tgerr.Is(err, "INPUT_TEXT_EMPTY") {
t.Fatalf("both modes err = %v", err)
}
}
func TestMessagesTogglePeerTranslationsAndProjection(t *testing.T) {
svc := &captureTranslationService{}
r := New(Config{}, Deps{Translation: svc}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 1001)
peer := &tg.InputPeerUser{UserID: 2002}
if ok, err := r.onMessagesTogglePeerTranslations(ctx, &tg.MessagesTogglePeerTranslationsRequest{Disabled: true, Peer: peer}); err != nil || !ok {
t.Fatalf("toggle = %v/%v", ok, err)
}
full := tg.UserFull{ID: 2002}
if err := r.applyTranslationDisabledToUserFull(ctx, 1001, 2002, &full); err != nil {
t.Fatalf("projection: %v", err)
}
if !full.TranslationsDisabled {
t.Fatal("userFull.translations_disabled = false")
}
}
func TestMessagesTogglePeerTranslationsValidatesUserAccessHash(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{Phone: "+10000000001", FirstName: "Owner", AccessHash: 11})
if err != nil {
t.Fatal(err)
}
peer, err := userStore.Create(ctx, domain.User{Phone: "+10000000002", FirstName: "Peer", AccessHash: 22})
if err != nil {
t.Fatal(err)
}
svc := &captureTranslationService{}
r := New(Config{}, Deps{Users: appusers.NewService(userStore), Translation: svc}, zaptest.NewLogger(t), clock.System)
ownerCtx := WithUserID(ctx, owner.ID)
_, err = r.onMessagesTogglePeerTranslations(ownerCtx, &tg.MessagesTogglePeerTranslationsRequest{
Disabled: true,
Peer: &tg.InputPeerUser{UserID: peer.ID, AccessHash: peer.AccessHash + 1},
})
if !tgerr.Is(err, "PEER_ID_INVALID") {
t.Fatalf("bad access hash err = %v, want PEER_ID_INVALID", err)
}
if len(svc.disabled) != 0 {
t.Fatalf("bad access hash wrote settings: %#v", svc.disabled)
}
}
func TestMessagesTranslateTextRejectsBotCaller(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
bot, err := userStore.Create(ctx, domain.User{Phone: "+10000000003", FirstName: "Bot", AccessHash: 33, Bot: true})
if err != nil {
t.Fatal(err)
}
r := New(Config{}, Deps{Users: appusers.NewService(userStore), Translation: &captureTranslationService{}}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesTranslateTextRequest{ToLang: "en"}
req.SetText([]tg.TextWithEntities{{Text: "hello"}})
if _, err := r.onMessagesTranslateText(WithUserID(ctx, bot.ID), req); !tgerr.Is(err, "BOT_METHOD_INVALID") {
t.Fatalf("bot translate err = %v, want BOT_METHOD_INVALID", err)
}
}

View file

@ -0,0 +1,33 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
func (r *Router) applyTranslationDisabledToUserFull(ctx context.Context, viewerUserID, peerUserID int64, full *tg.UserFull) error {
if full == nil || r.deps.Translation == nil || viewerUserID == 0 || peerUserID == 0 {
return nil
}
disabled, err := r.deps.Translation.PeerDisabled(ctx, viewerUserID, domain.Peer{Type: domain.PeerTypeUser, ID: peerUserID})
if err != nil {
return internalErr()
}
full.SetTranslationsDisabled(disabled)
return nil
}
func (r *Router) applyTranslationDisabledToChannelFull(ctx context.Context, viewerUserID, channelID int64, full *tg.ChannelFull) error {
if full == nil || r.deps.Translation == nil || viewerUserID == 0 || channelID == 0 {
return nil
}
disabled, err := r.deps.Translation.PeerDisabled(ctx, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID})
if err != nil {
return internalErr()
}
full.SetTranslationsDisabled(disabled)
return nil
}

View file

@ -140,6 +140,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
return nil, err
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
@ -154,6 +157,9 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
return nil, err
}
r.userFullProjectionCache.StoreIfEpoch(currentUserID, u.ID, full, loadEpoch)
if err := r.applyTranslationDisabledToUserFull(ctx, currentUserID, u.ID, &full); err != nil {
return nil, err
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)