Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

498 lines
15 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package sqlstore
import (
"database/sql"
"encoding/json"
"maps"
sq "github.com/mattermost/squirrel"
"github.com/pkg/errors"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/v8/channels/store"
)
type SqlAutoTranslationStore struct {
*SqlStore
}
type TranslationMeta json.RawMessage
type Translation struct {
ObjectType string
ObjectID string
ChannelID *string
DstLang string
ProviderID string
NormHash string
Text string
Confidence *float64
Meta TranslationMeta
State string
UpdateAt int64
}
func (m *TranslationMeta) ToMap() (map[string]any, error) {
if m == nil {
return nil, nil
}
var result map[string]any
if err := json.Unmarshal(*m, &result); err != nil {
return nil, err
}
return result, nil
}
func newSqlAutoTranslationStore(sqlStore *SqlStore) store.AutoTranslationStore {
return &SqlAutoTranslationStore{
SqlStore: sqlStore,
}
}
func (s *SqlAutoTranslationStore) IsUserEnabled(userID, channelID string) (bool, error) {
query := s.getQueryBuilder().
Select("cm.AutoTranslationDisabled").
From("ChannelMembers cm").
Join("Channels c ON cm.ChannelId = c.Id").
Where(sq.Eq{"cm.UserId": userID, "cm.ChannelId": channelID}).
Where("cm.AutoTranslationDisabled != true").
Where("c.AutoTranslation = true")
var disabled bool
if err := s.GetReplica().GetBuilder(&disabled, query); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, errors.Wrapf(err, "failed to get user enabled status for user_id=%s, channel_id=%s", userID, channelID)
}
return !disabled, nil
}
func (s *SqlAutoTranslationStore) GetUserLanguage(userID, channelID string) (string, error) {
query := s.getQueryBuilder().
Select("u.Locale").
From("Users u").
Join("ChannelMembers cm ON u.Id = cm.UserId").
Join("Channels c ON cm.ChannelId = c.Id").
Where(sq.Eq{"u.Id": userID, "c.Id": channelID}).
Where("c.AutoTranslation = true").
Where("cm.AutoTranslationDisabled != true")
var locale string
if err := s.GetReplica().GetBuilder(&locale, query); err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", errors.Wrapf(err, "failed to get user language for user_id=%s, channel_id=%s", userID, channelID)
}
return locale, nil
}
func (s *SqlAutoTranslationStore) GetActiveDestinationLanguages(channelID, excludeUserID string, filterUserIDs []string) ([]string, error) {
query := s.getQueryBuilder().
Select("DISTINCT u.Locale").
From("ChannelMembers cm").
Join("Channels c ON c.Id = cm.ChannelId").
Join("Users u ON u.Id = cm.UserId").
Where(sq.Eq{"cm.ChannelId": channelID}).
Where("c.AutoTranslation = true").
Where("cm.AutoTranslationDisabled != true")
// Filter to specific user IDs if provided (e.g., users with active WebSocket connections)
// When filterUserIDs is non-nil and non-empty, squirrel converts it to an IN clause
// Example: WHERE cm.userid IN ('id1', 'id2', 'id3')
if len(filterUserIDs) > 0 {
query = query.Where(sq.Eq{"cm.userid": filterUserIDs})
}
// Exclude specific user if provided (e.g., the message author)
// This works together with the filter above via SQL AND logic
// Example: WHERE cm.userid IN (...) AND cm.userid != 'excludedId'
if excludeUserID != "" {
query = query.Where(sq.NotEq{"cm.userid": excludeUserID})
}
var languages []string
if err := s.GetReplica().SelectBuilder(&languages, query); err != nil {
return nil, errors.Wrapf(err, "failed to get active destination languages for channel_id=%s", channelID)
}
return languages, nil
}
func (s *SqlAutoTranslationStore) Get(objectType, objectID, dstLang string) (*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectID, "DstLang": dstLang})
var translation Translation
if err := s.GetReplica().GetBuilder(&translation, query); err != nil {
if err == sql.ErrNoRows {
return nil, store.NewErrNotFound("Translation", objectID)
}
return nil, errors.Wrapf(err, "failed to get translation for object_id=%s, dst_lang=%s", objectID, dstLang)
}
meta, err := translation.Meta.ToMap()
var translationTypeStr string
if err != nil {
return nil, errors.Wrapf(err, "failed to parse translation meta for object_id=%s", objectID)
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
result := &model.Translation{
ObjectID: translation.ObjectID,
ObjectType: objectType,
Lang: translation.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: translation.Confidence,
State: model.TranslationState(translation.State),
NormHash: translation.NormHash,
Meta: meta,
}
if result.Type == model.TranslationTypeObject {
result.ObjectJSON = json.RawMessage(translation.Text)
} else {
result.Text = translation.Text
}
return result, nil
}
func (s *SqlAutoTranslationStore) GetBatch(objectType string, objectIDs []string, dstLang string) (map[string]*model.Translation, error) {
if len(objectIDs) == 0 {
return make(map[string]*model.Translation), nil
}
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectIDs, "DstLang": dstLang})
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get batch translations for dst_lang=%s", dstLang)
}
result := make(map[string]*model.Translation, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result[t.ObjectID] = modelT
}
return result, nil
}
func (s *SqlAutoTranslationStore) GetAllForObject(objectType, objectID string) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"ObjectType": objectType, "ObjectId": objectID})
var translations []Translation
// Use GetMaster to avoid replica lag issues when workers fetch queued items
if err := s.GetMaster().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get all translations for object_id=%s", objectID)
}
result := make([]*model.Translation, 0, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result = append(result, modelT)
}
return result, nil
}
func (s *SqlAutoTranslationStore) Save(translation *model.Translation) error {
if err := translation.IsValid(); err != nil {
return err
}
now := model.GetMillis()
var err error
text := translation.Text
if translation.Type == model.TranslationTypeObject && len(translation.ObjectJSON) > 0 {
text = string(translation.ObjectJSON)
}
objectType := translation.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
var channelID *string
if translation.ChannelID != "" {
channelID = &translation.ChannelID
}
objectID := translation.ObjectID
// Preserve existing Meta fields and add/override "type"
metaMap := make(map[string]any)
if translation.Meta != nil {
// Copy existing Meta fields (e.g., "src_lang", "error", etc.)
maps.Copy(metaMap, translation.Meta)
}
// Always set "type" field
metaMap["type"] = string(translation.Type)
metaBytes, err := json.Marshal(metaMap)
if err != nil {
return errors.Wrap(err, "failed to marshal translation meta")
}
// Apply binary flag if enabled (required for PostgreSQL JSONB with binary_parameters=yes)
if s.IsBinaryParamEnabled() {
metaBytes = AppendBinaryFlag(metaBytes)
}
dstLang := translation.Lang
providerID := translation.Provider
confidence := translation.Confidence
query := s.getQueryBuilder().
Insert("Translations").
Columns("ObjectId", "DstLang", "ObjectType", "ChannelId", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
Values(objectID, dstLang, objectType, channelID, providerID, translation.NormHash, text, confidence, metaBytes, string(translation.State), now).
Suffix(`ON CONFLICT (ObjectId, ObjectType, dstLang)
DO UPDATE SET
ChannelId = EXCLUDED.ChannelId,
ProviderId = EXCLUDED.ProviderId,
NormHash = EXCLUDED.NormHash,
Text = EXCLUDED.Text,
Confidence = EXCLUDED.Confidence,
Meta = EXCLUDED.Meta,
State = EXCLUDED.State,
UpdateAt = EXCLUDED.UpdateAt
WHERE Translations.NormHash IS DISTINCT FROM EXCLUDED.NormHash
OR Translations.State != EXCLUDED.State`)
if _, err := s.GetMaster().ExecBuilder(query); err != nil {
return errors.Wrapf(err, "failed to save translation for object_id=%s, lang=%s", objectID, dstLang)
}
return nil
}
func (s *SqlAutoTranslationStore) GetByStateOlderThan(state model.TranslationState, olderThanMillis int64, limit int) ([]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"State": string(state)}).
Where(sq.Lt{"UpdateAt": olderThanMillis}).
OrderBy("UpdateAt ASC").
Limit(uint64(limit))
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations by state=%s older than %d", state, olderThanMillis)
}
result := make([]*model.Translation, 0, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
// Log error but continue with other translations
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
// Default objectType to "post" if not set
objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result = append(result, modelT)
}
return result, nil
}
func (s *SqlAutoTranslationStore) ClearCaches() {}
func (s *SqlAutoTranslationStore) InvalidateUserAutoTranslation(userID, channelID string) {}
func (s *SqlAutoTranslationStore) InvalidateUserLocaleCache(userID string) {}
// GetLatestPostUpdateAtForChannel returns the most recent updateAt timestamp for post translations
// in the given channel (across all locales). Uses a direct lookup on the channelid column
// for O(1) performance. Returns 0 if no translations exist.
func (s *SqlAutoTranslationStore) GetLatestPostUpdateAtForChannel(channelID string) (int64, error) {
query := s.getQueryBuilder().
Select("COALESCE(MAX(updateAt), 0)").
From("translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"objectType": model.TranslationObjectTypePost})
var updateAt int64
if err := s.GetReplica().GetBuilder(&updateAt, query); err != nil {
return 0, errors.Wrapf(err, "failed to get latest translation updateAt for channel_id=%s", channelID)
}
return updateAt, nil
}
func (s *SqlAutoTranslationStore) InvalidatePostTranslationEtag(channelID string) {}
func (s *SqlAutoTranslationStore) GetTranslationsSinceForChannel(channelID, dstLang string, since int64) (map[string]*model.Translation, error) {
query := s.getQueryBuilder().
Select("ObjectType", "ObjectId", "DstLang", "ProviderId", "NormHash", "Text", "Confidence", "Meta", "State", "UpdateAt").
From("Translations").
Where(sq.Eq{"channelid": channelID}).
Where(sq.Eq{"DstLang": dstLang}).
Where(sq.Eq{"ObjectType": model.TranslationObjectTypePost}).
Where(sq.NotEq{"State": string(model.TranslationStateProcessing)}).
Where(sq.Gt{"UpdateAt": since}).
Limit(1000)
var translations []Translation
if err := s.GetReplica().SelectBuilder(&translations, query); err != nil {
return nil, errors.Wrapf(err, "failed to get translations since for channel_id=%s dst_lang=%s", channelID, dstLang)
}
result := make(map[string]*model.Translation, len(translations))
for _, t := range translations {
var translationTypeStr string
meta, err := t.Meta.ToMap()
if err != nil {
continue
}
if v, ok := meta["type"]; ok {
if s, ok := v.(string); ok {
translationTypeStr = s
}
}
objectType := t.ObjectType
if objectType == "" {
objectType = model.TranslationObjectTypePost
}
modelT := &model.Translation{
ObjectID: t.ObjectID,
ObjectType: objectType,
Lang: t.DstLang,
Type: model.TranslationType(translationTypeStr),
Confidence: t.Confidence,
State: model.TranslationState(t.State),
NormHash: t.NormHash,
Meta: meta,
UpdateAt: t.UpdateAt,
}
if modelT.Type == model.TranslationTypeObject {
modelT.ObjectJSON = json.RawMessage(t.Text)
} else {
modelT.Text = t.Text
}
result[t.ObjectID] = modelT
}
return result, nil
}