feat: sync message translation support
This commit is contained in:
parent
cbccd6a8d9
commit
ea6cc72886
26 changed files with 1189 additions and 0 deletions
306
internal/app/translation/service.go
Normal file
306
internal/app/translation/service.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// Package translation implements protocol-neutral chat translation and the
|
||||
// per-account peer visibility preference consumed by Telegram clients.
|
||||
package translation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const defaultTimeout = 15 * time.Second
|
||||
|
||||
type PrivateMessages interface {
|
||||
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
||||
}
|
||||
|
||||
type ChannelMessages interface {
|
||||
GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error)
|
||||
}
|
||||
|
||||
type SettingsStore interface {
|
||||
SetTranslationDisabled(ctx context.Context, userID int64, peer domain.Peer, disabled bool) (bool, error)
|
||||
TranslationDisabled(ctx context.Context, userID int64, peer domain.Peer) (bool, error)
|
||||
}
|
||||
|
||||
type RateLimiter interface {
|
||||
AllowN(ctx context.Context, key string, cost, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error)
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Translate(ctx context.Context, texts []domain.TranslationText, toLang, tone string) ([]domain.TranslationText, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
private PrivateMessages
|
||||
channels ChannelMessages
|
||||
settings SettingsStore
|
||||
providers []Provider
|
||||
enabled bool
|
||||
timeout time.Duration
|
||||
limiter RateLimiter
|
||||
rateLimit int
|
||||
rateWindow time.Duration
|
||||
}
|
||||
|
||||
type Option func(*Service)
|
||||
|
||||
func WithProviders(providers ...Provider) Option {
|
||||
return func(s *Service) {
|
||||
for _, provider := range providers {
|
||||
if provider != nil {
|
||||
s.providers = append(s.providers, provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithEnabled(enabled bool) Option { return func(s *Service) { s.enabled = enabled } }
|
||||
|
||||
func WithTimeout(timeout time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
if timeout > 0 {
|
||||
s.timeout = timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithRateLimiter(limiter RateLimiter, limit int, window time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.limiter = limiter
|
||||
s.rateLimit = limit
|
||||
if window > 0 {
|
||||
s.rateWindow = window
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(private PrivateMessages, channels ChannelMessages, settings SettingsStore, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
private: private,
|
||||
channels: channels,
|
||||
settings: settings,
|
||||
enabled: true,
|
||||
timeout: defaultTimeout,
|
||||
rateLimit: 60,
|
||||
rateWindow: time.Minute,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) Translate(ctx context.Context, req domain.TranslationRequest) (domain.TranslationResult, error) {
|
||||
if s == nil || !s.enabled || len(s.providers) == 0 {
|
||||
return domain.TranslationResult{}, domain.ErrTranslationDisabled
|
||||
}
|
||||
toLang := strings.ToLower(strings.TrimSpace(req.ToLang))
|
||||
if req.UserID == 0 || !validLanguage(toLang) || utf8.RuneCountInString(req.Tone) > domain.MaxTranslationToneRunes {
|
||||
return domain.TranslationResult{}, domain.ErrTranslationLanguageInvalid
|
||||
}
|
||||
texts, err := s.resolveTexts(ctx, req)
|
||||
if err != nil {
|
||||
return domain.TranslationResult{}, err
|
||||
}
|
||||
if err := validateTexts(texts); err != nil {
|
||||
return domain.TranslationResult{}, err
|
||||
}
|
||||
if s.limiter != nil && s.rateLimit > 0 {
|
||||
allowed, _, err := s.limiter.AllowN(ctx, fmt.Sprintf("translation:%d", req.UserID), len(texts), s.rateLimit, s.rateWindow)
|
||||
if err != nil {
|
||||
return domain.TranslationResult{}, err
|
||||
}
|
||||
if !allowed {
|
||||
return domain.TranslationResult{}, domain.ErrTranslationRateLimited
|
||||
}
|
||||
}
|
||||
|
||||
providerCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
var lastErr error
|
||||
for _, provider := range s.providers {
|
||||
translated, err := provider.Translate(providerCtx, cloneTexts(texts), toLang, req.Tone)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if providerCtx.Err() != nil {
|
||||
return domain.TranslationResult{}, domain.ErrTranslationTimeout
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(translated) != len(texts) {
|
||||
lastErr = domain.ErrTranslationProviderUnavailable
|
||||
continue
|
||||
}
|
||||
if err := validateTranslatedTexts(translated); err != nil {
|
||||
lastErr = err
|
||||
translated = nil
|
||||
}
|
||||
if translated != nil {
|
||||
return domain.TranslationResult{Texts: cloneTexts(translated)}, nil
|
||||
}
|
||||
}
|
||||
if errors.Is(lastErr, context.DeadlineExceeded) || errors.Is(lastErr, domain.ErrTranslationTimeout) {
|
||||
return domain.TranslationResult{}, domain.ErrTranslationTimeout
|
||||
}
|
||||
return domain.TranslationResult{}, domain.ErrTranslationProviderUnavailable
|
||||
}
|
||||
|
||||
func (s *Service) SetPeerDisabled(ctx context.Context, userID int64, peer domain.Peer, disabled bool) (bool, error) {
|
||||
if s == nil || s.settings == nil || userID == 0 || !validPeer(peer) {
|
||||
return false, domain.ErrTranslationPeerInvalid
|
||||
}
|
||||
return s.settings.SetTranslationDisabled(ctx, userID, peer, disabled)
|
||||
}
|
||||
|
||||
func (s *Service) PeerDisabled(ctx context.Context, userID int64, peer domain.Peer) (bool, error) {
|
||||
if s == nil || s.settings == nil || userID == 0 || !validPeer(peer) {
|
||||
return false, domain.ErrTranslationPeerInvalid
|
||||
}
|
||||
return s.settings.TranslationDisabled(ctx, userID, peer)
|
||||
}
|
||||
|
||||
func (s *Service) resolveTexts(ctx context.Context, req domain.TranslationRequest) ([]domain.TranslationText, error) {
|
||||
idMode := len(req.IDs) > 0 || req.Peer.ID != 0
|
||||
textMode := len(req.Texts) > 0
|
||||
if idMode == textMode {
|
||||
return nil, domain.ErrTranslationInputEmpty
|
||||
}
|
||||
if textMode {
|
||||
if len(req.Texts) > domain.MaxTranslationTexts {
|
||||
return nil, domain.ErrTranslationInputTooLong
|
||||
}
|
||||
return cloneTexts(req.Texts), nil
|
||||
}
|
||||
if !validPeer(req.Peer) {
|
||||
return nil, domain.ErrTranslationPeerInvalid
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
return nil, domain.ErrTranslationInputEmpty
|
||||
}
|
||||
if len(req.IDs) > domain.MaxTranslationTexts {
|
||||
return nil, domain.ErrTranslationInputTooLong
|
||||
}
|
||||
for _, id := range req.IDs {
|
||||
if id <= 0 || id > domain.MaxMessageBoxID {
|
||||
return nil, domain.ErrTranslationMessageInvalid
|
||||
}
|
||||
}
|
||||
switch req.Peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
if s.private == nil {
|
||||
return nil, domain.ErrTranslationProviderUnavailable
|
||||
}
|
||||
list, err := s.private.GetMessages(ctx, req.UserID, req.IDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[int]domain.Message, len(list.Messages))
|
||||
for _, message := range list.Messages {
|
||||
if message.Peer == req.Peer {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
out := make([]domain.TranslationText, 0, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
message, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrTranslationMessageInvalid
|
||||
}
|
||||
out = append(out, domain.TranslationText{Text: message.Body, Entities: append([]domain.MessageEntity(nil), message.Entities...)})
|
||||
}
|
||||
return out, nil
|
||||
case domain.PeerTypeChannel:
|
||||
if s.channels == nil {
|
||||
return nil, domain.ErrTranslationProviderUnavailable
|
||||
}
|
||||
history, err := s.channels.GetMessages(ctx, req.UserID, req.Peer.ID, req.IDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := make(map[int]domain.ChannelMessage, len(history.Messages))
|
||||
for _, message := range history.Messages {
|
||||
if message.ChannelID == req.Peer.ID && !message.Deleted {
|
||||
byID[message.ID] = message
|
||||
}
|
||||
}
|
||||
out := make([]domain.TranslationText, 0, len(req.IDs))
|
||||
for _, id := range req.IDs {
|
||||
message, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrTranslationMessageInvalid
|
||||
}
|
||||
out = append(out, domain.TranslationText{Text: message.Body, Entities: append([]domain.MessageEntity(nil), message.Entities...)})
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, domain.ErrTranslationPeerInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func validateTexts(texts []domain.TranslationText) error {
|
||||
if len(texts) == 0 {
|
||||
return domain.ErrTranslationInputEmpty
|
||||
}
|
||||
total := 0
|
||||
for _, text := range texts {
|
||||
if strings.TrimSpace(text.Text) == "" {
|
||||
return domain.ErrTranslationInputEmpty
|
||||
}
|
||||
total += len(text.Text)
|
||||
if total > domain.MaxTranslationInputBytes {
|
||||
return domain.ErrTranslationInputTooLong
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTranslatedTexts(texts []domain.TranslationText) error {
|
||||
total := 0
|
||||
for _, text := range texts {
|
||||
if strings.TrimSpace(text.Text) == "" {
|
||||
return domain.ErrTranslationProviderUnavailable
|
||||
}
|
||||
total += len(text.Text)
|
||||
if total > domain.MaxTranslationOutputBytes {
|
||||
return domain.ErrTranslationProviderUnavailable
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPeer(peer domain.Peer) bool {
|
||||
return peer.ID > 0 && (peer.Type == domain.PeerTypeUser || peer.Type == domain.PeerTypeChannel)
|
||||
}
|
||||
|
||||
func validLanguage(lang string) bool {
|
||||
_, ok := supportedLanguages[strings.ToLower(strings.TrimSpace(lang))]
|
||||
return ok
|
||||
}
|
||||
|
||||
var supportedLanguages = func() map[string]struct{} {
|
||||
// ISO 639-1. Keeping this explicit makes TO_LANG_INVALID deterministic and
|
||||
// still covers the wider DrKLO language picker, not just TDesktop's shortlist.
|
||||
const codes = "aa ab ae af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu"
|
||||
out := make(map[string]struct{}, 184)
|
||||
for _, code := range strings.Fields(codes) {
|
||||
out[code] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func cloneTexts(in []domain.TranslationText) []domain.TranslationText {
|
||||
out := make([]domain.TranslationText, len(in))
|
||||
for i := range in {
|
||||
out[i] = in[i].Clone()
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue