Files
61993b4387 feat(i18n): add Russian (ru) language — full parity across backend, web & desktop (#1391)
* docs(i18n): design spec for Russian (ru) language support

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(i18n): register ru locale constant and IsSupported

* feat(i18n): scaffold ru catalog (en placeholders)

* feat(i18n): translate ru backend catalog to Russian

* fix(i18n): polish ru catalog grammar per review

* feat(i18n): wire ru into system messages, config enum, parity test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(i18n): add ru to web language constants and system-message locales

* feat(i18n): scaffold ru web locale dir (en placeholders)

* feat(i18n): wire ru resources into web i18next config

* feat(i18n): translate ru web locale to Russian (41 namespaces)

* fix(i18n): normalize ru web terminology (арендатор, привязка)

* fix(i18n): restore dropped 'named' in ru setup OAuth hint

* feat(i18n): wire ru into desktop i18next and language pickers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(i18n): add ru desktop locale (Russian, 16 namespaces)

* test(i18n): extend traces/hooks locale guards to cover ru

* chore(deploy): add repeatable fork-dev local build+deploy tooling

Adds a one-command, idempotent way to run goclaw from a locally built
image that merges fresh upstream dev with un-merged fork features
(Telegram trigger-words PR #1383 + Russian locale), independent of the
published :latest image.

- scripts/deploy-forkdev.sh: fetch origin/dev -> merge feature branches
  into local/fork-dev worktree -> push fork/dev -> build -> up -d.
- docker-compose.forkdev.yml: overlay pinning image to goclaw:forkdev
  with pull_policy:never so :latest never silently replaces the build.
- .gitignore: track the deploy helper (past deploy-*.sh), ignore the
  ../.goclaw-forkdev integration worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(telegram): agent-callable message reaction (message action=react)

Adds a "react" action to the message tool so an agent can set an emoji
reaction on an existing message (e.g. mark its own status post 👍 once
fully paid). Mirrors the ChannelEditor wiring: ReactionSetter capability
-> Manager.ReactToMessage -> telegram Channel.ReactToMessage (validates
against Telegram's supported reaction set; ✅/❌ are rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(config): remove ignored session scoping controls (#1405)

Co-authored-by: Collective Developer <man@collective.dev>

* feat(telegram): agent-callable message reaction (message action=react) (#1407)

Adds a "react" action to the message tool so an agent can set an emoji
reaction on an existing message (e.g. mark its own status post 👍 once
fully paid). Mirrors the ChannelEditor wiring: ReactionSetter capability
-> Manager.ReactToMessage -> telegram Channel.ReactToMessage (validates
against Telegram's supported reaction set; ✅/❌ are rejected).

Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: build & publish fork image to GHCR (temporary, pre-upstream)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(memory): use 'russian' FTS config for memory_chunks recall

Memory keyword search (memory_search.go FTS + the generated tsv column)
used the 'simple' text-search config, which does no stemming. Russian
grammatical cases therefore never matched: a genitive query token
("Дианы") could not hit the stored nominative token ("Диана"), so
memory_search returned nothing for natural queries even though the fact
was saved. This is the sole recall path on deployments without an
embedding provider (ChatGPT-OAuth/codex cannot embed), so recall was
effectively dead there.

Switch both sides to the Snowball 'russian' config so index and query
lexemes stem to the same root ("диан"), restoring case-insensitive
recall. Migration 000094 recreates the generated tsv column (a GENERATED
expression cannot be ALTERed in place; ADD COLUMN backfills all rows).

Surface parity: desktop/SQLite N/A — SQLite memory search uses LIKE, a
separate mechanism unaffected by the PG regconfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: ナム <namnhfreelancer@gmail.com>
Co-authored-by: Collective Developer <man@collective.dev>
2026-07-11 12:25:38 +07:00

263 lines
9.9 KiB
Go

// Package systemmessages renders configurable messages that GoClaw sends
// directly, outside normal LLM output.
package systemmessages
import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
)
const (
KeyPairingAccountRequired = "pairing.account_required"
KeyPairingAccountSimpleRequired = "pairing.account_simple_required"
KeyPairingGroupRequired = "pairing.group_required"
KeyPairingGroupPrivateRequired = "pairing.group_private_required"
KeyPairingApproved = "pairing.approved"
)
const defaultAppName = "GoClaw"
// Vars are {{name}} template variables used when rendering a system message.
type Vars map[string]string
// Definition describes a system message key for UIs and validation.
type Definition struct {
Key string `json:"key"`
Template string `json:"template"`
Description string `json:"description,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Descriptions map[string]string `json:"descriptions,omitempty"`
Variables []string `json:"variables,omitempty"`
}
var defaults = map[string]Definition{
KeyPairingAccountRequired: {
Key: KeyPairingAccountRequired,
Template: `{{app_name}}: access not configured.
Your {{platform}} ID: {{sender_id}}
Pairing code: {{code}}
Ask the bot owner to approve with:
{{approve_command}}`,
Description: "Sent to an unpaired direct-message user.",
Labels: map[string]string{
i18n.LocaleEN: "Account pairing required",
i18n.LocaleVI: "Yêu cầu ghép nối tài khoản",
i18n.LocaleZH: "需要配对账号",
i18n.LocaleKO: "계정 연결 필요",
i18n.LocaleRU: "Требуется привязка аккаунта",
},
Descriptions: map[string]string{
i18n.LocaleEN: "Sent to an unpaired direct-message user.",
i18n.LocaleVI: "Gửi khi người dùng nhắn riêng chưa được ghép nối.",
i18n.LocaleZH: "发送给尚未配对的私聊用户。",
i18n.LocaleKO: "아직 연결되지 않은 1:1 메시지 사용자에게 보냅니다.",
i18n.LocaleRU: "Отправляется пользователю личных сообщений без привязки.",
},
Variables: []string{"app_name", "platform", "sender_id", "code", "approve_command"},
},
KeyPairingAccountSimpleRequired: {
Key: KeyPairingAccountSimpleRequired,
Template: `🔗 This account hasn't been paired yet.
Pairing code: {{code}}
Share this code with the bot owner to get access.`,
Description: "Sent to an unpaired account when the platform does not need to show the sender ID.",
Labels: map[string]string{
i18n.LocaleEN: "Simple account pairing required",
i18n.LocaleVI: "Yêu cầu ghép nối tài khoản đơn giản",
i18n.LocaleZH: "需要配对账号(简版)",
i18n.LocaleKO: "간단 계정 연결 필요",
i18n.LocaleRU: "Требуется простая привязка аккаунта",
},
Descriptions: map[string]string{
i18n.LocaleEN: "Sent to an unpaired account when the platform does not need to show the sender ID.",
i18n.LocaleVI: "Gửi khi tài khoản chưa được ghép nối và nền tảng không cần hiển thị ID người gửi.",
i18n.LocaleZH: "当平台不需要显示发送者 ID 时,发送给尚未配对的账号。",
i18n.LocaleKO: "플랫폼에서 보낸 사람 ID를 표시할 필요가 없을 때 연결되지 않은 계정에 보냅니다.",
i18n.LocaleRU: "Отправляется аккаунту без привязки, когда платформе не нужно показывать ID отправителя.",
},
Variables: []string{"app_name", "platform", "code", "approve_command"},
},
KeyPairingGroupRequired: {
Key: KeyPairingGroupRequired,
Template: `🔗 This group hasn't been paired yet.
Pairing code: {{code}}
Share this code with the bot owner to get access.`,
Description: "Sent to an unpaired group chat.",
Labels: map[string]string{
i18n.LocaleEN: "Group pairing required",
i18n.LocaleVI: "Yêu cầu ghép nối nhóm",
i18n.LocaleZH: "需要配对群组",
i18n.LocaleKO: "그룹 연결 필요",
i18n.LocaleRU: "Требуется привязка группы",
},
Descriptions: map[string]string{
i18n.LocaleEN: "Sent to an unpaired group chat.",
i18n.LocaleVI: "Gửi khi nhóm chat chưa được ghép nối.",
i18n.LocaleZH: "发送到尚未配对的群聊。",
i18n.LocaleKO: "아직 연결되지 않은 그룹 채팅에 보냅니다.",
i18n.LocaleRU: "Отправляется в групповой чат без привязки.",
},
Variables: []string{"app_name", "platform", "code", "approve_command"},
},
KeyPairingGroupPrivateRequired: {
Key: KeyPairingGroupPrivateRequired,
Template: `This channel is not authorized to use this bot.
An admin can approve via CLI:
{{approve_command}}
Or approve via the {{app_name}} web UI (Pairing section).`,
Description: "Sent to an unpaired group/private channel where the pairing code should not be exposed directly.",
Labels: map[string]string{
i18n.LocaleEN: "Channel approval required",
i18n.LocaleVI: "Kênh cần được cấp quyền",
i18n.LocaleZH: "频道需要授权",
i18n.LocaleKO: "채널 승인 필요",
i18n.LocaleRU: "Требуется одобрение канала",
},
Descriptions: map[string]string{
i18n.LocaleEN: "Sent to an unpaired group/private channel where the pairing code should not be exposed directly.",
i18n.LocaleVI: "Gửi khi kênh hoặc nhóm chưa được cấp quyền và không nên hiển thị mã ghép nối trực tiếp.",
i18n.LocaleZH: "发送到尚未配对且不应直接暴露配对码的群组或私有频道。",
i18n.LocaleKO: "연결되지 않았고 연결 코드를 직접 노출하면 안 되는 그룹 또는 비공개 채널에 보냅니다.",
i18n.LocaleRU: "Отправляется в группу или приватный канал без привязки, где код привязки не следует показывать напрямую.",
},
Variables: []string{"app_name", "platform", "code", "approve_command"},
},
KeyPairingApproved: {
Key: KeyPairingApproved,
Template: "✅ {{app_name}} access approved. Send a message to start chatting.",
Description: "Sent after a pairing request is approved.",
Labels: map[string]string{
i18n.LocaleEN: "Pairing approved",
i18n.LocaleVI: "Đã phê duyệt ghép nối",
i18n.LocaleZH: "配对已批准",
i18n.LocaleKO: "연결 승인됨",
i18n.LocaleRU: "Привязка одобрена",
},
Descriptions: map[string]string{
i18n.LocaleEN: "Sent after a pairing request is approved.",
i18n.LocaleVI: "Gửi sau khi yêu cầu ghép nối được phê duyệt.",
i18n.LocaleZH: "配对请求获批后发送。",
i18n.LocaleKO: "연결 요청이 승인된 뒤 보냅니다.",
i18n.LocaleRU: "Отправляется после одобрения запроса на привязку.",
},
Variables: []string{"app_name"},
},
}
// Defaults returns a defensive copy of built-in system message definitions.
func Defaults() map[string]Definition {
out := make(map[string]Definition, len(defaults))
for key, def := range defaults {
if len(def.Labels) > 0 {
def.Labels = copyStringMap(def.Labels)
}
if len(def.Descriptions) > 0 {
def.Descriptions = copyStringMap(def.Descriptions)
}
if len(def.Variables) > 0 {
def.Variables = append([]string(nil), def.Variables...)
}
out[key] = def
}
return out
}
func copyStringMap(src map[string]string) map[string]string {
out := make(map[string]string, len(src))
for key, value := range src {
out[key] = value
}
return out
}
// Resolver renders messages using live config snapshots.
type Resolver struct {
cfg *config.Config
}
func NewResolver(cfg *config.Config) *Resolver {
return &Resolver{cfg: cfg}
}
// Render resolves a message template by key and locale, applies variables, and
// falls back to built-in English defaults when no override exists.
func (r *Resolver) Render(locale, key string, vars Vars) string {
template := r.template(locale, key)
return renderTemplate(template, r.withDefaults(vars))
}
func (r *Resolver) template(locale, key string) string {
normalized := r.locale(locale)
if r != nil && r.cfg != nil {
if messages := r.cfg.SystemMessagesSnapshot().Messages; len(messages) > 0 {
if byLocale := messages[key]; len(byLocale) > 0 {
if template := strings.TrimSpace(byLocale[normalized]); template != "" {
return template
}
if template := strings.TrimSpace(byLocale[i18n.LocaleEN]); template != "" {
return template
}
}
}
}
if def, ok := defaults[key]; ok {
return def.Template
}
return key
}
func (r *Resolver) locale(locale string) string {
if strings.TrimSpace(locale) != "" {
return i18n.Normalize(locale)
}
if r != nil && r.cfg != nil {
if configured := strings.TrimSpace(r.cfg.SystemMessagesSnapshot().DefaultLocale); configured != "" {
return i18n.Normalize(configured)
}
}
return i18n.LocaleEN
}
func (r *Resolver) withDefaults(vars Vars) Vars {
out := make(Vars, len(vars)+2)
for key, value := range vars {
out[key] = value
}
if strings.TrimSpace(out["app_name"]) == "" {
out["app_name"] = r.appName()
}
if strings.TrimSpace(out["approve_command"]) == "" && strings.TrimSpace(out["code"]) != "" {
out["approve_command"] = "goclaw pairing approve " + out["code"]
}
return out
}
func (r *Resolver) appName() string {
return defaultAppName
}
// Render renders a default resolver with no custom config. It is useful for
// callers that do not yet have config wiring but should share the same defaults.
func Render(locale, key string, vars Vars) string {
return NewResolver(nil).Render(locale, key, vars)
}
func renderTemplate(template string, vars Vars) string {
rendered := template
for key, value := range vars {
rendered = strings.ReplaceAll(rendered, "{{"+key+"}}", value)
}
return rendered
}