From e1fcccea4f9d1fdb38826c5268b3d306fe2c1b59 Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Sat, 4 Jul 2026 21:57:14 +0700 Subject: [PATCH] feat: add configurable system messages (#1343) Co-authored-by: GoClaw Operator --- cmd/gateway.go | 2 + cmd/gateway_channels_setup.go | 6 +- internal/channels/channel.go | 13 + internal/channels/discord/handler.go | 10 +- internal/channels/feishu/bot_policy.go | 10 +- internal/channels/manager.go | 24 ++ internal/channels/slack/handlers_mention.go | 15 +- internal/channels/system_messages_test.go | 60 +++++ .../channels/telegram/commands_pairing.go | 30 ++- internal/channels/whatsapp/policy.go | 10 +- internal/channels/zalo/personal/policy.go | 10 +- internal/channels/zalo/zalo.go | 10 +- internal/config/config.go | 42 +++ .../config/system_messages_config_test.go | 63 +++++ internal/gateway/methods/config.go | 33 +++ .../gateway/methods/config_schema_test.go | 94 +++++++ internal/systemmessages/resolver.go | 252 ++++++++++++++++++ internal/systemmessages/resolver_test.go | 96 +++++++ ui/web/src/i18n/locales/en/config.json | 19 ++ ui/web/src/i18n/locales/ko/config.json | 19 ++ ui/web/src/i18n/locales/vi/config.json | 19 ++ ui/web/src/i18n/locales/zh/config.json | 19 ++ ui/web/src/pages/config/config-page.tsx | 13 +- ui/web/src/pages/config/hooks/use-config.ts | 11 +- .../system-messages-section-utils.test.ts | 139 ++++++++++ .../sections/system-messages-section-utils.ts | 114 ++++++++ .../sections/system-messages-section.tsx | 188 +++++++++++++ 27 files changed, 1280 insertions(+), 41 deletions(-) create mode 100644 internal/channels/system_messages_test.go create mode 100644 internal/config/system_messages_config_test.go create mode 100644 internal/gateway/methods/config_schema_test.go create mode 100644 internal/systemmessages/resolver.go create mode 100644 internal/systemmessages/resolver_test.go create mode 100644 ui/web/src/pages/config/sections/system-messages-section-utils.test.ts create mode 100644 ui/web/src/pages/config/sections/system-messages-section-utils.ts create mode 100644 ui/web/src/pages/config/sections/system-messages-section.tsx diff --git a/cmd/gateway.go b/cmd/gateway.go index d665c0d2..4592b3ca 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -48,6 +48,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/security" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" "github.com/nextlevelbuilder/goclaw/internal/tools" usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps" usagepricing "github.com/nextlevelbuilder/goclaw/internal/usage/pricing" @@ -689,6 +690,7 @@ func runGateway() { // Channel manager channelMgr := channels.NewManager(msgBus) + channelMgr.SetSystemMessages(systemmessages.NewResolver(cfg)) deps.channelMgr = channelMgr // Wire channel member resolver into permission grant paths (WS + HTTP) so diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 9cab7bd0..c5b53985 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -24,6 +24,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -216,13 +217,16 @@ func wireChannelEventSubscribers( // Wire pairing approval notification → channel (matching TS notifyPairingApproved). botName := cfg.ResolveDisplayName("default") + messageResolver := systemmessages.NewResolver(cfg) pairingMethods.SetOnApprove(func(ctx context.Context, channel, chatID, senderID string) { // Browser/internal channels use WebSocket — UI polls approval status directly. if channels.IsInternalChannel(channel) { slog.Debug("pairing approved for internal channel, skipping notification", "channel", channel) return } - msg := fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName) + msg := messageResolver.Render("", systemmessages.KeyPairingApproved, systemmessages.Vars{ + "app_name": botName, + }) // Group pairings need group_id metadata so channels (e.g. Zalo) route to group API. if strings.HasPrefix(senderID, "group:") { msgBus.PublishOutbound(bus.OutboundMessage{ diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 1b35b035..961e5ba8 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -22,6 +22,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) // PolicyResult is returned by BaseChannel policy checks. @@ -214,6 +215,7 @@ type BaseChannel struct { // Shared policy + pairing fields (set via setters after construction). pairingService store.PairingStore + systemMessages *systemmessages.Resolver groupHistory *PendingHistory historyLimit int approvedGroups sync.Map // chatID → true (in-memory cache for paired group approval) @@ -272,6 +274,17 @@ func (c *BaseChannel) SetPairingService(ps store.PairingStore) { c.pairingServic // PairingService returns the configured pairing store (may be nil). func (c *BaseChannel) PairingService() store.PairingStore { return c.pairingService } +// SetSystemMessages sets the resolver used for operator/system messages. +func (c *BaseChannel) SetSystemMessages(r *systemmessages.Resolver) { c.systemMessages = r } + +// SystemMessage renders a configurable operator/system message. +func (c *BaseChannel) SystemMessage(locale, key string, vars systemmessages.Vars) string { + if c.systemMessages != nil { + return c.systemMessages.Render(locale, key, vars) + } + return systemmessages.Render(locale, key, vars) +} + // SetGroupHistory sets the pending group history tracker. func (c *BaseChannel) SetGroupHistory(gh *PendingHistory) { c.groupHistory = gh } diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index fc7df229..3ad393db 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -16,6 +16,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -404,10 +405,11 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID stri return } - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour Discord user ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) + replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Discord", + "sender_id": senderID, + "code": code, + }) if _, err := c.session.ChannelMessageSend(channelID, replyText); err != nil { slog.Warn("failed to send discord pairing reply", "error", err) diff --git a/internal/channels/feishu/bot_policy.go b/internal/channels/feishu/bot_policy.go index 4cdc000e..3d80eac5 100644 --- a/internal/channels/feishu/bot_policy.go +++ b/internal/channels/feishu/bot_policy.go @@ -8,6 +8,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) // --- Sender name resolution --- @@ -216,10 +217,11 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) return } - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour Feishu open_id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) + replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Feishu", + "sender_id": senderID, + "code": code, + }) receiveIDType := resolveReceiveIDType(chatID) if err := c.sendText(context.Background(), chatID, receiveIDType, replyText, ""); err != nil { diff --git a/internal/channels/manager.go b/internal/channels/manager.go index a7ca96c9..46f6fe89 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -11,6 +11,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) // ChannelStream is the per-run streaming handle stored on RunContext. @@ -69,6 +70,7 @@ type Manager struct { dispatchTask *asyncTask mu sync.RWMutex contactCollector *store.ContactCollector + systemMessages *systemmessages.Resolver } type asyncTask struct { @@ -196,6 +198,13 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { bc.SetContactCollector(m.contactCollector) } } + if m.systemMessages != nil { + if sm, ok := channel.(interface { + SetSystemMessages(*systemmessages.Resolver) + }); ok { + sm.SetSystemMessages(m.systemMessages) + } + } m.channels[name] = channel if hc, ok := channel.(interface{ MarkRegistered(string) }); ok { hc.MarkRegistered("Configured") @@ -203,6 +212,21 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.syncChannelHealthLocked(name, channel) } +// SetSystemMessages sets the resolver propagated to channels registered now and +// in the future. +func (m *Manager) SetSystemMessages(r *systemmessages.Resolver) { + m.mu.Lock() + defer m.mu.Unlock() + m.systemMessages = r + for _, channel := range m.channels { + if sm, ok := channel.(interface { + SetSystemMessages(*systemmessages.Resolver) + }); ok { + sm.SetSystemMessages(r) + } + } +} + // RecordHealth stores runtime health for an instance, including failures before registration. func (m *Manager) RecordHealth(name string, snapshot ChannelHealth) { m.mu.Lock() diff --git a/internal/channels/slack/handlers_mention.go b/internal/channels/slack/handlers_mention.go index 57e5a0a9..b949e85b 100644 --- a/internal/channels/slack/handlers_mention.go +++ b/internal/channels/slack/handlers_mention.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -181,12 +182,16 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID stri // Instead, direct admin to CLI or web UI where pending codes are listed. var msg string if strings.HasPrefix(senderID, "group:") { - msg = fmt.Sprintf("This channel is not authorized to use this bot.\n\n"+ - "An admin can approve via CLI:\n goclaw pairing approve %s\n\n"+ - "Or approve via the GoClaw web UI (Pairing section).", code) + msg = c.SystemMessage("", systemmessages.KeyPairingGroupPrivateRequired, systemmessages.Vars{ + "platform": "Slack", + "code": code, + }) } else { - msg = fmt.Sprintf("GoClaw: access not configured.\n\nYour Slack user ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code) + msg = c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Slack", + "sender_id": senderID, + "code": code, + }) } if _, _, err := c.api.PostMessage(channelID, slackapi.MsgOptionText(msg, false)); err != nil { slog.Warn("slack: failed to send pairing reply", diff --git a/internal/channels/system_messages_test.go b/internal/channels/system_messages_test.go new file mode 100644 index 00000000..2e0b6fff --- /dev/null +++ b/internal/channels/system_messages_test.go @@ -0,0 +1,60 @@ +package channels + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" +) + +func TestBaseChannelRendersConfiguredSystemMessage(t *testing.T) { + cfg := config.Default() + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + systemmessages.KeyPairingGroupRequired: { + i18n.LocaleEN: "Custom group code {{code}}", + }, + } + + base := NewBaseChannel("telegram-main", nil, nil) + base.SetSystemMessages(systemmessages.NewResolver(cfg)) + + got := base.SystemMessage(i18n.LocaleEN, systemmessages.KeyPairingGroupRequired, systemmessages.Vars{"code": "XYZ"}) + want := "Custom group code XYZ" + if got != want { + t.Fatalf("SystemMessage = %q, want %q", got, want) + } +} + +func TestBaseChannelSystemMessageUsesConfiguredDefaultLocale(t *testing.T) { + cfg := config.Default() + cfg.Messages.DefaultLocale = i18n.LocaleVI + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + systemmessages.KeyPairingAccountRequired: { + i18n.LocaleVI: "Ghép {{platform}} {{sender_id}} bằng {{code}}", + i18n.LocaleEN: "Pair {{platform}} {{sender_id}} with {{code}}", + }, + } + + base := NewBaseChannel("telegram-main", nil, nil) + base.SetSystemMessages(systemmessages.NewResolver(cfg)) + + got := base.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Telegram", + "sender_id": "U123", + "code": "XYZ", + }) + want := "Ghép Telegram U123 bằng XYZ" + if got != want { + t.Fatalf("SystemMessage empty locale = %q, want %q", got, want) + } +} + +func TestBaseChannelSystemMessageFallsBackWithoutResolver(t *testing.T) { + base := NewBaseChannel("telegram-main", nil, nil) + got := base.SystemMessage(i18n.LocaleEN, systemmessages.KeyPairingApproved, systemmessages.Vars{"app_name": "GoClaw"}) + want := "✅ GoClaw access approved. Send a message to start chatting." + if got != want { + t.Fatalf("SystemMessage fallback = %q, want %q", got, want) + } +} diff --git a/internal/channels/telegram/commands_pairing.go b/internal/channels/telegram/commands_pairing.go index 708fcd47..53b82486 100644 --- a/internal/channels/telegram/commands_pairing.go +++ b/internal/channels/telegram/commands_pairing.go @@ -8,16 +8,25 @@ import ( "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" + + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) // --- Pairing UX --- // buildPairingReply builds the pairing reply message for unpaired users. -func buildPairingReply(code string) string { - return fmt.Sprintf( - "🔗 This account hasn't been paired yet.\n\nPairing code: %s\n\nShare this code with the bot owner to get access.", - code, - ) +func (c *Channel) buildPairingReply(code string) string { + return c.SystemMessage("", systemmessages.KeyPairingAccountSimpleRequired, systemmessages.Vars{ + "platform": "Telegram", + "code": code, + }) +} + +func (c *Channel) buildGroupPairingReply(code string) string { + return c.SystemMessage("", systemmessages.KeyPairingGroupRequired, systemmessages.Vars{ + "platform": "Telegram", + "code": code, + }) } // sendPairingReply generates a pairing code and sends the reply to the user. @@ -40,7 +49,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, chatID int64, userID, us return } - replyText := buildPairingReply(code) + replyText := c.buildPairingReply(code) msg := tu.Message(tu.ID(chatID), replyText) if _, err := c.bot.SendMessage(ctx, msg); err != nil { slog.Warn("failed to send pairing reply", "chat_id", chatID, "error", err) @@ -77,10 +86,7 @@ func (c *Channel) sendGroupPairingReply(ctx context.Context, chatID int64, chatI return } - replyText := fmt.Sprintf( - "🔗 This group hasn't been paired yet.\n\nPairing code: %s\n\nShare this code with the bot owner to get access.", - code, - ) + replyText := c.buildGroupPairingReply(code) msg := tu.Message(tu.ID(chatID), replyText) if messageThreadID > 0 { msg.MessageThreadID = messageThreadID @@ -110,7 +116,9 @@ func (c *Channel) SendPairingApproved(ctx context.Context, chatID, botName strin botName = "GoClaw" } - msg := tu.Message(tu.ID(id), fmt.Sprintf("✅ %s access approved. Send a message to start chatting.", botName)) + msg := tu.Message(tu.ID(id), c.SystemMessage("", systemmessages.KeyPairingApproved, systemmessages.Vars{ + "app_name": botName, + })) // Extract thread ID from topic/thread suffix for forum groups. if idx := strings.Index(chatID, ":topic:"); idx > 0 { diff --git a/internal/channels/whatsapp/policy.go b/internal/channels/whatsapp/policy.go index 199b9e5d..b2fc0da5 100644 --- a/internal/channels/whatsapp/policy.go +++ b/internal/channels/whatsapp/policy.go @@ -9,6 +9,7 @@ import ( "go.mau.fi/whatsmeow/types" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) // checkGroupPolicy evaluates the group policy for a sender. @@ -64,10 +65,11 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) return } - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) + replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "WhatsApp", + "sender_id": senderID, + "code": code, + }) if c.client == nil || !c.client.IsConnected() { slog.Warn("whatsapp not connected, cannot send pairing reply") diff --git a/internal/channels/zalo/personal/policy.go b/internal/channels/zalo/personal/policy.go index 2ad24499..a5f15f16 100644 --- a/internal/channels/zalo/personal/policy.go +++ b/internal/channels/zalo/personal/policy.go @@ -9,6 +9,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) const pairingDebounce = 60 * time.Second @@ -62,10 +63,11 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) return } - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour Zalo user id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) + replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Zalo", + "sender_id": senderID, + "code": code, + }) threadType := protocol.ThreadTypeUser if strings.HasPrefix(senderID, "group:") { diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index b50460de..e01d5590 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -21,6 +21,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/systemmessages" ) const ( @@ -322,10 +323,11 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) return } - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour Zalo user id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) + replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ + "platform": "Zalo", + "sender_id": senderID, + "code": code, + }) if err := c.sendMessage(chatID, replyText); err != nil { slog.Warn("failed to send zalo pairing reply", "error", err) diff --git a/internal/config/config.go b/internal/config/config.go index 362e0c88..8c7447f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,7 @@ type Config struct { Bindings []AgentBinding `json:"bindings,omitempty"` Hooks HooksConfig `json:"hooks"` Packages PackagesConfig `json:"packages"` // runtime package mgmt (GitHub updater) + Messages SystemMsgConfig `json:"system_messages,omitempty"` mu sync.RWMutex } @@ -82,6 +83,38 @@ type PackagesConfig struct { } // UpdatesCheckTTLDuration parses UpdatesCheckTTL returning 1h on empty/invalid. +// SystemMsgConfig customizes operator-facing system messages that GoClaw +// sends directly, outside normal LLM replies. Message templates use +// {{variable}} placeholders and may be overridden per locale. +type SystemMsgConfig struct { + DefaultLocale string `json:"default_locale,omitempty"` + Messages map[string]LocalizedSystemMessage `json:"messages,omitempty"` +} + +// LocalizedSystemMessage maps locale code ("en", "vi", "zh", "ko") to a +// template override for one system message key. +type LocalizedSystemMessage map[string]string + +// Clone returns a deep copy safe for snapshots and ReplaceFrom. +func (s SystemMsgConfig) Clone() SystemMsgConfig { + out := SystemMsgConfig{DefaultLocale: strings.TrimSpace(s.DefaultLocale)} + if len(s.Messages) == 0 { + return out + } + out.Messages = make(map[string]LocalizedSystemMessage, len(s.Messages)) + for key, byLocale := range s.Messages { + if len(byLocale) == 0 { + continue + } + cp := make(LocalizedSystemMessage, len(byLocale)) + for locale, template := range byLocale { + cp[locale] = template + } + out.Messages[key] = cp + } + return out +} + func (p PackagesConfig) UpdatesCheckTTLDuration() time.Duration { if p.UpdatesCheckTTL == "" { return time.Hour @@ -583,6 +616,7 @@ func (c *Config) ReplaceFrom(src *Config) { c.Telemetry = src.Telemetry c.Tailscale = src.Tailscale c.Bindings = src.Bindings + c.Messages = src.Messages.Clone() } // Clone returns a deep copy of the config while holding the read lock. @@ -615,6 +649,14 @@ func (c *Config) ShellDenyGroupsSnapshot() map[string]bool { return groups } +// SystemMessagesSnapshot returns a deep copy of configured system-message +// overrides without exposing mutable config state to long-lived channels. +func (c *Config) SystemMessagesSnapshot() SystemMsgConfig { + c.mu.RLock() + defer c.mu.RUnlock() + return c.Messages.Clone() +} + // IdentityConfig defines agent persona / display identity. type IdentityConfig struct { Name string `json:"name,omitempty"` diff --git a/internal/config/system_messages_config_test.go b/internal/config/system_messages_config_test.go new file mode 100644 index 00000000..9fc84ec0 --- /dev/null +++ b/internal/config/system_messages_config_test.go @@ -0,0 +1,63 @@ +package config + +import "testing" + +func TestSystemMsgConfig_SaveLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + path := dir + "/config.json" + cfg := Default() + cfg.Messages = SystemMsgConfig{Messages: map[string]LocalizedSystemMessage{ + "pairing.group_required": { + "en": "Custom group pairing code {{code}}", + "vi": "Ma ghep nhom {{code}}", + }, + }} + cfg.Messages.DefaultLocale = "vi" + + if err := Save(path, cfg); err != nil { + t.Fatalf("Save error: %v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load error: %v", err) + } + if got := loaded.Messages.Messages["pairing.group_required"]["vi"]; got != "Ma ghep nhom {{code}}" { + t.Errorf("system message round-trip: got %q", got) + } + if loaded.Messages.DefaultLocale != "vi" { + t.Errorf("system message default locale round-trip: got %q", loaded.Messages.DefaultLocale) + } +} + +func TestSystemMsgConfig_CloneAndReplaceFromDeepCopy(t *testing.T) { + src := Default() + src.Messages = SystemMsgConfig{Messages: map[string]LocalizedSystemMessage{ + "pairing.approved": {"en": "Approved {{app_name}}"}, + }} + src.Messages.DefaultLocale = "vi" + + clone := src.Clone() + if got := clone.Messages.Messages["pairing.approved"]["en"]; got != "Approved {{app_name}}" { + t.Fatalf("Clone system messages = %#v", clone.Messages) + } + if clone.Messages.DefaultLocale != "vi" { + t.Fatalf("Clone system messages default locale = %q", clone.Messages.DefaultLocale) + } + clone.Messages.Messages["pairing.approved"]["en"] = "mutated" + if got := src.Messages.Messages["pairing.approved"]["en"]; got != "Approved {{app_name}}" { + t.Fatalf("Clone should deep-copy system messages, src got %q", got) + } + + dst := Default() + dst.ReplaceFrom(src) + if got := dst.Messages.Messages["pairing.approved"]["en"]; got != "Approved {{app_name}}" { + t.Fatalf("ReplaceFrom system messages = %#v", dst.Messages) + } + if dst.Messages.DefaultLocale != "vi" { + t.Fatalf("ReplaceFrom system messages default locale = %q", dst.Messages.DefaultLocale) + } + dst.Messages.Messages["pairing.approved"]["en"] = "mutated" + if got := src.Messages.Messages["pairing.approved"]["en"]; got != "Approved {{app_name}}" { + t.Fatalf("ReplaceFrom should deep-copy system messages, src got %q", got) + } +} diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index d068d055..56b70c6e 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "slices" "github.com/titanous/json5" @@ -12,6 +13,7 @@ import ( "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" ) @@ -301,6 +303,22 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, "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"}, + "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(), + }, }, } @@ -309,6 +327,21 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, })) } +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) { diff --git a/internal/gateway/methods/config_schema_test.go b/internal/gateway/methods/config_schema_test.go new file mode 100644 index 00000000..4c1635ab --- /dev/null +++ b/internal/gateway/methods/config_schema_test.go @@ -0,0 +1,94 @@ +package methods + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +func TestConfigSchemaIncludesSystemMessageDefinitions(t *testing.T) { + t.Parallel() + + methods := NewConfigMethods(config.Default(), "", nil, nil) + client, responses := gateway.NewCapturingTestClient(permissions.RoleOwner, store.MasterTenantID, "owner", 1) + + methods.handleSchema( + store.WithTenantID(context.Background(), store.MasterTenantID), + client, + &protocol.RequestFrame{ + Type: protocol.FrameTypeRequest, + ID: "schema-system-messages", + Method: protocol.MethodConfigSchema, + }, + ) + + res := readConfigSchemaResponse(t, responses) + if !res.OK { + t.Fatalf("config.schema failed: %#v", res.Error) + } + raw, err := json.Marshal(res.Payload) + if err != nil { + t.Fatal(err) + } + var payload struct { + JSON struct { + Properties map[string]struct { + Properties map[string]any `json:"properties"` + Definitions []struct { + Key string `json:"key"` + Template string `json:"template"` + Labels map[string]string `json:"labels"` + Descriptions map[string]string `json:"descriptions"` + Variables []string `json:"variables"` + } `json:"definitions"` + } `json:"properties"` + } `json:"json"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + defs := payload.JSON.Properties["system_messages"].Definitions + if len(defs) == 0 { + t.Fatal("system_messages definitions missing from config.schema") + } + if defs[0].Key == "" || defs[0].Template == "" || len(defs[0].Variables) == 0 { + t.Fatalf("system_messages definition incomplete: %#v", defs[0]) + } + foundLocalizedMetadata := false + for _, def := range defs { + if def.Key == "pairing.account_required" { + if def.Labels["vi"] == "" || def.Descriptions["vi"] == "" { + t.Fatalf("pairing.account_required missing Vietnamese metadata: %#v", def) + } + foundLocalizedMetadata = true + } + } + if !foundLocalizedMetadata { + t.Fatal("pairing.account_required definition missing from config.schema") + } + if _, ok := payload.JSON.Properties["system_messages"].Properties["default_locale"]; !ok { + t.Fatal("system_messages.default_locale missing from config.schema") + } +} + +func readConfigSchemaResponse(t *testing.T, responses <-chan []byte) protocol.ResponseFrame { + t.Helper() + select { + case raw := <-responses: + var res protocol.ResponseFrame + if err := json.Unmarshal(raw, &res); err != nil { + t.Fatal(err) + } + return res + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for config.schema response") + return protocol.ResponseFrame{} + } +} diff --git a/internal/systemmessages/resolver.go b/internal/systemmessages/resolver.go new file mode 100644 index 00000000..8334e9ea --- /dev/null +++ b/internal/systemmessages/resolver.go @@ -0,0 +1,252 @@ +// 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: "계정 연결 필요", + }, + 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 메시지 사용자에게 보냅니다.", + }, + 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: "간단 계정 연결 필요", + }, + 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를 표시할 필요가 없을 때 연결되지 않은 계정에 보냅니다.", + }, + 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: "그룹 연결 필요", + }, + 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: "아직 연결되지 않은 그룹 채팅에 보냅니다.", + }, + 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: "채널 승인 필요", + }, + 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: "연결되지 않았고 연결 코드를 직접 노출하면 안 되는 그룹 또는 비공개 채널에 보냅니다.", + }, + 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: "연결 승인됨", + }, + 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: "연결 요청이 승인된 뒤 보냅니다.", + }, + 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 +} diff --git a/internal/systemmessages/resolver_test.go b/internal/systemmessages/resolver_test.go new file mode 100644 index 00000000..94f7063d --- /dev/null +++ b/internal/systemmessages/resolver_test.go @@ -0,0 +1,96 @@ +package systemmessages + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" +) + +func TestResolverUsesLocaleOverrideAndVariables(t *testing.T) { + cfg := config.Default() + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + KeyPairingGroupRequired: { + i18n.LocaleVI: "{{app_name}} cần ghép nối nhóm. Mã: {{code}}.", + }, + } + + r := NewResolver(cfg) + got := r.Render(i18n.LocaleVI, KeyPairingGroupRequired, Vars{"app_name": "AcmeBot", "code": "ABCD1234"}) + want := "AcmeBot cần ghép nối nhóm. Mã: ABCD1234." + if got != want { + t.Fatalf("Render override = %q, want %q", got, want) + } +} + +func TestResolverUsesConfiguredDefaultLocaleWhenCallerLocaleIsEmpty(t *testing.T) { + cfg := config.Default() + cfg.Messages.DefaultLocale = i18n.LocaleVI + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + KeyPairingGroupRequired: { + i18n.LocaleVI: "Nhóm cần ghép nối. Mã: {{code}}.", + i18n.LocaleEN: "Group needs pairing. Code: {{code}}.", + }, + } + + r := NewResolver(cfg) + got := r.Render("", KeyPairingGroupRequired, Vars{"code": "ABCD1234"}) + want := "Nhóm cần ghép nối. Mã: ABCD1234." + if got != want { + t.Fatalf("Render empty locale with configured default = %q, want %q", got, want) + } +} + +func TestResolverFallsBackToEnglishOverrideThenDefaultTemplate(t *testing.T) { + cfg := config.Default() + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + KeyPairingAccountRequired: { + i18n.LocaleEN: "Pair {{platform}} user {{sender_id}} with {{code}}", + }, + } + + r := NewResolver(cfg) + got := r.Render(i18n.LocaleKO, KeyPairingAccountRequired, Vars{ + "platform": "Telegram", + "sender_id": "U123", + "code": "PAIRME", + }) + want := "Pair Telegram user U123 with PAIRME" + if got != want { + t.Fatalf("Render English fallback = %q, want %q", got, want) + } + + got = r.Render(i18n.LocaleVI, KeyPairingApproved, Vars{"app_name": "GoClaw"}) + want = "✅ GoClaw access approved. Send a message to start chatting." + if got != want { + t.Fatalf("Render default template = %q, want %q", got, want) + } +} + +func TestResolverLeavesUnknownVariablesVisible(t *testing.T) { + cfg := config.Default() + cfg.Messages.Messages = map[string]config.LocalizedSystemMessage{ + KeyPairingGroupRequired: { + i18n.LocaleEN: "Known {{code}} unknown {{missing}}", + }, + } + + r := NewResolver(cfg) + got := r.Render(i18n.LocaleEN, KeyPairingGroupRequired, Vars{"code": "123"}) + want := "Known 123 unknown {{missing}}" + if got != want { + t.Fatalf("Render unknown variables = %q, want %q", got, want) + } +} + +func TestKnownMessagesExposeDefaultsForUI(t *testing.T) { + defs := Defaults() + for _, key := range []string{KeyPairingAccountRequired, KeyPairingGroupRequired, KeyPairingGroupPrivateRequired, KeyPairingApproved} { + if defs[key].Key != key { + t.Fatalf("Defaults()[%q] missing or wrong key", key) + } + if defs[key].Template == "" { + t.Fatalf("Defaults()[%q] has empty template", key) + } + } +} diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 61a4a43e..ae9fe17e 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -351,6 +351,25 @@ "bindings.any": "Any", + "tabs.systemMessages": "System Messages", + "systemMessages.title": "Custom System Messages", + "systemMessages.description": "Override built-in gateway messages sent directly by channels", + "systemMessages.message": "Message", + "systemMessages.messageTip": "Built-in system message key to customize.", + "systemMessages.locale": "Language", + "systemMessages.defaultLocale": "Default delivery language", + "systemMessages.defaultLocaleTip": "Language used when a channel does not provide a locale.", + "systemMessages.localeTip": "Locale override to edit. Empty values fall back to the built-in default.", + "systemMessages.locale.en": "English", + "systemMessages.locale.vi": "Vietnamese", + "systemMessages.locale.zh": "Chinese", + "systemMessages.locale.ko": "Korean", + "systemMessages.override": "Override Template", + "systemMessages.overrideTip": "Template sent instead of the built-in message for the selected locale. Variables use {{name}}.", + "systemMessages.defaultTemplate": "Default Template", + "systemMessages.defaultTemplateTip": "Built-in fallback template used when the override is empty.", + "systemMessages.useDefault": "Use Default", + "toast": { "saved": "Config saved", "saveFailed": "Failed to save config" diff --git a/ui/web/src/i18n/locales/ko/config.json b/ui/web/src/i18n/locales/ko/config.json index e99c9e69..1ff7b158 100644 --- a/ui/web/src/i18n/locales/ko/config.json +++ b/ui/web/src/i18n/locales/ko/config.json @@ -308,6 +308,25 @@ "bindings.any": "모두", + "tabs.systemMessages": "시스템 메시지", + "systemMessages.title": "사용자 지정 시스템 메시지", + "systemMessages.description": "채널에서 직접 보내는 기본 게이트웨이 메시지를 재정의합니다", + "systemMessages.message": "메시지", + "systemMessages.messageTip": "사용자 지정할 기본 시스템 메시지 키입니다.", + "systemMessages.locale": "언어", + "systemMessages.defaultLocale": "기본 전송 언어", + "systemMessages.defaultLocaleTip": "채널이 locale을 제공하지 않을 때 사용할 언어입니다.", + "systemMessages.localeTip": "편집할 locale 재정의입니다. 비워 두면 기본값을 사용합니다.", + "systemMessages.locale.en": "영어", + "systemMessages.locale.vi": "베트남어", + "systemMessages.locale.zh": "중국어", + "systemMessages.locale.ko": "한국어", + "systemMessages.override": "재정의 템플릿", + "systemMessages.overrideTip": "기본 메시지 대신 보낼 템플릿입니다. 변수는 {{name}} 형식입니다.", + "systemMessages.defaultTemplate": "기본 템플릿", + "systemMessages.defaultTemplateTip": "재정의가 비어 있을 때 사용하는 기본 템플릿입니다.", + "systemMessages.useDefault": "기본값 사용", + "toast": { "saved": "설정 저장됨", "saveFailed": "설정 저장 실패" diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 01516a10..3f5094c7 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -350,6 +350,25 @@ "bindings.any": "Bất kỳ", + "tabs.systemMessages": "Thông báo hệ thống", + "systemMessages.title": "Tùy chỉnh thông báo hệ thống", + "systemMessages.description": "Ghi đè các thông báo mà gateway gửi trực tiếp qua kênh", + "systemMessages.message": "Thông báo", + "systemMessages.messageTip": "Chọn loại thông báo hệ thống cần tùy chỉnh.", + "systemMessages.locale": "Ngôn ngữ", + "systemMessages.defaultLocale": "Ngôn ngữ gửi mặc định", + "systemMessages.defaultLocaleTip": "Ngôn ngữ dùng khi kênh không cung cấp locale.", + "systemMessages.localeTip": "Ngôn ngữ của mẫu cần sửa. Để trống thì dùng mẫu mặc định có sẵn.", + "systemMessages.locale.en": "Tiếng Anh", + "systemMessages.locale.vi": "Tiếng Việt", + "systemMessages.locale.zh": "Tiếng Trung", + "systemMessages.locale.ko": "Tiếng Hàn", + "systemMessages.override": "Mẫu ghi đè", + "systemMessages.overrideTip": "Mẫu sẽ được gửi thay cho thông báo có sẵn. Biến dùng dạng {{name}}.", + "systemMessages.defaultTemplate": "Mẫu mặc định", + "systemMessages.defaultTemplateTip": "Mẫu có sẵn được dùng khi phần ghi đè để trống.", + "systemMessages.useDefault": "Dùng mặc định", + "toast": { "saved": "Đã lưu cấu hình", "saveFailed": "Không thể lưu cấu hình" diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index ac8b5b07..52be0378 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -350,6 +350,25 @@ "bindings.any": "任意", + "tabs.systemMessages": "系统消息", + "systemMessages.title": "自定义系统消息", + "systemMessages.description": "覆盖频道直接发送的内置网关消息", + "systemMessages.message": "消息", + "systemMessages.messageTip": "要自定义的内置系统消息键。", + "systemMessages.locale": "语言", + "systemMessages.defaultLocale": "默认发送语言", + "systemMessages.defaultLocaleTip": "频道未提供 locale 时使用的语言。", + "systemMessages.localeTip": "要编辑的 locale 覆盖。留空则使用内置默认值。", + "systemMessages.locale.en": "英语", + "systemMessages.locale.vi": "越南语", + "systemMessages.locale.zh": "中文", + "systemMessages.locale.ko": "韩语", + "systemMessages.override": "覆盖模板", + "systemMessages.overrideTip": "替代内置消息发送的模板。变量使用 {{name}}。", + "systemMessages.defaultTemplate": "默认模板", + "systemMessages.defaultTemplateTip": "覆盖为空时使用的内置回退模板。", + "systemMessages.useDefault": "使用默认值", + "toast": { "saved": "配置已保存", "saveFailed": "保存配置失败" diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index c8d21f53..cc385d94 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -22,10 +22,11 @@ import { TtsSection } from "./sections/tts-section"; import { CronSection } from "./sections/cron-section"; import { TelemetrySection } from "./sections/telemetry-section"; import { BindingsSection } from "./sections/bindings-section"; +import { SystemMessagesSection } from "./sections/system-messages-section"; export function ConfigPage() { const { t } = useTranslation("config"); - const { config, hash, loading, saving, refresh, patch } = useConfig(); + const { config, schema, hash, loading, saving, refresh, patch } = useConfig(); const isMobile = useIsMobile(); const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && !config); @@ -99,6 +100,7 @@ export function ConfigPage() { {t("tabs.quota")} {t("tabs.tools")} {t("tabs.integrations")} + {t("tabs.systemMessages")} @@ -174,6 +176,15 @@ export function ConfigPage() { saving={saving} /> + + + patch({ system_messages: v })} + saving={saving} + /> + ); diff --git a/ui/web/src/pages/config/hooks/use-config.ts b/ui/web/src/pages/config/hooks/use-config.ts index 6d210552..64d85199 100644 --- a/ui/web/src/pages/config/hooks/use-config.ts +++ b/ui/web/src/pages/config/hooks/use-config.ts @@ -10,6 +10,7 @@ import { userFriendlyError } from "@/lib/error-utils"; interface ConfigData { config: Record; + schema: Record | null; hash: string; path: string; } @@ -25,15 +26,19 @@ export function useConfig() { const { data, isPending: loading } = useQuery({ queryKey: queryKeys.config.all, queryFn: async (): Promise => { - const res = await ws.call(Methods.CONFIG_GET); + const [res, schemaRes] = await Promise.all([ + ws.call(Methods.CONFIG_GET), + ws.call<{ json: Record }>(Methods.CONFIG_SCHEMA), + ]); hashRef.current = res.hash; - return res; + return { ...res, schema: schemaRes.json ?? null }; }, staleTime: 5 * 60_000, enabled: connected, }); const config = data?.config ?? null; + const schema = data?.schema ?? null; const hash = data?.hash ?? ""; const configPath = data?.path ?? ""; @@ -90,5 +95,5 @@ export function useConfig() { [ws, invalidate], ); - return { config, hash, configPath, loading, saving, error, refresh: invalidate, applyRaw, patch }; + return { config, schema, hash, configPath, loading, saving, error, refresh: invalidate, applyRaw, patch }; } diff --git a/ui/web/src/pages/config/sections/system-messages-section-utils.test.ts b/ui/web/src/pages/config/sections/system-messages-section-utils.test.ts new file mode 100644 index 00000000..545d4694 --- /dev/null +++ b/ui/web/src/pages/config/sections/system-messages-section-utils.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + SYSTEM_MESSAGE_LOCALES, + buildSystemMessagesPatch, + localizedSystemMessageDescription, + localizedSystemMessageLabel, + normalizeSystemMessagesDraft, + systemMessageDefinitionsFromSchema, +} from "./system-messages-section-utils"; + +describe("system-messages-section-utils", () => { + const definitions = [ + { + key: "pairing.group_required", + template: "Group {{code}}", + description: "Sent to an unpaired group chat.", + labels: { en: "Group pairing required", vi: "Yêu cầu ghép nối nhóm" }, + descriptions: { en: "Sent to an unpaired group chat.", vi: "Gửi khi nhóm chưa được ghép nối." }, + variables: ["code"], + }, + { + key: "pairing.approved", + template: "Approved {{app_name}}", + description: "Sent after approval.", + variables: ["app_name"], + }, + ]; + + + it("reads localized labels and descriptions from config schema definitions", () => { + const schema = { + properties: { + system_messages: { + definitions: [definitions[0]], + }, + }, + }; + + const parsed = systemMessageDefinitionsFromSchema(schema); + const first = parsed[0]; + + expect(first).toBeDefined(); + expect(localizedSystemMessageLabel(first!, "vi-VN")).toBe("Yêu cầu ghép nối nhóm"); + expect(localizedSystemMessageDescription(first!, "vi")).toBe("Gửi khi nhóm chưa được ghép nối."); + expect(localizedSystemMessageLabel({ key: "pairing.account_required", template: "" }, "vi")).toBe("pairing.account_required"); + }); + + it("normalizes all supported locales for each known message definition", () => { + expect(SYSTEM_MESSAGE_LOCALES.map((locale) => locale.code)).toEqual(["en", "vi", "zh", "ko"]); + + const draft = normalizeSystemMessagesDraft( + { + default_locale: "vi", + messages: { + "pairing.group_required": { + vi: "Nhóm {{code}}", + }, + }, + }, + definitions, + ); + + expect(draft["pairing.group_required"]).toMatchObject({ + en: "", + vi: "Nhóm {{code}}", + zh: "", + ko: "", + }); + expect(draft["pairing.approved"]).toMatchObject({ + en: "", + vi: "", + zh: "", + ko: "", + }); + }); + + it("builds a default delivery locale into the config.patch payload", () => { + const patch = buildSystemMessagesPatch( + { + "pairing.group_required": { en: "", vi: "Nhóm {{code}}", zh: "", ko: "" }, + }, + undefined, + "vi", + ); + + expect(patch).toEqual({ + default_locale: "vi", + messages: { + "pairing.group_required": { + en: "", + vi: "Nhóm {{code}}", + zh: "", + ko: "", + }, + }, + }); + }); + + it("builds a config.patch payload and preserves cleared overrides", () => { + const patch = buildSystemMessagesPatch({ + "pairing.group_required": { en: "Group {{code}}", vi: "", zh: "", ko: "" }, + }); + + expect(patch).toEqual({ + messages: { + "pairing.group_required": { + en: "Group {{code}}", + vi: "", + zh: "", + ko: "", + }, + }, + }); + }); + + it("keeps an existing key with empty locales so config.patch can clear old overrides", () => { + const patch = buildSystemMessagesPatch( + { + "pairing.group_required": { en: "", vi: "", zh: "", ko: "" }, + }, + { + messages: { + "pairing.group_required": { en: "Old" }, + }, + }, + ); + + expect(patch).toEqual({ + messages: { + "pairing.group_required": { + en: "", + vi: "", + zh: "", + ko: "", + }, + }, + }); + }); +}); diff --git a/ui/web/src/pages/config/sections/system-messages-section-utils.ts b/ui/web/src/pages/config/sections/system-messages-section-utils.ts new file mode 100644 index 00000000..41b1413e --- /dev/null +++ b/ui/web/src/pages/config/sections/system-messages-section-utils.ts @@ -0,0 +1,114 @@ +export interface SystemMessageDefinition { + key: string; + template: string; + description?: string; + labels?: Record; + descriptions?: Record; + variables?: string[]; +} + +export interface SystemMessagesData { + default_locale?: string; + messages?: Record>; +} + +export type SystemMessagesDraft = Record>; + +export const SYSTEM_MESSAGE_LOCALES = [ + { code: "en", labelKey: "systemMessages.locale.en" }, + { code: "vi", labelKey: "systemMessages.locale.vi" }, + { code: "zh", labelKey: "systemMessages.locale.zh" }, + { code: "ko", labelKey: "systemMessages.locale.ko" }, +] as const; + +export function systemMessageDefinitionsFromSchema(schema: Record | null | undefined): SystemMessageDefinition[] { + const definitions = schema?.properties?.system_messages?.definitions; + if (!Array.isArray(definitions)) return []; + return definitions + .filter((definition) => typeof definition?.key === "string" && typeof definition?.template === "string") + .map((definition) => ({ + key: definition.key, + template: definition.template, + description: typeof definition.description === "string" ? definition.description : undefined, + labels: sanitizeLocalizedTextMap(definition.labels), + descriptions: sanitizeLocalizedTextMap(definition.descriptions), + variables: Array.isArray(definition.variables) + ? definition.variables.filter((variable: unknown): variable is string => typeof variable === "string") + : [], + })); +} + +function sanitizeLocalizedTextMap(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [key, text] of Object.entries(value as Record)) { + if (typeof text === "string" && text.trim() !== "") { + out[key.toLowerCase()] = text; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function normalizeLocale(locale: string | undefined): string { + return (locale || "en").toLowerCase().split("-")[0] || "en"; +} + +function localizedText(values: Record | undefined, locale: string | undefined): string { + if (!values) return ""; + const normalized = normalizeLocale(locale); + return values[normalized] || values.en || ""; +} + +export function localizedSystemMessageLabel(definition: SystemMessageDefinition, locale: string | undefined): string { + return localizedText(definition.labels, locale) || definition.key; +} + +export function localizedSystemMessageDescription(definition: SystemMessageDefinition, locale: string | undefined): string { + return localizedText(definition.descriptions, locale) || definition.description || ""; +} + +export function normalizeSystemMessagesDraft( + data: SystemMessagesData | undefined, + definitions: SystemMessageDefinition[], +): SystemMessagesDraft { + const messages = data?.messages ?? {}; + const out: SystemMessagesDraft = {}; + for (const definition of definitions) { + const byLocale = messages[definition.key] ?? {}; + const entry: Record = {}; + for (const locale of SYSTEM_MESSAGE_LOCALES) { + entry[locale.code] = byLocale[locale.code] ?? ""; + } + out[definition.key] = entry; + } + return out; +} + +export function buildSystemMessagesPatch( + draft: SystemMessagesDraft, + current?: SystemMessagesData, + defaultLocale?: string, +): SystemMessagesData { + const messages: Record> = {}; + for (const [key, byLocale] of Object.entries(draft)) { + const next: Record = {}; + let hasValue = false; + for (const locale of SYSTEM_MESSAGE_LOCALES) { + const value = byLocale[locale.code] ?? ""; + if (value.trim() !== "") { + hasValue = true; + } + next[locale.code] = value; + } + if (hasValue || Object.prototype.hasOwnProperty.call(current?.messages ?? {}, key)) { + messages[key] = next; + } + } + const patch: SystemMessagesData = { messages }; + if (defaultLocale !== undefined) { + patch.default_locale = defaultLocale; + } else if (current?.default_locale) { + patch.default_locale = current.default_locale; + } + return patch; +} diff --git a/ui/web/src/pages/config/sections/system-messages-section.tsx b/ui/web/src/pages/config/sections/system-messages-section.tsx new file mode 100644 index 00000000..3612034b --- /dev/null +++ b/ui/web/src/pages/config/sections/system-messages-section.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from "react"; +import { MessageSquareText, RotateCcw, Save } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { InfoLabel } from "@/components/shared/info-label"; +import { + SYSTEM_MESSAGE_LOCALES, + type SystemMessagesData, + type SystemMessagesDraft, + buildSystemMessagesPatch, + localizedSystemMessageDescription, + localizedSystemMessageLabel, + normalizeSystemMessagesDraft, + systemMessageDefinitionsFromSchema, +} from "./system-messages-section-utils"; + +interface Props { + data: SystemMessagesData | undefined; + schema: Record | null; + onSave: (value: SystemMessagesData) => Promise; + saving: boolean; +} + +export function SystemMessagesSection({ data, schema, onSave, saving }: Props) { + const { t, i18n } = useTranslation("config"); + const definitions = useMemo(() => systemMessageDefinitionsFromSchema(schema), [schema]); + const [draft, setDraft] = useState(() => normalizeSystemMessagesDraft(data, definitions)); + const [selectedKey, setSelectedKey] = useState(""); + const [selectedLocale, setSelectedLocale] = useState<(typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]>("vi"); + const [defaultLocale, setDefaultLocale] = useState<(typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]>( + (data?.default_locale as (typeof SYSTEM_MESSAGE_LOCALES)[number]["code"]) || "en", + ); + const [dirty, setDirty] = useState(false); + + useEffect(() => { + setDraft(normalizeSystemMessagesDraft(data, definitions)); + setDefaultLocale((data?.default_locale as typeof defaultLocale) || "en"); + setSelectedKey((prev) => (prev && definitions.some((definition) => definition.key === prev) ? prev : definitions[0]?.key ?? "")); + setDirty(false); + }, [data, definitions]); + + const activeKey = selectedKey || definitions[0]?.key || ""; + const selectedDefinition = definitions.find((definition) => definition.key === activeKey); + const currentValue = activeKey ? draft[activeKey]?.[selectedLocale] ?? "" : ""; + const uiLocale = i18n.resolvedLanguage || i18n.language || selectedLocale; + + const updateTemplate = (value: string) => { + if (!activeKey) return; + setDraft((prev) => ({ + ...prev, + [activeKey]: { + ...(prev[activeKey] ?? {}), + [selectedLocale]: value, + }, + })); + setDirty(true); + }; + + const restoreDefault = () => updateTemplate(""); + + if (definitions.length === 0) { + return null; + } + + return ( + + + + + {t("systemMessages.title")} + + {t("systemMessages.description")} + + +
+
+ {t("systemMessages.message")} + +
+
+ {t("systemMessages.locale")} + +
+
+ {t("systemMessages.defaultLocale")} + +
+
+ + {selectedDefinition && ( +
+ {localizedSystemMessageDescription(selectedDefinition, uiLocale) && ( +

{localizedSystemMessageDescription(selectedDefinition, uiLocale)}

+ )} + {selectedDefinition.variables && selectedDefinition.variables.length > 0 && ( +
+ {selectedDefinition.variables.map((variable) => ( + + {"{{" + variable + "}}"} + + ))} +
+ )} + +
+ {t("systemMessages.override")} +