mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 07:12:50 +00:00
feat: add configurable system messages (#1343)
Co-authored-by: GoClaw Operator <operator@goclaw>
This commit is contained in:
1 parent
8d28f23efb
commit
e1fcccea4f
27 files changed
+1280
-41
No files matched your search
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "설정 저장 실패"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "保存配置失败"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user