feat: sync message translation support
This commit is contained in:
parent
cbccd6a8d9
commit
ea6cc72886
26 changed files with 1189 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
156
internal/rpc/messages_translation.go
Normal file
156
internal/rpc/messages_translation.go
Normal 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()
|
||||
}
|
||||
}
|
||||
142
internal/rpc/messages_translation_rpc_test.go
Normal file
142
internal/rpc/messages_translation_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
33
internal/rpc/translation_projection.go
Normal file
33
internal/rpc/translation_projection.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue