* fix(captain): restore inbox takeover and KB citations * fix(captain): harden grounded citations and smoke seed --------- Co-authored-by: Rogee <rogee@ipao.vip>
550 lines
27 KiB
Go
550 lines
27 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/app"
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/database"
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/pkg/crypto"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
func main() {
|
|
config.LoadDotEnvEnvironment()
|
|
database.SanitizePostgresEnvironment()
|
|
|
|
cmd := "serve"
|
|
if len(os.Args) > 1 {
|
|
cmd = os.Args[1]
|
|
}
|
|
|
|
var err error
|
|
switch cmd {
|
|
case "serve", "server", "run":
|
|
err = serve()
|
|
case "seed":
|
|
err = seed()
|
|
case "init":
|
|
err = initAdmin()
|
|
case "help", "-h", "--help":
|
|
printUsage()
|
|
return
|
|
default:
|
|
err = fmt.Errorf("unknown command %q", cmd)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "gochat: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Println("Usage: gochat [serve|seed|init]")
|
|
fmt.Println(" serve Start the GoChat HTTP server")
|
|
fmt.Println(" seed Create deterministic development/smoke data")
|
|
fmt.Println(" init Initialize super admin account (interactive or via flags)")
|
|
}
|
|
|
|
func serve() error {
|
|
env := os.Getenv("GOCHAT_ENV")
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
application, err := app.Bootstrap(env)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return application.Run()
|
|
}
|
|
|
|
func seed() error {
|
|
env := os.Getenv("GOCHAT_ENV")
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
cfg, err := config.LoadWithEnv(env)
|
|
if err != nil {
|
|
return fmt.Errorf("config load failed: %w", err)
|
|
}
|
|
cfg.Log.Level = "silent"
|
|
if err := applogger.Init(applogger.Config{Level: cfg.Log.Level, Format: cfg.Log.Format, Output: "stdout", ErrorOutput: "stderr"}); err != nil {
|
|
return fmt.Errorf("logger init failed: %w", err)
|
|
}
|
|
if shouldRunSeedMigrations(cfg) {
|
|
if err := database.RunMigrations(cfg.Database.MigrateDSN(), cfg.Database.GetMigrationsPath()); err != nil {
|
|
return fmt.Errorf("database migrations failed: %w", err)
|
|
}
|
|
}
|
|
db, err := app.NewDatabase(&cfg.Database, cfg.Log.Level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDB(db)
|
|
|
|
data, err := seedSmokeData(context.Background(), db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded, _ := json.MarshalIndent(data, "", " ")
|
|
fmt.Println(string(encoded))
|
|
return nil
|
|
}
|
|
|
|
func shouldRunSeedMigrations(cfg *config.Config) bool {
|
|
raw := strings.ToLower(strings.TrimSpace(os.Getenv("GOCHAT_SEED_RUN_MIGRATIONS")))
|
|
if raw == "true" || raw == "1" || raw == "yes" {
|
|
return true
|
|
}
|
|
return cfg.Database.RunMigrations
|
|
}
|
|
|
|
func closeDB(db *gorm.DB) {
|
|
if sqlDB, err := db.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
}
|
|
|
|
type smokeSeedSummary struct {
|
|
AdminID uint `json:"admin_id"`
|
|
AdminEmail string `json:"admin_email"`
|
|
AdminPassword string `json:"admin_password"`
|
|
AccountID uint `json:"account_id"`
|
|
InboxID uint `json:"inbox_id"`
|
|
VoiceInboxID uint `json:"voice_inbox_id"`
|
|
ContactID uint `json:"contact_id"`
|
|
CompanyID uint `json:"company_id"`
|
|
PortalID uint `json:"portal_id"`
|
|
ArticleID uint `json:"article_id"`
|
|
ConversationID uint `json:"conversation_id"`
|
|
ConversationDisplayID uint `json:"conversation_display_id"`
|
|
ConversationUID string `json:"conversation_uuid"`
|
|
CsatMessageID uint `json:"csat_message_id"`
|
|
SlaPolicyID uint `json:"sla_policy_id"`
|
|
CustomRoleID uint `json:"custom_role_id"`
|
|
CapacityPolicyID uint `json:"capacity_policy_id"`
|
|
CaptainAssistantID uint `json:"captain_assistant_id"`
|
|
CaptainMessageID uint `json:"captain_message_id"`
|
|
AgentBotID uint `json:"agent_bot_id"`
|
|
}
|
|
|
|
var smokeCaptainSeedMu sync.Mutex
|
|
|
|
func seedSmokeData(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) {
|
|
var summary *smokeSeedSummary
|
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var err error
|
|
summary, err = seedSmokeDataInTransaction(ctx, tx)
|
|
return err
|
|
})
|
|
return summary, err
|
|
}
|
|
|
|
func seedSmokeDataInTransaction(ctx context.Context, db *gorm.DB) (*smokeSeedSummary, error) {
|
|
adminEmail := getenvDefault("GOCHAT_SEED_ADMIN_EMAIL", "admin@gochat.local")
|
|
adminPassword := getenvDefault("GOCHAT_SEED_ADMIN_PASSWORD", "changeme")
|
|
adminName := getenvDefault("GOCHAT_SEED_ADMIN_NAME", "Super Admin")
|
|
accountName := getenvDefault("GOCHAT_SEED_ACCOUNT_NAME", "Test Account")
|
|
inboxName := getenvDefault("GOCHAT_SEED_INBOX_NAME", "Test Website Inbox")
|
|
|
|
account := &model.Account{}
|
|
smokeFeatureFlags := `{"advanced_assignment":true,"agent_bots":true,"agent_management":true,"assignment_v2":true,"audit_logs":true,"auto_resolve_conversations":true,"automations":true,"campaigns":true,"canned_responses":true,"captain_integration":true,"captain_integration_v2":true,"captain_tasks":true,"companies":true,"crm":true,"csat":true,"custom_attributes":true,"custom_roles":true,"custom_tools":true,"help_center":true,"inbox_management":true,"inbox_view":true,"integrations":true,"labels":true,"macros":true,"reports":true,"sla":true,"saml":true,"team_management":true,"voice_recorder":true,"whatsapp_campaign":true,"channel_email":true,"channel_facebook":true,"channel_website":true,"channel_instagram":true,"channel_tiktok":true,"channel_voice":true,"conversation_unread_counts":true}`
|
|
if err := db.WithContext(ctx).Where("name = ?", accountName).FirstOrCreate(account, model.Account{Name: accountName, Locale: "zh_CN", Timezone: "UTC", Active: true, Status: "active", FeatureFlags: smokeFeatureFlags}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed account: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(account).Updates(map[string]any{"active": true, "status": "active", "feature_flags": smokeFeatureFlags}).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke account flags: %w", err)
|
|
}
|
|
|
|
hashed, err := crypto.HashPassword(adminPassword)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash admin password: %w", err)
|
|
}
|
|
now := time.Now()
|
|
admin := &model.User{}
|
|
if err := firstOrCreateBy(ctx, db, admin, model.User{Email: adminEmail}, model.User{AccountID: account.ID, Name: adminName, DisplayName: adminName, Email: adminEmail, Password: hashed, PasswordDigest: hashed, Provider: "email", Role: "super_admin", Type: "SuperAdmin", Active: true, Available: true, ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`))}); err != nil {
|
|
return nil, fmt.Errorf("seed admin user: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(admin).Updates(map[string]any{"account_id": account.ID, "name": adminName, "display_name": adminName, "password": hashed, "password_digest": hashed, "provider": "email", "role": "super_admin", "type": "SuperAdmin", "active": true, "available": true, "confirmed_at": now}).Error; err != nil {
|
|
return nil, fmt.Errorf("update admin user: %w", err)
|
|
}
|
|
|
|
if err := db.WithContext(ctx).Where("user_id = ? AND account_id = ?", admin.ID, account.ID).FirstOrCreate(&model.AccountUser{}, model.AccountUser{UserID: admin.ID, AccountID: account.ID, Role: "administrator", Availability: "online", AutoOffline: true}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed account user: %w", err)
|
|
}
|
|
|
|
inbox := &model.Inbox{}
|
|
channelConfig := `{"website_url":"http://localhost:3036","website_token":"gochat-smoke-widget-token","widget_color":"#1f93ff"}`
|
|
csatConfig := `{"display_type":"emoji","message":"Rate this chat"}`
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, inboxName).FirstOrCreate(inbox, model.Inbox{AccountID: account.ID, Name: inboxName, ChannelType: "web_widget", Enabled: true, EnableAutoAssignment: true, GreetingEnabled: true, GreetingMessage: "Hello from GoChat", EnableEmailCollect: true, CsatSurveyEnabled: true, CsatConfig: csatConfig, Timezone: "UTC", AllowMessagesAfterResolved: true, SenderNameType: "friendly_name", ChannelConfig: channelConfig}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed inbox: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(inbox).Updates(map[string]any{"enabled": true, "csat_survey_enabled": true, "csat_config": csatConfig, "channel_config": channelConfig}).Error; err != nil {
|
|
return nil, fmt.Errorf("update inbox smoke settings: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Where("inbox_id = ? AND user_id = ?", inbox.ID, admin.ID).FirstOrCreate(&model.InboxMember{}, model.InboxMember{InboxID: inbox.ID, UserID: admin.ID, Role: "administrator"}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed inbox member: %w", err)
|
|
}
|
|
voiceInbox := &model.Inbox{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Voice Inbox").FirstOrCreate(voiceInbox, model.Inbox{AccountID: account.ID, Name: "Smoke Voice Inbox", ChannelType: "twilio_sms", ChannelID: 999999, Enabled: true, ChannelConfig: `{"voice_enabled":true,"inbound_calls_enabled":true}`}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed voice inbox: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(voiceInbox).Update("channel_config", `{"voice_enabled":true,"inbound_calls_enabled":true}`).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke voice inbox: %w", err)
|
|
}
|
|
|
|
company := &model.Company{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Company").FirstOrCreate(company, model.Company{AccountID: account.ID, Name: "Smoke Company", Domain: "gochat.local", WebsiteURL: "https://gochat.local", CustomAttributes: datatypes.JSON([]byte(`{"tier":"enterprise"}`))}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed company: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(company).Updates(map[string]any{"domain": "gochat.local", "website_url": "https://gochat.local", "custom_attributes": datatypes.JSON([]byte(`{"tier":"enterprise"}`))}).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke company: %w", err)
|
|
}
|
|
|
|
portalSlug := fmt.Sprintf("gochat-smoke-portal-%d", account.ID)
|
|
portal := &model.Portal{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND slug = ?", account.ID, portalSlug).FirstOrCreate(portal, model.Portal{AccountID: account.ID, Name: "Smoke Help Center", Slug: portalSlug, Description: "B12 frontend smoke help center", HeaderText: "How can we help?", PageTitle: "Smoke Help Center", Color: "#1f93ff", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en"],"default_locale":"en"}`)}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed portal: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(portal).Updates(map[string]any{"name": "Smoke Help Center", "description": "B12 frontend smoke help center", "locale": "en"}).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke portal: %w", err)
|
|
}
|
|
|
|
category := &model.Category{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND portal_id = ? AND slug = ?", account.ID, portal.ID, "smoke-guides").FirstOrCreate(category, model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Smoke Guides", Slug: "smoke-guides", Description: "B12 frontend smoke guides", Locale: "en", Position: 1, CustomAttributes: json.RawMessage(`{}`)}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed category: %w", err)
|
|
}
|
|
|
|
articleSlug := fmt.Sprintf("gochat-smoke-onboarding-%d", account.ID)
|
|
article := &model.Article{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND slug = ?", account.ID, articleSlug).FirstOrCreate(article, model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, AuthorID: &admin.ID, Title: "Smoke Onboarding Guide", Slug: articleSlug, Description: "B12 frontend smoke article", Content: "Use this onboarding guide to verify Woochat search direct-connect behavior.", Status: "published", Position: 1, Locale: "en", Meta: json.RawMessage(`{}`), CustomAttributes: json.RawMessage(`{}`)}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed article: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(article).Updates(map[string]any{"portal_id": portal.ID, "category_id": category.ID, "author_id": admin.ID, "title": "Smoke Onboarding Guide", "description": "B12 frontend smoke article", "content": "Use this onboarding guide to verify Woochat search direct-connect behavior.", "status": "published", "locale": "en"}).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke article: %w", err)
|
|
}
|
|
|
|
contact := &model.Contact{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND email = ?", account.ID, "customer@gochat.local").FirstOrCreate(contact, model.Contact{AccountID: account.ID, CompanyID: &company.ID, Name: "Smoke Customer", Email: "customer@gochat.local", Identifier: "gochat-smoke-customer", ContactType: "lead", AdditionalAttributes: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{"plan":"enterprise"}`))}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed contact: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(contact).Updates(map[string]any{"company_id": company.ID}).Error; err != nil {
|
|
return nil, fmt.Errorf("update contact company: %w", err)
|
|
}
|
|
contactInbox := &model.ContactInbox{}
|
|
if err := db.WithContext(ctx).Where("contact_id = ? AND inbox_id = ?", contact.ID, inbox.ID).FirstOrCreate(contactInbox, model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "gochat-smoke-source", PubsubToken: "gochat-smoke-contact-pubsub"}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed contact inbox: %w", err)
|
|
}
|
|
|
|
displayID := uint(1)
|
|
lastActivity := time.Now().Unix()
|
|
conversation := &model.Conversation{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ? AND contact_id = ?", account.ID, inbox.ID, contact.ID).FirstOrCreate(conversation, model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, DisplayID: &displayID, Status: "open", Priority: "medium", ChannelType: "web_widget", Channel: "web_widget", Labels: "vip", LastActivityAt: &lastActivity}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed conversation: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Model(conversation).Updates(map[string]any{"contact_inbox_id": contactInbox.ID, "display_id": displayID, "status": "open", "priority": "medium", "channel_type": "web_widget", "channel": "web_widget", "labels": "vip", "last_activity_at": lastActivity}).Error; err != nil {
|
|
return nil, fmt.Errorf("update smoke conversation: %w", err)
|
|
}
|
|
if err := repository.UpdateConversationAssignee(ctx, db, account.ID, conversation.ID, admin.ID, nil); err != nil {
|
|
return nil, fmt.Errorf("assign smoke conversation: %w", err)
|
|
}
|
|
conversation.ContactInboxID = &contactInbox.ID
|
|
conversation.DisplayID = &displayID
|
|
conversation.AssigneeID = &admin.ID
|
|
if _, err := seedMessage(ctx, db, conversation, inbox.ID, contact.ID, "incoming", "text", "Hello, I need help with my order."); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := seedMessage(ctx, db, conversation, inbox.ID, admin.ID, "outgoing", "text", "I can help with that."); err != nil {
|
|
return nil, err
|
|
}
|
|
csatMessage, err := seedMessage(ctx, db, conversation, inbox.ID, admin.ID, "template", "input_csat", "Rate this chat")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sla := &model.SlaPolicy{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke SLA").FirstOrCreate(sla, model.SlaPolicy{AccountID: account.ID, Name: "Smoke SLA", Description: "B12 frontend smoke SLA", FirstResponseTimeThreshold: 300, NextResponseTimeThreshold: 600, ResolutionTimeThreshold: 3600}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed sla policy: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Where("sla_policy_id = ? AND inbox_id = ?", sla.ID, inbox.ID).FirstOrCreate(&model.SlaPolicyInbox{}, model.SlaPolicyInbox{SlaPolicyID: sla.ID, InboxID: inbox.ID, AccountID: account.ID}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed sla policy inbox: %w", err)
|
|
}
|
|
|
|
customRole := &model.CustomRole{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Custom Role").FirstOrCreate(customRole, model.CustomRole{AccountID: account.ID, Name: "Smoke Custom Role", Description: "B12 smoke role", Permissions: `["conversation_manage","contact_manage","report_manage"]`}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed custom role: %w", err)
|
|
}
|
|
capacity := &model.AgentCapacityPolicy{}
|
|
if err := db.WithContext(ctx).Where("account_id = ? AND name = ?", account.ID, "Smoke Capacity").FirstOrCreate(capacity, model.AgentCapacityPolicy{AccountID: account.ID, Name: "Smoke Capacity", Description: "B12 smoke capacity", AssignmentLogic: "round_robin", ExclusionRules: json.RawMessage(`{}`)}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed capacity policy: %w", err)
|
|
}
|
|
if err := db.WithContext(ctx).Where("agent_capacity_policy_id = ? AND inbox_id = ?", capacity.ID, inbox.ID).FirstOrCreate(&model.InboxCapacityLimit{}, model.InboxCapacityLimit{AgentCapacityPolicyID: capacity.ID, InboxID: inbox.ID, ConversationLimit: 10}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed capacity limit: %w", err)
|
|
}
|
|
|
|
assistant, err := seedSmokeCaptain(ctx, db, account.ID, inbox.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
captainMessage := &model.Message{}
|
|
if err := db.WithContext(ctx).Where("conversation_id = ? AND content = ?", conversation.ID, "Smoke Captain answer").FirstOrCreate(captainMessage, model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &assistant.ID, SenderType: "Captain::Assistant", MessageType: "outgoing", ContentType: "text", Content: "Smoke Captain answer", Status: "sent"}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed captain message: %w", err)
|
|
}
|
|
agentBot := &model.AgentBot{}
|
|
if err := db.WithContext(ctx).Where("name = ? AND account_id = ?", "Smoke Agent Bot", account.ID).FirstOrCreate(agentBot, model.AgentBot{AccountID: &account.ID, Name: "Smoke Agent Bot", Description: "B12 assignment smoke bot", BotType: "webhook", AccessToken: fmt.Sprintf("smoke-agent-bot-%d", account.ID), Secret: fmt.Sprintf("smoke-agent-bot-secret-%d", account.ID)}).Error; err != nil {
|
|
return nil, fmt.Errorf("seed agent bot: %w", err)
|
|
}
|
|
|
|
conversationDisplayID := uint(0)
|
|
if conversation.DisplayID != nil {
|
|
conversationDisplayID = *conversation.DisplayID
|
|
}
|
|
return &smokeSeedSummary{AdminID: admin.ID, AdminEmail: adminEmail, AdminPassword: adminPassword, AccountID: account.ID, InboxID: inbox.ID, VoiceInboxID: voiceInbox.ID, ContactID: contact.ID, CompanyID: company.ID, PortalID: portal.ID, ArticleID: article.ID, ConversationID: conversation.ID, ConversationDisplayID: conversationDisplayID, ConversationUID: conversation.UUID, CsatMessageID: csatMessage.ID, SlaPolicyID: sla.ID, CustomRoleID: customRole.ID, CapacityPolicyID: capacity.ID, CaptainAssistantID: assistant.ID, CaptainMessageID: captainMessage.ID, AgentBotID: agentBot.ID}, nil
|
|
}
|
|
|
|
func seedSmokeCaptain(ctx context.Context, db *gorm.DB, accountID, inboxID uint) (*model.CaptainAssistant, error) {
|
|
// ponytail: seed is an operator-only path; this lock keeps SQLite and
|
|
// concurrent in-process runs deterministic, while PostgreSQL also locks the account row.
|
|
smokeCaptainSeedMu.Lock()
|
|
defer smokeCaptainSeedMu.Unlock()
|
|
|
|
assistant := &model.CaptainAssistant{}
|
|
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var account model.Account
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&account, accountID).Error; err != nil {
|
|
return fmt.Errorf("seed captain account: %w", err)
|
|
}
|
|
if err := tx.Where("account_id = ? AND name = ?", accountID, "Smoke Captain").Attrs(model.CaptainAssistant{AccountID: accountID, Name: "Smoke Captain", Description: "B12 smoke assistant", Status: model.AssistantStatusActive, Config: json.RawMessage(`{"model":"gpt-4o","feature_citation":true}`)}).FirstOrCreate(assistant).Error; err != nil {
|
|
return fmt.Errorf("seed captain assistant: %w", err)
|
|
}
|
|
|
|
binding := &model.CaptainInbox{AccountID: accountID, AssistantID: assistant.ID, InboxID: inboxID}
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "inbox_id"}},
|
|
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}},
|
|
DoNothing: true,
|
|
}).Create(binding).Error; err != nil {
|
|
return fmt.Errorf("seed captain inbox: %w", err)
|
|
}
|
|
var persisted model.CaptainInbox
|
|
if err := tx.Where("inbox_id = ?", inboxID).First(&persisted).Error; err != nil {
|
|
return fmt.Errorf("seed captain inbox: %w", err)
|
|
}
|
|
if persisted.AccountID != accountID || persisted.AssistantID != assistant.ID {
|
|
return fmt.Errorf("seed captain inbox: inbox belongs to assistant %d in account %d, expected assistant %d in account %d", persisted.AssistantID, persisted.AccountID, assistant.ID, accountID)
|
|
}
|
|
|
|
var config map[string]any
|
|
if err := json.Unmarshal(assistant.Config, &config); err != nil {
|
|
return fmt.Errorf("seed captain assistant config: %w", err)
|
|
}
|
|
if config == nil {
|
|
config = make(map[string]any)
|
|
}
|
|
if citations, _ := config["feature_citation"].(bool); !citations {
|
|
config["feature_citation"] = true
|
|
configJSON, err := json.Marshal(config)
|
|
if err != nil {
|
|
return fmt.Errorf("seed captain assistant config: %w", err)
|
|
}
|
|
if err := tx.Model(assistant).Update("config", configJSON).Error; err != nil {
|
|
return fmt.Errorf("seed captain assistant config: %w", err)
|
|
}
|
|
assistant.Config = configJSON
|
|
}
|
|
return nil
|
|
})
|
|
return assistant, err
|
|
}
|
|
|
|
func seedMessage(ctx context.Context, db *gorm.DB, conversation *model.Conversation, inboxID, senderID uint, messageType, contentType, content string) (*model.Message, error) {
|
|
message := &model.Message{}
|
|
var count int64
|
|
if err := db.WithContext(ctx).Model(&model.Message{}).Where("conversation_id = ? AND content = ?", conversation.ID, content).Count(&count).Error; err != nil {
|
|
return nil, fmt.Errorf("check message: %w", err)
|
|
}
|
|
if count > 0 {
|
|
if err := db.WithContext(ctx).Where("conversation_id = ? AND content = ?", conversation.ID, content).First(message).Error; err != nil {
|
|
return nil, fmt.Errorf("load existing message: %w", err)
|
|
}
|
|
return message, nil
|
|
}
|
|
senderType := "contact"
|
|
if messageType == "outgoing" || messageType == "template" {
|
|
senderType = "user"
|
|
}
|
|
message = &model.Message{AccountID: conversation.AccountID, InboxID: inboxID, ConversationID: conversation.ID, SenderID: &senderID, SenderType: senderType, MessageType: messageType, ContentType: contentType, Content: content, Status: "sent", ContentAttributes: datatypes.JSON([]byte(`{"display_type":"emoji"}`))}
|
|
if err := db.WithContext(ctx).Create(message).Error; err != nil {
|
|
return nil, fmt.Errorf("seed message: %w", err)
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func firstOrCreateBy[T any](ctx context.Context, db *gorm.DB, dest *T, query T, attrs T) error {
|
|
result := db.WithContext(ctx).Where(query).First(dest)
|
|
if result.Error == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
|
return result.Error
|
|
}
|
|
record := attrs
|
|
if err := db.WithContext(ctx).Create(&record).Error; err != nil {
|
|
return err
|
|
}
|
|
*dest = record
|
|
return nil
|
|
}
|
|
|
|
func getenvDefault(key, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// --- init command: initialize super admin account ---
|
|
|
|
func initAdmin() error {
|
|
env := os.Getenv("GOCHAT_ENV")
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
cfg, err := config.LoadWithEnv(env)
|
|
if err != nil {
|
|
return fmt.Errorf("config load failed: %w", err)
|
|
}
|
|
if shouldRunSeedMigrations(cfg) {
|
|
if err := database.RunMigrations(cfg.Database.MigrateDSN(), cfg.Database.GetMigrationsPath()); err != nil {
|
|
return fmt.Errorf("database migrations failed: %w", err)
|
|
}
|
|
}
|
|
db, err := app.NewDatabase(&cfg.Database, cfg.Log.Level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDB(db)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Check if a super admin already exists.
|
|
var count int64
|
|
if err := db.WithContext(ctx).Model(&model.User{}).Where("role = ? OR type = ?", "super_admin", "SuperAdmin").Count(&count).Error; err != nil {
|
|
return fmt.Errorf("check existing admin: %w", err)
|
|
}
|
|
if count > 0 {
|
|
fmt.Println("Super admin account already exists. Skipping initialization.")
|
|
return nil
|
|
}
|
|
|
|
// Gather admin details from flags or interactive prompt.
|
|
email, password, name, err := readAdminCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hashed, err := crypto.HashPassword(password)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
now := time.Now()
|
|
|
|
// Create default account.
|
|
account := &model.Account{}
|
|
if err := db.WithContext(ctx).Where("name = ?", "Default Account").FirstOrCreate(account, model.Account{Name: "Default Account", Locale: "zh_CN", Timezone: "UTC", Active: true, Status: "active"}).Error; err != nil {
|
|
return fmt.Errorf("create default account: %w", err)
|
|
}
|
|
|
|
// Create super admin user.
|
|
admin := &model.User{}
|
|
if err := db.WithContext(ctx).Where("email = ?", email).FirstOrCreate(admin, model.User{
|
|
AccountID: account.ID, Name: name, DisplayName: name, Email: email,
|
|
Password: hashed, PasswordDigest: hashed, Provider: "email",
|
|
Role: "super_admin", Type: "SuperAdmin", Active: true, Available: true,
|
|
ConfirmedAt: &now, UISettings: datatypes.JSON([]byte(`{}`)), CustomAttributes: datatypes.JSON([]byte(`{}`)),
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("create admin user: %w", err)
|
|
}
|
|
|
|
// Link admin to account.
|
|
if err := db.WithContext(ctx).Where("user_id = ? AND account_id = ?", admin.ID, account.ID).FirstOrCreate(&model.AccountUser{}, model.AccountUser{
|
|
UserID: admin.ID, AccountID: account.ID, Role: "administrator", Availability: "online", AutoOffline: true,
|
|
}).Error; err != nil {
|
|
return fmt.Errorf("create account user: %w", err)
|
|
}
|
|
|
|
fmt.Printf("✓ Super admin created successfully\n")
|
|
fmt.Printf(" Email: %s\n", email)
|
|
fmt.Printf(" Account: Default Account (ID: %d)\n", account.ID)
|
|
fmt.Printf(" User ID: %d\n", admin.ID)
|
|
return nil
|
|
}
|
|
|
|
// readAdminCredentials collects email, password, and display name from CLI flags
|
|
// or interactive prompt.
|
|
func readAdminCredentials() (email, password, name string, err error) {
|
|
args := os.Args[2:]
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--email":
|
|
if i+1 < len(args) {
|
|
email = args[i+1]
|
|
i++
|
|
}
|
|
case "--password":
|
|
if i+1 < len(args) {
|
|
password = args[i+1]
|
|
i++
|
|
}
|
|
case "--name":
|
|
if i+1 < len(args) {
|
|
name = args[i+1]
|
|
i++
|
|
}
|
|
case "-h", "--help":
|
|
fmt.Println("Usage: gochat init [--email <email>] [--password <password>] [--name <name>]")
|
|
os.Exit(0)
|
|
}
|
|
}
|
|
|
|
reader := bufio.NewReader(os.Stdin)
|
|
if email == "" {
|
|
fmt.Print("Admin email: ")
|
|
email, _ = reader.ReadString('\n')
|
|
email = strings.TrimSpace(email)
|
|
}
|
|
if email == "" {
|
|
return "", "", "", fmt.Errorf("email is required")
|
|
}
|
|
if password == "" {
|
|
fmt.Print("Admin password: ")
|
|
password, _ = reader.ReadString('\n')
|
|
password = strings.TrimSpace(password)
|
|
}
|
|
if password == "" {
|
|
return "", "", "", fmt.Errorf("password is required")
|
|
}
|
|
if name == "" {
|
|
fmt.Print("Admin display name [Super Admin]: ")
|
|
name, _ = reader.ReadString('\n')
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
name = "Super Admin"
|
|
}
|
|
}
|
|
return email, password, name, nil
|
|
}
|