package service import ( "context" "errors" "fmt" "regexp" "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) const ( CaptainSkillMaxInstructionsBytes = 32 * 1024 CaptainSkillMaxReferences = 20 CaptainSkillMaxReferenceBytes = 64 * 1024 ) var ( ErrCaptainSkillValidation = errors.New("captain skill validation failed") ErrCaptainSkillConflict = errors.New("captain skill state conflict") referenceKeyPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) ) type CaptainSkillReferenceRequest struct { ReferenceKey string `json:"reference_key"` ContentMD string `json:"content_md"` } type CaptainSkillRequest struct { Name string `json:"name"` Description string `json:"description"` InstructionsMD string `json:"instructions_md"` Status model.CaptainSkillStatus `json:"status"` References []CaptainSkillReferenceRequest `json:"references"` ExpectedVersion uint `json:"expected_version"` } type CaptainSkillListItem struct { Skill model.CaptainSkill ReferenceCount int64 Bound bool BoundAssistantCount int64 } type CaptainSkillService struct{ repo *repository.CaptainSkillRepo } func NewCaptainSkillService(repo *repository.CaptainSkillRepo) *CaptainSkillService { return &CaptainSkillService{repo: repo} } func (s *CaptainSkillService) Create(ctx context.Context, accountID uint, req *CaptainSkillRequest) (*model.CaptainSkill, error) { if req.Status == "" { req.Status = model.CaptainSkillStatusDraft } if err := validateCaptainSkillRequest(req); err != nil { return nil, err } exists, err := s.repo.NameExists(ctx, accountID, strings.TrimSpace(req.Name), 0) if err != nil { return nil, err } if exists { return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation) } skill := captainSkillFromRequest(accountID, 0, 1, req) if err := s.repo.Create(ctx, skill); err != nil { if exists, lookupErr := s.repo.NameExists(ctx, accountID, skill.Name, 0); lookupErr == nil && exists { return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation) } return nil, err } return skill, nil } func (s *CaptainSkillService) Get(ctx context.Context, accountID, skillID uint) (*model.CaptainSkill, error) { return s.repo.Get(ctx, accountID, skillID) } func (s *CaptainSkillService) List(ctx context.Context, accountID uint, assistantID *uint) ([]CaptainSkillListItem, error) { if assistantID != nil { if err := s.repo.AssistantExists(ctx, accountID, *assistantID); err != nil { return nil, err } } rows, err := s.repo.List(ctx, accountID, assistantID) if err != nil { return nil, err } result := make([]CaptainSkillListItem, len(rows)) for i := range rows { result[i] = CaptainSkillListItem{rows[i].Skill, rows[i].ReferenceCount, rows[i].Bound, rows[i].BoundAssistantCount} } return result, nil } func (s *CaptainSkillService) Update(ctx context.Context, accountID, skillID uint, req *CaptainSkillRequest) (*model.CaptainSkill, error) { if err := validateCaptainSkillRequest(req); err != nil { return nil, err } if req.ExpectedVersion == 0 { return nil, fmt.Errorf("%w: expected_version is required", ErrCaptainSkillValidation) } current, err := s.repo.Get(ctx, accountID, skillID) if err != nil { return nil, err } if current.Version != req.ExpectedVersion { return nil, fmt.Errorf("%w: expected version %d, got %d", ErrCaptainSkillConflict, req.ExpectedVersion, current.Version) } exists, err := s.repo.NameExists(ctx, accountID, strings.TrimSpace(req.Name), skillID) if err != nil { return nil, err } if exists { return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation) } next := captainSkillFromRequest(accountID, skillID, current.Version, req) next.CreatedAt = current.CreatedAt if captainSkillsEqual(current, next) { return current, nil } next.Version++ referencesChanged := !captainSkillReferencesEqual(current, next) enforceActivationLimit := current.Status != model.CaptainSkillStatusActive && req.Status == model.CaptainSkillStatusActive if err := s.repo.Update(ctx, next, req.ExpectedVersion, referencesChanged, enforceActivationLimit); err != nil { if errors.Is(err, repository.ErrCaptainSkillVersionConflict) { return nil, fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err) } if errors.Is(err, repository.ErrCaptainAssistantSkillLimit) { return nil, fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err) } if exists, lookupErr := s.repo.NameExists(ctx, accountID, next.Name, skillID); lookupErr == nil && exists { return nil, fmt.Errorf("%w: name is already in use", ErrCaptainSkillValidation) } return nil, err } return s.repo.Get(ctx, accountID, skillID) } func (s *CaptainSkillService) Delete(ctx context.Context, accountID, skillID uint) error { err := s.repo.Delete(ctx, accountID, skillID) if errors.Is(err, repository.ErrCaptainSkillCannotDelete) { return fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err) } return err } func (s *CaptainSkillService) Bind(ctx context.Context, accountID, assistantID, skillID uint) error { err := s.repo.Bind(ctx, accountID, assistantID, skillID) if errors.Is(err, repository.ErrCaptainSkillNotActive) || errors.Is(err, repository.ErrCaptainAssistantSkillLimit) { return fmt.Errorf("%w: %v", ErrCaptainSkillConflict, err) } return err } func (s *CaptainSkillService) Unbind(ctx context.Context, accountID, assistantID, skillID uint) error { return s.repo.Unbind(ctx, accountID, assistantID, skillID) } func validateCaptainSkillRequest(req *CaptainSkillRequest) error { if req == nil { return fmt.Errorf("%w: skill is required", ErrCaptainSkillValidation) } name, description := strings.TrimSpace(req.Name), strings.TrimSpace(req.Description) if name == "" || len(name) > 255 { return fmt.Errorf("%w: name is required and must be at most 255 bytes", ErrCaptainSkillValidation) } if description == "" || len(description) > 1024 { return fmt.Errorf("%w: description is required and must be at most 1024 bytes", ErrCaptainSkillValidation) } if req.InstructionsMD == "" || len(req.InstructionsMD) > CaptainSkillMaxInstructionsBytes { return fmt.Errorf("%w: instructions_md is required and must be at most %d bytes", ErrCaptainSkillValidation, CaptainSkillMaxInstructionsBytes) } if req.Status != model.CaptainSkillStatusDraft && req.Status != model.CaptainSkillStatusActive && req.Status != model.CaptainSkillStatusArchived { return fmt.Errorf("%w: invalid status", ErrCaptainSkillValidation) } if len(req.References) > CaptainSkillMaxReferences { return fmt.Errorf("%w: at most %d references are allowed", ErrCaptainSkillValidation, CaptainSkillMaxReferences) } seen := make(map[string]struct{}, len(req.References)) for _, reference := range req.References { if len(reference.ReferenceKey) > 255 || !referenceKeyPattern.MatchString(reference.ReferenceKey) { return fmt.Errorf("%w: invalid reference_key", ErrCaptainSkillValidation) } if _, exists := seen[reference.ReferenceKey]; exists { return fmt.Errorf("%w: duplicate reference_key", ErrCaptainSkillValidation) } seen[reference.ReferenceKey] = struct{}{} if reference.ContentMD == "" || len(reference.ContentMD) > CaptainSkillMaxReferenceBytes { return fmt.Errorf("%w: reference content_md is required and must be at most %d bytes", ErrCaptainSkillValidation, CaptainSkillMaxReferenceBytes) } } return nil } func captainSkillFromRequest(accountID, skillID, version uint, req *CaptainSkillRequest) *model.CaptainSkill { skill := &model.CaptainSkill{ ID: skillID, AccountID: accountID, Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), InstructionsMD: req.InstructionsMD, Status: req.Status, Version: version, References: make([]model.CaptainSkillReference, len(req.References)), } for i, reference := range req.References { skill.References[i] = model.CaptainSkillReference{SkillID: skillID, ReferenceKey: reference.ReferenceKey, ContentMD: reference.ContentMD, Position: i} } return skill } func captainSkillsEqual(a, b *model.CaptainSkill) bool { return a.Status == b.Status && captainSkillContentEqual(a, b) } func captainSkillContentEqual(a, b *model.CaptainSkill) bool { return a.Name == b.Name && a.Description == b.Description && a.InstructionsMD == b.InstructionsMD && captainSkillReferencesEqual(a, b) } func captainSkillReferencesEqual(a, b *model.CaptainSkill) bool { if len(a.References) != len(b.References) { return false } for i := range a.References { if a.References[i].ReferenceKey != b.References[i].ReferenceKey || a.References[i].ContentMD != b.References[i].ContentMD || a.References[i].Position != b.References[i].Position { return false } } return true }