// Package bootstrap выполняет первичную настройку инстанса (AGENT.md 10.3 шаг 10, // 13 Фаза 1): инстанс-администратор, главный сервер, роли по умолчанию. package bootstrap import ( "context" "errors" "fmt" "log/slog" "strconv" "strings" "glchat/internal/auth" "glchat/internal/crypto" "glchat/internal/permissions" "glchat/internal/store" ) // DefaultMainGuildName — название главного сервера по умолчанию. const DefaultMainGuildName = "Главный сервер" // Options — параметры первичной настройки. type Options struct { Email string Password string Username string InstanceName string MainGuild string Force bool } // Result — что было создано (используется CLI и smoke-тестом). type Result struct { UserID uint64 GuildID uint64 AdminRoleID uint64 MemberRoleID uint64 CreatedUser bool CreatedGuild bool AlreadyDone bool } // Runner выполняет первичную настройку поверх хранилища и сервиса аутентификации. type Runner struct { store *store.Store auth *auth.Service logger *slog.Logger } func New(st *store.Store, authService *auth.Service, logger *slog.Logger) *Runner { return &Runner{store: st, auth: authService, logger: logger} } // Run создаёт администратора инстанса и главный сервер; повторный запуск // идемпотентен (AGENT.md 10.3: установщик можно перезапускать). func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) { email := crypto.NormalizeEmail(opts.Email) if email == "" { return nil, errors.New("email is required") } if opts.Username == "" { // Логин по умолчанию — часть адреса до @, приведённая к допустимым символам. opts.Username = sanitizeUsername(strings.SplitN(email, "@", 2)[0]) } if err := crypto.ValidateUsername(opts.Username); err != nil { return nil, fmt.Errorf("invalid admin username %q: %w", opts.Username, err) } result := &Result{} admin, err := r.findOrCreateAdmin(ctx, opts, result) if err != nil { return nil, err } settings, err := r.store.InstanceSettings(ctx) if err != nil { return nil, fmt.Errorf("load instance settings: %w", err) } if settings.MainGuildID != 0 { guild, err := r.store.GetGuild(ctx, settings.MainGuildID) if err == nil { result.GuildID = guild.ID result.AlreadyDone = !result.CreatedUser if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil { return nil, err } return result, nil } if !errors.Is(err, store.ErrNotFound) { return nil, err } } guildName := opts.MainGuild if guildName == "" { guildName = DefaultMainGuildName } guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{ Name: guildName, OwnerID: admin.ID, IsMain: true, IsDiscoverable: false, Description: "", }) if err != nil { return nil, fmt.Errorf("create main guild: %w", err) } result.GuildID = guild.ID result.CreatedGuild = true if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { return nil, fmt.Errorf("set main guild: %w", err) } if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil { return nil, err } // Главный сервер всегда содержит общую текстовую комнату. if _, err := r.store.CreateChannel(ctx, store.CreateChannelParams{ GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, }); err != nil { return nil, fmt.Errorf("create main channel: %w", err) } return result, nil } // findOrCreateAdmin находит администратора по email или создаёт его. func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Result) (*store.User, error) { email := crypto.NormalizeEmail(opts.Email) existing, err := r.auth.UserByEmail(ctx, email) if err == nil && existing != nil { result.UserID = existing.ID if !existing.IsInstanceAdmin { if err := r.store.SetInstanceAdmin(ctx, existing.ID, true); err != nil { return nil, fmt.Errorf("grant instance admin: %w", err) } } // Пароль администратора, заданный установщиком, обновляем осознанно: // без этого повторный запуск с новым паролем не менял бы доступ. if opts.Password != "" { if err := r.auth.SetPassword(ctx, existing.ID, opts.Password); err != nil { return nil, fmt.Errorf("update admin password: %w", err) } } return r.store.GetUser(ctx, existing.ID) } if err != nil && !errors.Is(err, store.ErrNotFound) { return nil, err } if opts.Password == "" { return nil, errors.New("password is required to create the instance administrator") } user, _, _, err := r.auth.RegisterAdmin(ctx, auth.RegisterInput{ Username: opts.Username, DisplayName: opts.Username, Email: email, Password: opts.Password, Locale: "ru", UserAgent: "glchat-bootstrap", }) if err != nil { return nil, fmt.Errorf("create instance administrator: %w", err) } result.UserID = user.ID result.CreatedUser = true // Бейдж «Администратор сервера» отображается рядом с ником (AGENT.md 7.19). if err := r.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil { r.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err)) } return user, nil } // SeedResult — роли, созданные при настройке сервера. type SeedResult struct { AdminRoleID uint64 MemberRoleID uint64 } // SeedGuildDefaults создаёт роли по умолчанию, добавляет владельца в сервер и // выдаёт ему роль «Администратор» (AGENT.md 6.3). Функция идемпотентна: её // используют и установщик, и ручка создания сервера. func SeedGuildDefaults(ctx context.Context, st *store.Store, guild *store.Guild, owner *store.User) (*SeedResult, error) { result := &SeedResult{} roles, err := st.ListGuildRoles(ctx, guild.ID) if err != nil { return nil, err } var adminRole, defaultRole *store.Role for i := range roles { switch { case permissions.Permission(roles[i].Permissions).Has(permissions.Administrator): adminRole = &roles[i] case roles[i].IsDefault: defaultRole = &roles[i] } } if adminRole == nil { created, err := st.CreateRole(ctx, store.CreateRoleParams{ GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100, Mentionable: true, }) if err != nil { return nil, fmt.Errorf("create administrator role: %w", err) } adminRole = created } result.AdminRoleID = adminRole.ID if defaultRole == nil { created, err := st.CreateRole(ctx, store.CreateRoleParams{ GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions), Position: 0, IsDefault: true, Mentionable: true, }) if err != nil { return nil, fmt.Errorf("create default role: %w", err) } defaultRole = created } result.MemberRoleID = defaultRole.ID if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil { return nil, fmt.Errorf("add owner to guild: %w", err) } if err := st.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil { return nil, fmt.Errorf("assign administrator role: %w", err) } return result, nil } // ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и // выдачу роли «Администратор» (AGENT.md 6.3). func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error { seeded, err := SeedGuildDefaults(ctx, r.store, guild, owner) if err != nil { return err } result.AdminRoleID = seeded.AdminRoleID result.MemberRoleID = seeded.MemberRoleID return nil } func sanitizeUsername(value string) string { cleaned := strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.': return r default: return '_' } }, value) if len(cleaned) < 2 { cleaned = "admin" } if len(cleaned) > 32 { cleaned = cleaned[:32] } return cleaned }