owpengram-server/internal/app/translation/service.go

306 lines
9.3 KiB
Go

// 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
}