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

413 lines
14 KiB
Go

package methods
import (
"context"
"encoding/json"
"log/slog"
"slices"
"github.com/titanous/json5"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/systemmessages"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ConfigMethods handles config.get, config.apply, config.patch, config.schema.
// Matching TS src/gateway/server-methods/config.ts.
type ConfigMethods struct {
cfg *config.Config
cfgPath string
secretsStore store.ConfigSecretsStore
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
}
func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
return &ConfigMethods{cfg: cfg, cfgPath: cfgPath, secretsStore: secretsStore, eventBus: eventBus}
}
// SetSystemConfigSync sets a callback to sync config to system_configs after save.
// The callback receives the final resolved config (with secrets + env applied).
func (m *ConfigMethods) SetSystemConfigSync(fn func(ctx context.Context, cfg *config.Config)) {
m.syncFn = fn
}
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodConfigGet, m.requireMasterScope(m.requireOwner(m.handleGet)))
router.Register(protocol.MethodConfigApply, m.requireMasterScope(m.requireOwner(m.handleApply)))
router.Register(protocol.MethodConfigPatch, m.requireMasterScope(m.requireOwner(m.handlePatch)))
router.Register(protocol.MethodConfigSchema, m.requireMasterScope(m.requireOwner(m.handleSchema)))
// config.defaults is read-only + secret-free (Go consts + agents.defaults overlay),
// so it only needs requireMasterScope — owner gating would spam auth errors for
// operators viewing agent detail pages.
router.Register(protocol.MethodConfigDefaults, m.requireMasterScope(m.handleDefaults))
}
// requireOwner wraps a handler to only allow owner-role users.
func (m *ConfigMethods) requireOwner(next gateway.MethodHandler) gateway.MethodHandler {
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
if !client.IsOwner() {
locale := store.LocaleFromContext(ctx)
client.SendResponse(protocol.NewErrorResponse(
req.ID, protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
))
return
}
next(ctx, client, req)
}
}
// requireMasterScope rejects config.* calls when the caller's ctx is scoped to
// a non-master tenant. System owner callers (bypass-all) are allowed through.
//
// Background: config.* mutates the master in-memory *config.Config and the
// on-disk config.json. A non-master tenant admin calling config.patch would
// corrupt master state + leak master config to other tenants. This guard keeps
// config.* strictly master-scoped until a tenant-aware refactor lands.
//
// Shares the predicate with store.IsMasterScope so HTTP and WS layers can't
// drift — same rule, one source of truth.
func (m *ConfigMethods) requireMasterScope(next gateway.MethodHandler) gateway.MethodHandler {
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
if !store.IsMasterScope(ctx) {
locale := store.LocaleFromContext(ctx)
client.SendResponse(protocol.NewErrorResponse(
req.ID,
protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgConfigMasterScopeOnly),
))
return
}
next(ctx, client, req)
}
}
func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"config": m.cfg.MaskedCopy(),
"hash": m.cfg.Hash(),
"path": m.cfgPath,
}))
}
// handleApply replaces the entire config with the provided JSON5 raw content.
// Matching TS config.apply (src/gateway/server-methods/config.ts:435-486).
func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Raw string `json:"raw"`
BaseHash string `json:"baseHash"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Raw == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawConfigRequired)))
return
}
// Optimistic concurrency: validate hash if provided
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
return
}
// Parse the new config
newCfg := config.Default()
if err := json5.Unmarshal([]byte(params.Raw), newCfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
return
}
// Extract secrets → save to config_secrets table, strip all from file
m.saveSecretsToStore(ctx, newCfg)
newCfg.StripSecrets()
// Save to disk
if err := config.Save(m.cfgPath, newCfg); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
// Update in-memory config and restore secrets
m.cfg.ReplaceFrom(newCfg)
if m.secretsStore != nil {
if secrets, err := m.secretsStore.GetAll(ctx); err == nil {
m.cfg.ApplyDBSecrets(secrets)
}
}
m.cfg.ApplyEnvOverrides()
m.syncToSystemConfigs(ctx)
m.broadcastChanged()
emitAudit(m.eventBus, client, "config.applied", "config", "gateway")
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
"path": m.cfgPath,
"config": m.cfg.MaskedCopy(),
"hash": m.cfg.Hash(),
"restart": false,
}))
}
// handlePatch merges a partial config update into the current config.
// Matching TS config.patch (src/gateway/server-methods/config.ts:321-434).
func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
Raw string `json:"raw"`
BaseHash string `json:"baseHash"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.Raw == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawPatchRequired)))
return
}
// Optimistic concurrency
if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
return
}
// Start from a locked current-config snapshot as base.
merged := m.cfg.Clone()
// Apply patch on top
if err := json5.Unmarshal([]byte(params.Raw), merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
return
}
// Extract secrets → save to config_secrets table, strip all from file
m.saveSecretsToStore(ctx, merged)
merged.StripSecrets()
// Save to disk
if err := config.Save(m.cfgPath, merged); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
return
}
// Update in-memory config and restore secrets
m.cfg.ReplaceFrom(merged)
if m.secretsStore != nil {
if secrets, err := m.secretsStore.GetAll(ctx); err == nil {
m.cfg.ApplyDBSecrets(secrets)
}
}
m.cfg.ApplyEnvOverrides()
m.syncToSystemConfigs(ctx)
m.broadcastChanged()
emitAudit(m.eventBus, client, "config.patched", "config", "gateway")
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
"path": m.cfgPath,
"config": m.cfg.MaskedCopy(),
"hash": m.cfg.Hash(),
"restart": false,
}))
}
// syncToSystemConfigs syncs the resolved config to system_configs table for the given tenant.
func (m *ConfigMethods) syncToSystemConfigs(ctx context.Context) {
if m.syncFn != nil {
m.syncFn(ctx, m.cfg)
}
}
// broadcastChanged notifies subscribers that config has been updated.
func (m *ConfigMethods) broadcastChanged() {
if m.eventBus != nil {
m.eventBus.Broadcast(bus.Event{Name: bus.TopicConfigChanged, Payload: m.cfg})
}
}
// handleSchema returns the config JSON schema for UI form generation.
// Matching TS config.schema (src/gateway/server-methods/config.ts:276-289).
func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{
"agents": map[string]any{
"type": "object",
"description": "Agent configuration (defaults + per-agent overrides)",
},
"channels": map[string]any{
"type": "object",
"description": "Channel configuration (telegram, discord, slack, etc.)",
},
"providers": map[string]any{
"type": "object",
"description": "AI provider API keys and settings",
},
"gateway": map[string]any{
"type": "object",
"description": "Gateway server settings (host, port, token)",
},
"branding": map[string]any{
"type": "object",
"description": "Public app branding and SEO metadata",
"properties": map[string]any{
"app_name": map[string]any{
"type": "string",
"description": "Application name shown in browser metadata and UI chrome",
},
"app_short_name": map[string]any{
"type": "string",
"description": "Short application name for compact surfaces",
},
"meta_title": map[string]any{
"type": "string",
"description": "HTML document title override",
},
"meta_description": map[string]any{
"type": "string",
"description": "HTML meta description override",
},
"meta_keywords": map[string]any{
"type": "string",
"description": "HTML meta keywords override",
},
"logo_url": map[string]any{
"type": "string",
"description": "Logo URL or uploaded /branding-assets/* path",
},
"favicon_url": map[string]any{
"type": "string",
"description": "Favicon URL or uploaded /branding-assets/* path",
},
"apple_touch_icon_url": map[string]any{
"type": "string",
"description": "Apple touch icon URL or uploaded /branding-assets/* path",
},
"og_title": map[string]any{
"type": "string",
"description": "Open Graph title override",
},
"og_description": map[string]any{
"type": "string",
"description": "Open Graph description override",
},
"og_image_url": map[string]any{
"type": "string",
"description": "Open Graph image URL or uploaded /branding-assets/* path",
},
"theme_color": map[string]any{
"type": "string",
"description": "Browser theme-color value",
},
},
},
"tools": map[string]any{
"type": "object",
"description": "Tool configuration (browser, exec, web search)",
},
"skills": map[string]any{
"type": "object",
"description": "Skill storage and upload settings",
"properties": map[string]any{
"max_upload_size_mb": map[string]any{
"type": "integer",
"minimum": config.MinSkillMaxUploadSizeMB,
"maximum": config.MaxSkillMaxUploadSizeMB,
"default": config.DefaultSkillMaxUploadSizeMB,
"description": "Maximum skill ZIP upload size in MB",
},
"slash_commands": map[string]any{
"type": "object",
"description": "Explicit slash command skill activation settings",
"properties": map[string]any{
"enabled": map[string]any{
"type": "boolean",
"default": true,
"description": "Enable slash command detection in user prompts",
},
"suggest_not_found": map[string]any{
"type": "boolean",
"default": true,
"description": "Suggest similar skills when a requested skill is not found",
},
"partial_matching": map[string]any{
"type": "boolean",
"default": false,
"description": "Allow unique skill slug/name prefixes",
},
"prefix": map[string]any{
"type": "string",
"default": config.DefaultSkillSlashCommandPrefix,
"description": "Single-character slash command prefix",
},
},
},
},
},
"sessions": map[string]any{
"type": "object",
"description": "Session storage configuration",
},
"system_messages": map[string]any{
"type": "object",
"description": "Custom operator-facing system messages sent outside normal LLM replies",
"properties": map[string]any{
"default_locale": map[string]any{
"type": "string",
"enum": []string{"en", "vi", "zh", "ko", "ru"},
"description": "Default locale used when a channel caller does not provide a locale",
},
"messages": map[string]any{
"type": "object",
"description": "Message template overrides keyed by message key and locale",
},
},
"definitions": systemMessageSchemaDefinitions(),
},
},
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"json": schema,
}))
}
func systemMessageSchemaDefinitions() []systemmessages.Definition {
defaults := systemmessages.Defaults()
keys := make([]string, 0, len(defaults))
for key := range defaults {
keys = append(keys, key)
}
slices.Sort(keys)
defs := make([]systemmessages.Definition, 0, len(keys))
for _, key := range keys {
defs = append(defs, defaults[key])
}
return defs
}
// saveSecretsToStore extracts non-LLM/non-channel secrets from the config
// and persists them to the config_secrets table.
func (m *ConfigMethods) saveSecretsToStore(ctx context.Context, cfg *config.Config) {
if m.secretsStore == nil {
return
}
secrets := cfg.ExtractDBSecrets()
for key, value := range secrets {
if err := m.secretsStore.Set(ctx, key, value); err != nil {
slog.Warn("failed to save config secret", "key", key, "error", err)
}
}
}