mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
* 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>
263 lines
9.9 KiB
Go
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
|
|
}
|