// Package systemmessages renders configurable messages that GoClaw sends // directly, outside normal LLM output. package systemmessages import ( "strings" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" ) const ( KeyPairingAccountRequired = "pairing.account_required" KeyPairingAccountSimpleRequired = "pairing.account_simple_required" KeyPairingGroupRequired = "pairing.group_required" KeyPairingGroupPrivateRequired = "pairing.group_private_required" KeyPairingApproved = "pairing.approved" ) const defaultAppName = "GoClaw" // Vars are {{name}} template variables used when rendering a system message. type Vars map[string]string // Definition describes a system message key for UIs and validation. type Definition struct { Key string `json:"key"` Template string `json:"template"` Description string `json:"description,omitempty"` Labels map[string]string `json:"labels,omitempty"` Descriptions map[string]string `json:"descriptions,omitempty"` Variables []string `json:"variables,omitempty"` } var defaults = map[string]Definition{ KeyPairingAccountRequired: { Key: KeyPairingAccountRequired, Template: `{{app_name}}: access not configured. Your {{platform}} ID: {{sender_id}} Pairing code: {{code}} Ask the bot owner to approve with: {{approve_command}}`, Description: "Sent to an unpaired direct-message user.", Labels: map[string]string{ i18n.LocaleEN: "Account pairing required", i18n.LocaleVI: "Yêu cầu ghép nối tài khoản", i18n.LocaleZH: "需要配对账号", i18n.LocaleKO: "계정 연결 필요", i18n.LocaleRU: "Требуется привязка аккаунта", }, Descriptions: map[string]string{ i18n.LocaleEN: "Sent to an unpaired direct-message user.", i18n.LocaleVI: "Gửi khi người dùng nhắn riêng chưa được ghép nối.", i18n.LocaleZH: "发送给尚未配对的私聊用户。", i18n.LocaleKO: "아직 연결되지 않은 1:1 메시지 사용자에게 보냅니다.", i18n.LocaleRU: "Отправляется пользователю личных сообщений без привязки.", }, Variables: []string{"app_name", "platform", "sender_id", "code", "approve_command"}, }, KeyPairingAccountSimpleRequired: { Key: KeyPairingAccountSimpleRequired, Template: `🔗 This account hasn't been paired yet. Pairing code: {{code}} Share this code with the bot owner to get access.`, Description: "Sent to an unpaired account when the platform does not need to show the sender ID.", Labels: map[string]string{ i18n.LocaleEN: "Simple account pairing required", i18n.LocaleVI: "Yêu cầu ghép nối tài khoản đơn giản", i18n.LocaleZH: "需要配对账号(简版)", i18n.LocaleKO: "간단 계정 연결 필요", i18n.LocaleRU: "Требуется простая привязка аккаунта", }, Descriptions: map[string]string{ i18n.LocaleEN: "Sent to an unpaired account when the platform does not need to show the sender ID.", i18n.LocaleVI: "Gửi khi tài khoản chưa được ghép nối và nền tảng không cần hiển thị ID người gửi.", i18n.LocaleZH: "当平台不需要显示发送者 ID 时,发送给尚未配对的账号。", i18n.LocaleKO: "플랫폼에서 보낸 사람 ID를 표시할 필요가 없을 때 연결되지 않은 계정에 보냅니다.", i18n.LocaleRU: "Отправляется аккаунту без привязки, когда платформе не нужно показывать ID отправителя.", }, Variables: []string{"app_name", "platform", "code", "approve_command"}, }, KeyPairingGroupRequired: { Key: KeyPairingGroupRequired, Template: `🔗 This group hasn't been paired yet. Pairing code: {{code}} Share this code with the bot owner to get access.`, Description: "Sent to an unpaired group chat.", Labels: map[string]string{ i18n.LocaleEN: "Group pairing required", i18n.LocaleVI: "Yêu cầu ghép nối nhóm", i18n.LocaleZH: "需要配对群组", i18n.LocaleKO: "그룹 연결 필요", i18n.LocaleRU: "Требуется привязка группы", }, Descriptions: map[string]string{ i18n.LocaleEN: "Sent to an unpaired group chat.", i18n.LocaleVI: "Gửi khi nhóm chat chưa được ghép nối.", i18n.LocaleZH: "发送到尚未配对的群聊。", i18n.LocaleKO: "아직 연결되지 않은 그룹 채팅에 보냅니다.", i18n.LocaleRU: "Отправляется в групповой чат без привязки.", }, Variables: []string{"app_name", "platform", "code", "approve_command"}, }, KeyPairingGroupPrivateRequired: { Key: KeyPairingGroupPrivateRequired, Template: `This channel is not authorized to use this bot. An admin can approve via CLI: {{approve_command}} Or approve via the {{app_name}} web UI (Pairing section).`, Description: "Sent to an unpaired group/private channel where the pairing code should not be exposed directly.", Labels: map[string]string{ i18n.LocaleEN: "Channel approval required", i18n.LocaleVI: "Kênh cần được cấp quyền", i18n.LocaleZH: "频道需要授权", i18n.LocaleKO: "채널 승인 필요", i18n.LocaleRU: "Требуется одобрение канала", }, Descriptions: map[string]string{ i18n.LocaleEN: "Sent to an unpaired group/private channel where the pairing code should not be exposed directly.", i18n.LocaleVI: "Gửi khi kênh hoặc nhóm chưa được cấp quyền và không nên hiển thị mã ghép nối trực tiếp.", i18n.LocaleZH: "发送到尚未配对且不应直接暴露配对码的群组或私有频道。", i18n.LocaleKO: "연결되지 않았고 연결 코드를 직접 노출하면 안 되는 그룹 또는 비공개 채널에 보냅니다.", i18n.LocaleRU: "Отправляется в группу или приватный канал без привязки, где код привязки не следует показывать напрямую.", }, Variables: []string{"app_name", "platform", "code", "approve_command"}, }, KeyPairingApproved: { Key: KeyPairingApproved, Template: "✅ {{app_name}} access approved. Send a message to start chatting.", Description: "Sent after a pairing request is approved.", Labels: map[string]string{ i18n.LocaleEN: "Pairing approved", i18n.LocaleVI: "Đã phê duyệt ghép nối", i18n.LocaleZH: "配对已批准", i18n.LocaleKO: "연결 승인됨", i18n.LocaleRU: "Привязка одобрена", }, Descriptions: map[string]string{ i18n.LocaleEN: "Sent after a pairing request is approved.", i18n.LocaleVI: "Gửi sau khi yêu cầu ghép nối được phê duyệt.", i18n.LocaleZH: "配对请求获批后发送。", i18n.LocaleKO: "연결 요청이 승인된 뒤 보냅니다.", i18n.LocaleRU: "Отправляется после одобрения запроса на привязку.", }, Variables: []string{"app_name"}, }, } // Defaults returns a defensive copy of built-in system message definitions. func Defaults() map[string]Definition { out := make(map[string]Definition, len(defaults)) for key, def := range defaults { if len(def.Labels) > 0 { def.Labels = copyStringMap(def.Labels) } if len(def.Descriptions) > 0 { def.Descriptions = copyStringMap(def.Descriptions) } if len(def.Variables) > 0 { def.Variables = append([]string(nil), def.Variables...) } out[key] = def } return out } func copyStringMap(src map[string]string) map[string]string { out := make(map[string]string, len(src)) for key, value := range src { out[key] = value } return out } // Resolver renders messages using live config snapshots. type Resolver struct { cfg *config.Config } func NewResolver(cfg *config.Config) *Resolver { return &Resolver{cfg: cfg} } // Render resolves a message template by key and locale, applies variables, and // falls back to built-in English defaults when no override exists. func (r *Resolver) Render(locale, key string, vars Vars) string { template := r.template(locale, key) return renderTemplate(template, r.withDefaults(vars)) } func (r *Resolver) template(locale, key string) string { normalized := r.locale(locale) if r != nil && r.cfg != nil { if messages := r.cfg.SystemMessagesSnapshot().Messages; len(messages) > 0 { if byLocale := messages[key]; len(byLocale) > 0 { if template := strings.TrimSpace(byLocale[normalized]); template != "" { return template } if template := strings.TrimSpace(byLocale[i18n.LocaleEN]); template != "" { return template } } } } if def, ok := defaults[key]; ok { return def.Template } return key } func (r *Resolver) locale(locale string) string { if strings.TrimSpace(locale) != "" { return i18n.Normalize(locale) } if r != nil && r.cfg != nil { if configured := strings.TrimSpace(r.cfg.SystemMessagesSnapshot().DefaultLocale); configured != "" { return i18n.Normalize(configured) } } return i18n.LocaleEN } func (r *Resolver) withDefaults(vars Vars) Vars { out := make(Vars, len(vars)+2) for key, value := range vars { out[key] = value } if strings.TrimSpace(out["app_name"]) == "" { out["app_name"] = r.appName() } if strings.TrimSpace(out["approve_command"]) == "" && strings.TrimSpace(out["code"]) != "" { out["approve_command"] = "goclaw pairing approve " + out["code"] } return out } func (r *Resolver) appName() string { return defaultAppName } // Render renders a default resolver with no custom config. It is useful for // callers that do not yet have config wiring but should share the same defaults. func Render(locale, key string, vars Vars) string { return NewResolver(nil).Render(locale, key, vars) } func renderTemplate(template string, vars Vars) string { rendered := template for key, value := range vars { rendered = strings.ReplaceAll(rendered, "{{"+key+"}}", value) } return rendered }