feat: add configurable system messages (#1343)

Co-authored-by: GoClaw Operator <operator@goclaw>
This commit is contained in:
nguyenha935andGoClaw Operator authored and GitHub committed 2026-07-04 21:57:14 +07:00
1 parent 8d28f23efb
commit e1fcccea4f
27 files changed
+1280 -41

No files matched your search

+2
View File
@@ -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
+5 -1
View File
@@ -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{
+13
View File
@@ -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 }
+6 -4
View File
@@ -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)
+6 -4
View File
@@ -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 {
+24
View File
@@ -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()
+10 -5
View File
@@ -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",
+60
View File
@@ -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)
}
}
+19 -11
View File
@@ -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 {
+6 -4
View File
@@ -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")
+6 -4
View File
@@ -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:") {
+6 -4
View File
@@ -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)
+42
View File
@@ -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"`
@@ -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)
}
}
+33
View File
@@ -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) {
@@ -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{}
}
}
+252
View File
@@ -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
}
+96
View File
@@ -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)
}
}
}
+19
View File
@@ -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"
+19
View File
@@ -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": "설정 저장 실패"
+19
View File
@@ -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"
+19
View File
@@ -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": "保存配置失败"
+12 -1
View File
@@ -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() {
<TabsTrigger value="quota">{t("tabs.quota")}</TabsTrigger>
<TabsTrigger value="tools">{t("tabs.tools")}</TabsTrigger>
<TabsTrigger value="integrations">{t("tabs.integrations")}</TabsTrigger>
<TabsTrigger value="systemMessages">{t("tabs.systemMessages")}</TabsTrigger>
</TabsList>
<TabsContent value="server" className="space-y-4">
@@ -174,6 +176,15 @@ export function ConfigPage() {
saving={saving}
/>
</TabsContent>
<TabsContent value="systemMessages" className="space-y-4">
<SystemMessagesSection
data={config.system_messages as any}
schema={schema}
onSave={(v) => patch({ system_messages: v })}
saving={saving}
/>
</TabsContent>
</Tabs>
</div>
);
+8 -3
View File
@@ -10,6 +10,7 @@ import { userFriendlyError } from "@/lib/error-utils";
interface ConfigData {
config: Record<string, unknown>;
schema: Record<string, any> | null;
hash: string;
path: string;
}
@@ -25,15 +26,19 @@ export function useConfig() {
const { data, isPending: loading } = useQuery({
queryKey: queryKeys.config.all,
queryFn: async (): Promise<ConfigData> => {
const res = await ws.call<ConfigData>(Methods.CONFIG_GET);
const [res, schemaRes] = await Promise.all([
ws.call<ConfigData>(Methods.CONFIG_GET),
ws.call<{ json: Record<string, any> }>(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 };
}
@@ -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: "",
},
},
});
});
});
@@ -0,0 +1,114 @@
export interface SystemMessageDefinition {
key: string;
template: string;
description?: string;
labels?: Record<string, string>;
descriptions?: Record<string, string>;
variables?: string[];
}
export interface SystemMessagesData {
default_locale?: string;
messages?: Record<string, Record<string, string>>;
}
export type SystemMessagesDraft = Record<string, Record<string, string>>;
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<string, any> | 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<string, string> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const out: Record<string, string> = {};
for (const [key, text] of Object.entries(value as Record<string, unknown>)) {
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<string, string> | 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<string, string> = {};
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<string, Record<string, string>> = {};
for (const [key, byLocale] of Object.entries(draft)) {
const next: Record<string, string> = {};
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;
}
@@ -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<string, any> | null;
onSave: (value: SystemMessagesData) => Promise<void>;
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<SystemMessagesDraft>(() => 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 (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquareText className="h-4 w-4" />
{t("systemMessages.title")}
</CardTitle>
<CardDescription>{t("systemMessages.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_12rem_12rem]">
<div className="grid gap-1.5">
<InfoLabel tip={t("systemMessages.messageTip")}>{t("systemMessages.message")}</InfoLabel>
<Select value={activeKey} onValueChange={setSelectedKey}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{definitions.map((definition) => (
<SelectItem
key={definition.key}
value={definition.key}
textValue={localizedSystemMessageLabel(definition, uiLocale)}
>
<span className="flex flex-col gap-0.5">
<span>{localizedSystemMessageLabel(definition, uiLocale)}</span>
<span className="font-mono text-xs text-muted-foreground">{definition.key}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("systemMessages.localeTip")}>{t("systemMessages.locale")}</InfoLabel>
<Select value={selectedLocale} onValueChange={(value) => setSelectedLocale(value as typeof selectedLocale)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SYSTEM_MESSAGE_LOCALES.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{t(locale.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("systemMessages.defaultLocaleTip")}>{t("systemMessages.defaultLocale")}</InfoLabel>
<Select
value={defaultLocale}
onValueChange={(value) => {
setDefaultLocale(value as typeof defaultLocale);
setDirty(true);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SYSTEM_MESSAGE_LOCALES.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{t(locale.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{selectedDefinition && (
<div className="space-y-4">
{localizedSystemMessageDescription(selectedDefinition, uiLocale) && (
<p className="text-sm text-muted-foreground">{localizedSystemMessageDescription(selectedDefinition, uiLocale)}</p>
)}
{selectedDefinition.variables && selectedDefinition.variables.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedDefinition.variables.map((variable) => (
<Badge key={variable} variant="outline" className="font-mono text-xs">
{"{{" + variable + "}}"}
</Badge>
))}
</div>
)}
<div className="grid gap-1.5">
<InfoLabel tip={t("systemMessages.overrideTip")}>{t("systemMessages.override")}</InfoLabel>
<Textarea
value={currentValue}
onChange={(event) => updateTemplate(event.target.value)}
className="min-h-[150px] font-mono text-base md:text-sm"
spellCheck={false}
placeholder={selectedDefinition.template}
/>
</div>
<div className="grid gap-1.5">
<InfoLabel tip={t("systemMessages.defaultTemplateTip")}>{t("systemMessages.defaultTemplate")}</InfoLabel>
<pre className="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap text-muted-foreground">
{selectedDefinition.template}
</pre>
</div>
</div>
)}
{dirty && (
<div className="flex flex-wrap justify-end gap-2 pt-2">
<Button type="button" variant="outline" size="sm" onClick={restoreDefault} className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" /> {t("systemMessages.useDefault")}
</Button>
<Button size="sm" onClick={() => onSave(buildSystemMessagesPatch(draft, data, defaultLocale))} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}