diff --git a/cmd/server/main.go b/cmd/server/main.go index acdd3beb3..70f5ef609 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -107,12 +107,14 @@ func main() { chatRepo := repository.NewChatRepository(database.DB) noteRepo := repository.NewNoteRepository(database.DB) speakerMappingRepo := repository.NewSpeakerMappingRepository(database.DB) + openClawProfileRepo := repository.NewOpenClawProfileRepository(database.DB) refreshTokenRepo := repository.NewRefreshTokenRepository(database.DB) // Initialize services logger.Startup("service", "Initializing services") userService := service.NewUserService(userRepo, authService) fileService := service.NewFileService() + openClawService := service.NewOpenClawService() // Initialize unified transcription processor logger.Startup("transcription", "Initializing transcription service") @@ -158,7 +160,9 @@ func main() { chatRepo, noteRepo, speakerMappingRepo, + openClawProfileRepo, refreshTokenRepo, + openClawService, taskQueue, unifiedProcessor, quickTranscriptionService, diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 1b94beef4..e6f539a40 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -46,7 +46,9 @@ type Handler struct { chatRepo repository.ChatRepository noteRepo repository.NoteRepository speakerMappingRepo repository.SpeakerMappingRepository + openClawProfileRepo repository.OpenClawProfileRepository refreshTokenRepo repository.RefreshTokenRepository + openClawService *service.OpenClawService taskQueue *queue.TaskQueue unifiedProcessor *transcription.UnifiedJobProcessor quickTranscription *transcription.QuickTranscriptionService @@ -69,7 +71,9 @@ func NewHandler( chatRepo repository.ChatRepository, noteRepo repository.NoteRepository, speakerMappingRepo repository.SpeakerMappingRepository, + openClawProfileRepo repository.OpenClawProfileRepository, refreshTokenRepo repository.RefreshTokenRepository, + openClawService *service.OpenClawService, taskQueue *queue.TaskQueue, unifiedProcessor *transcription.UnifiedJobProcessor, quickTranscription *transcription.QuickTranscriptionService, @@ -90,7 +94,9 @@ func NewHandler( chatRepo: chatRepo, noteRepo: noteRepo, speakerMappingRepo: speakerMappingRepo, + openClawProfileRepo: openClawProfileRepo, refreshTokenRepo: refreshTokenRepo, + openClawService: openClawService, taskQueue: taskQueue, unifiedProcessor: unifiedProcessor, quickTranscription: quickTranscription, @@ -1002,6 +1008,8 @@ func (h *Handler) StartTranscription(c *gin.Context) { job.Transcript = nil job.Summary = nil job.ErrorMessage = nil + job.OpenClawSentAt = nil + job.OpenClawProfileName = nil // Save updated job if err := h.jobRepo.Update(c.Request.Context(), job); err != nil { diff --git a/internal/api/openclaw_handlers.go b/internal/api/openclaw_handlers.go new file mode 100644 index 000000000..f8ae97b7f --- /dev/null +++ b/internal/api/openclaw_handlers.go @@ -0,0 +1,347 @@ +package api + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "strings" + "time" + + "scriberr/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// OpenClawProfileRequest defines create/update payloads for OpenClaw profiles. +type OpenClawProfileRequest struct { + Name string `json:"name"` + IP string `json:"ip"` + SSHKey string `json:"ssh_key"` + HookKey string `json:"hook_key"` + HookName string `json:"hook_name"` + Message string `json:"message"` +} + +type openClawProfileResponse struct { + ID string `json:"id"` + Name string `json:"name"` + IP string `json:"ip"` + HookName string `json:"hook_name"` + Message string `json:"message"` + HasSSHKey bool `json:"has_ssh_key"` + HasHookKey bool `json:"has_hook_key"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SendToOpenClawRequest defines the payload for sending a transcription. +type SendToOpenClawRequest struct { + ProfileID string `json:"profile_id" binding:"required"` +} + +// ListOpenClawProfiles lists all saved OpenClaw profiles. +func (h *Handler) ListOpenClawProfiles(c *gin.Context) { + profiles, _, err := h.openClawProfileRepo.List(c.Request.Context(), 0, 1000) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch OpenClaw profiles"}) + return + } + + response := make([]openClawProfileResponse, 0, len(profiles)) + for _, profile := range profiles { + response = append(response, toOpenClawProfileResponse(profile)) + } + c.JSON(http.StatusOK, response) +} + +// CreateOpenClawProfile creates a new OpenClaw profile. +func (h *Handler) CreateOpenClawProfile(c *gin.Context) { + req, ok := bindOpenClawProfileRequest(c) + if !ok { + return + } + + profile := models.OpenClawProfile{ + Name: strings.TrimSpace(req.Name), + IP: strings.TrimSpace(req.IP), + SSHKey: strings.TrimSpace(req.SSHKey), + HookKey: strings.TrimSpace(req.HookKey), + HookName: strings.TrimSpace(req.HookName), + Message: strings.TrimSpace(req.Message), + } + if err := validateOpenClawProfile(profile, true); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.openClawProfileRepo.Create(c.Request.Context(), &profile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create OpenClaw profile"}) + return + } + + c.JSON(http.StatusOK, toOpenClawProfileResponse(profile)) +} + +// GetOpenClawProfile fetches one OpenClaw profile by ID. +func (h *Handler) GetOpenClawProfile(c *gin.Context) { + profile, err := h.openClawProfileRepo.FindByID(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "OpenClaw profile not found"}) + return + } + c.JSON(http.StatusOK, toOpenClawProfileResponse(*profile)) +} + +// UpdateOpenClawProfile updates an existing OpenClaw profile. +func (h *Handler) UpdateOpenClawProfile(c *gin.Context) { + profile, err := h.openClawProfileRepo.FindByID(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "OpenClaw profile not found"}) + return + } + + req, ok := bindOpenClawProfileRequest(c) + if !ok { + return + } + + profile.Name = strings.TrimSpace(req.Name) + profile.IP = strings.TrimSpace(req.IP) + profile.HookName = strings.TrimSpace(req.HookName) + profile.Message = strings.TrimSpace(req.Message) + if sshKey := strings.TrimSpace(req.SSHKey); sshKey != "" { + profile.SSHKey = sshKey + } + if hookKey := strings.TrimSpace(req.HookKey); hookKey != "" { + profile.HookKey = hookKey + } + + if err := validateOpenClawProfile(*profile, false); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.openClawProfileRepo.Update(c.Request.Context(), profile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update OpenClaw profile"}) + return + } + + c.JSON(http.StatusOK, toOpenClawProfileResponse(*profile)) +} + +// DeleteOpenClawProfile deletes a saved OpenClaw profile. +func (h *Handler) DeleteOpenClawProfile(c *gin.Context) { + id := c.Param("id") + if _, err := h.openClawProfileRepo.FindByID(c.Request.Context(), id); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "OpenClaw profile not found"}) + return + } + + if err := h.openClawProfileRepo.Delete(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete OpenClaw profile"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "OpenClaw profile deleted"}) +} + +// SendTranscriptionToOpenClaw uploads SRT then triggers the OpenClaw hook. +func (h *Handler) SendTranscriptionToOpenClaw(c *gin.Context) { + if h.openClawService == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OpenClaw service is not initialized"}) + return + } + if authType, exists := c.Get("auth_type"); !exists || authType != "jwt" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "JWT authentication required"}) + return + } + + var req SendToOpenClawRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + job, err := h.jobRepo.FindByID(c.Request.Context(), c.Param("id")) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "Transcription not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load transcription"}) + return + } + if job.Status != models.StatusCompleted { + c.JSON(http.StatusBadRequest, gin.H{"error": "Transcription is not completed yet"}) + return + } + if job.Transcript == nil || strings.TrimSpace(*job.Transcript) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Transcript is empty"}) + return + } + + profile, err := h.openClawProfileRepo.FindByID(c.Request.Context(), req.ProfileID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "OpenClaw profile not found"}) + return + } + + speakerMap := map[string]string{} + mappings, err := h.speakerMappingRepo.ListByJob(c.Request.Context(), job.ID) + if err == nil { + for _, mapping := range mappings { + speakerMap[mapping.OriginalSpeaker] = mapping.CustomName + } + } + + srt, err := buildSRTFromRawTranscript(*job.Transcript, speakerMap) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build SRT: " + err.Error()}) + return + } + + title := "Untitled Recording" + if job.Title != nil && strings.TrimSpace(*job.Title) != "" { + title = strings.TrimSpace(*job.Title) + } + + result, err := h.openClawService.SendSRT(c.Request.Context(), profile, srt, title, job.ID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to send to OpenClaw: " + err.Error()}) + return + } + + now := time.Now() + job.OpenClawSentAt = &now + job.OpenClawProfileName = &profile.Name + if err := h.jobRepo.Update(c.Request.Context(), job); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist OpenClaw send status"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Sent to OpenClaw", + "profile_name": result.ProfileName, + "remote_path": result.RemotePath, + "hook_output": result.HookOutput, + }) +} + +type transcriptSegmentForSRT struct { + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Speaker string `json:"speaker,omitempty"` +} + +type transcriptPayloadForSRT struct { + Text string `json:"text"` + Segments []transcriptSegmentForSRT `json:"segments"` +} + +func bindOpenClawProfileRequest(c *gin.Context) (*OpenClawProfileRequest, bool) { + var req OpenClawProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return nil, false + } + return &req, true +} + +func validateOpenClawProfile(profile models.OpenClawProfile, requireSecrets bool) error { + if strings.TrimSpace(profile.Name) == "" || + strings.TrimSpace(profile.IP) == "" || + strings.TrimSpace(profile.HookName) == "" || + strings.TrimSpace(profile.Message) == "" { + return fmt.Errorf("name, host, hook name, and message are required") + } + if requireSecrets && (strings.TrimSpace(profile.SSHKey) == "" || strings.TrimSpace(profile.HookKey) == "") { + return fmt.Errorf("ssh key and hook key are required") + } + if !requireSecrets && (strings.TrimSpace(profile.SSHKey) == "" || strings.TrimSpace(profile.HookKey) == "") { + return fmt.Errorf("stored ssh key and hook key are required") + } + return nil +} + +func toOpenClawProfileResponse(profile models.OpenClawProfile) openClawProfileResponse { + return openClawProfileResponse{ + ID: profile.ID, + Name: profile.Name, + IP: profile.IP, + HookName: profile.HookName, + Message: profile.Message, + HasSSHKey: strings.TrimSpace(profile.SSHKey) != "", + HasHookKey: strings.TrimSpace(profile.HookKey) != "", + CreatedAt: profile.CreatedAt, + UpdatedAt: profile.UpdatedAt, + } +} + +func buildSRTFromRawTranscript(raw string, speakerMap map[string]string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", fmt.Errorf("empty transcript") + } + + var payload transcriptPayloadForSRT + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + var plain string + if errString := json.Unmarshal([]byte(trimmed), &plain); errString == nil && strings.TrimSpace(plain) != "" { + return buildSingleLineSRT(plain), nil + } + return "", fmt.Errorf("invalid transcript format") + } + + if len(payload.Segments) > 0 { + var out strings.Builder + index := 1 + for _, segment := range payload.Segments { + text := strings.TrimSpace(segment.Text) + if text == "" { + continue + } + + start := math.Max(0, segment.Start) + end := math.Max(start+0.001, segment.End) + if strings.TrimSpace(segment.Speaker) != "" { + displaySpeaker := segment.Speaker + if custom, ok := speakerMap[segment.Speaker]; ok && strings.TrimSpace(custom) != "" { + displaySpeaker = custom + } + text = fmt.Sprintf("%s: %s", displaySpeaker, text) + } + + out.WriteString(fmt.Sprintf("%d\n%s --> %s\n%s\n\n", index, formatSRTTime(start), formatSRTTime(end), text)) + index++ + } + + if out.Len() == 0 { + return "", fmt.Errorf("no usable transcript segments") + } + return out.String(), nil + } + + if strings.TrimSpace(payload.Text) != "" { + return buildSingleLineSRT(payload.Text), nil + } + + return "", fmt.Errorf("no transcript segments found") +} + +func buildSingleLineSRT(text string) string { + return fmt.Sprintf("1\n00:00:00,000 --> 00:00:05,000\n%s\n\n", strings.TrimSpace(text)) +} + +func formatSRTTime(seconds float64) string { + if seconds < 0 { + seconds = 0 + } + hours := int(seconds / 3600) + minutes := int(math.Mod(seconds, 3600) / 60) + secs := int(math.Mod(seconds, 60)) + milliseconds := int(math.Mod(seconds, 1.0) * 1000) + + return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, minutes, secs, milliseconds) +} diff --git a/internal/api/router.go b/internal/api/router.go index b5bec3224..72bcf4882 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -136,6 +136,7 @@ func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine { transcription.GET("/:id/logs", handler.GetJobLogs) transcription.GET("/:id/status", handler.GetJobStatus) transcription.GET("/:id/transcript", handler.GetTranscript) + transcription.POST("/:id/send-openclaw", handler.SendTranscriptionToOpenClaw) transcription.GET("/:id/execution", handler.GetJobExecutionData) transcription.GET("/:id/merge-status", handler.GetMergeStatus) transcription.GET("/:id/track-progress", handler.GetTrackProgress) @@ -180,6 +181,17 @@ func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine { user.PUT("/settings", handler.UpdateUserSettings) } + // OpenClaw profile routes (JWT-only due to stored secret material) + openClaw := v1.Group("/openclaw") + openClaw.Use(middleware.JWTOnlyMiddleware(authService)) + { + openClaw.GET("/profiles", handler.ListOpenClawProfiles) + openClaw.POST("/profiles", handler.CreateOpenClawProfile) + openClaw.GET("/profiles/:id", handler.GetOpenClawProfile) + openClaw.PUT("/profiles/:id", handler.UpdateOpenClawProfile) + openClaw.DELETE("/profiles/:id", handler.DeleteOpenClawProfile) + } + // Admin routes (require authentication) admin := v1.Group("/admin") admin.Use(middleware.AuthMiddleware(authService)) diff --git a/internal/database/database.go b/internal/database/database.go index d93e142c1..49bd03e75 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -76,6 +76,7 @@ func Initialize(dbPath string) error { &models.Summary{}, &models.Note{}, &models.RefreshToken{}, + &models.OpenClawProfile{}, ); err != nil { return fmt.Errorf("failed to auto migrate: %v", err) } diff --git a/internal/models/openclaw.go b/internal/models/openclaw.go new file mode 100644 index 000000000..7765e58ba --- /dev/null +++ b/internal/models/openclaw.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// OpenClawProfile stores remote delivery settings for OpenClaw automation. +type OpenClawProfile struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + IP string `json:"ip" gorm:"type:varchar(255);not null"` // Accepts host or user@host + SSHKey string `json:"ssh_key,omitempty" gorm:"type:text;not null"` // Private key content + HookKey string `json:"hook_key,omitempty" gorm:"type:text;not null"` // OpenClaw hook bearer token + Message string `json:"message" gorm:"type:text;not null"` // Default hook message + HookName string `json:"hook_name" gorm:"type:varchar(255);not null"` // OpenClaw agent name + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index" swaggertype:"string"` +} + +// BeforeCreate sets a UUID if not already set. +func (p *OpenClawProfile) BeforeCreate(tx *gorm.DB) error { + if p.ID == "" { + p.ID = uuid.New().String() + } + return nil +} diff --git a/internal/models/transcription.go b/internal/models/transcription.go index 632c6c3b7..98bedfaad 100644 --- a/internal/models/transcription.go +++ b/internal/models/transcription.go @@ -24,6 +24,8 @@ type TranscriptionJob struct { MergeStatus string `json:"merge_status" gorm:"type:varchar(20);default:'none'"` // none, pending, processing, completed, failed MergeError *string `json:"merge_error,omitempty" gorm:"type:text"` IndividualTranscripts *string `json:"individual_transcripts,omitempty" gorm:"type:text"` // JSON-serialized map[string]*string + OpenClawSentAt *time.Time `json:"openclaw_sent_at,omitempty" gorm:"type:datetime"` + OpenClawProfileName *string `json:"openclaw_profile_name,omitempty" gorm:"type:varchar(255)"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index" swaggertype:"string"` @@ -207,10 +209,10 @@ func (tp *TranscriptionProfile) BeforeSave(tx *gorm.DB) error { // LLMConfig represents LLM configuration settings type LLMConfig struct { ID uint `json:"id" gorm:"primaryKey"` - Provider string `json:"provider" gorm:"not null;type:varchar(50)"` // "ollama" or "openai" - BaseURL *string `json:"base_url,omitempty" gorm:"type:text"` // For Ollama + Provider string `json:"provider" gorm:"not null;type:varchar(50)"` // "ollama" or "openai" + BaseURL *string `json:"base_url,omitempty" gorm:"type:text"` // For Ollama OpenAIBaseURL *string `json:"openai_base_url,omitempty" gorm:"type:text"` // For OpenAI custom endpoint - APIKey *string `json:"api_key,omitempty" gorm:"type:text"` // For OpenAI (encrypted) + APIKey *string `json:"api_key,omitempty" gorm:"type:text"` // For OpenAI (encrypted) IsActive bool `json:"is_active" gorm:"type:boolean;default:false"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` diff --git a/internal/repository/implementations.go b/internal/repository/implementations.go index d71e5c381..7abb1318e 100644 --- a/internal/repository/implementations.go +++ b/internal/repository/implementations.go @@ -284,6 +284,31 @@ func (r *profileRepository) FindByName(ctx context.Context, name string) (*model return &profile, nil } +// OpenClawProfileRepository handles OpenClaw profile operations. +type OpenClawProfileRepository interface { + Repository[models.OpenClawProfile] + FindByName(ctx context.Context, name string) (*models.OpenClawProfile, error) +} + +type openClawProfileRepository struct { + *BaseRepository[models.OpenClawProfile] +} + +func NewOpenClawProfileRepository(db *gorm.DB) OpenClawProfileRepository { + return &openClawProfileRepository{ + BaseRepository: NewBaseRepository[models.OpenClawProfile](db), + } +} + +func (r *openClawProfileRepository) FindByName(ctx context.Context, name string) (*models.OpenClawProfile, error) { + var profile models.OpenClawProfile + err := r.db.WithContext(ctx).Where("name = ?", name).First(&profile).Error + if err != nil { + return nil, err + } + return &profile, nil +} + // LLMConfigRepository handles LLM configuration operations type LLMConfigRepository interface { Repository[models.LLMConfig] diff --git a/internal/service/openclaw_service.go b/internal/service/openclaw_service.go new file mode 100644 index 000000000..0a6311d9d --- /dev/null +++ b/internal/service/openclaw_service.go @@ -0,0 +1,277 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "scriberr/internal/models" +) + +const ( + openClawRemoteHookURL = "http://127.0.0.1:18789/hooks/meeting" + openClawRemoteDir = "/tmp" +) + +// OpenClawCommandRunner executes shell commands. +type OpenClawCommandRunner interface { + Run(ctx context.Context, name string, args []string, stdin []byte) ([]byte, error) +} + +type osOpenClawCommandRunner struct{} + +func (r *osOpenClawCommandRunner) Run(ctx context.Context, name string, args []string, stdin []byte) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + if len(stdin) > 0 { + cmd.Stdin = bytes.NewReader(stdin) + } + return cmd.CombinedOutput() +} + +// OpenClawService handles secure delivery to remote OpenClaw hosts. +type OpenClawService struct { + runner OpenClawCommandRunner +} + +// NewOpenClawService creates a service with OS-backed command execution. +func NewOpenClawService() *OpenClawService { + return &OpenClawService{runner: &osOpenClawCommandRunner{}} +} + +// NewOpenClawServiceWithRunner creates a service with a custom command runner for tests. +func NewOpenClawServiceWithRunner(runner OpenClawCommandRunner) *OpenClawService { + return &OpenClawService{runner: runner} +} + +type openClawHookPayload struct { + Message string `json:"message"` + Name string `json:"name"` + Deliver bool `json:"deliver"` +} + +// OpenClawSendResult captures remote delivery results. +type OpenClawSendResult struct { + RemotePath string `json:"remote_path"` + SCPOutput string `json:"scp_output,omitempty"` + HookOutput string `json:"hook_output,omitempty"` + ProfileName string `json:"profile_name"` +} + +// SendSRT uploads an SRT file to a remote host via SCP, then triggers the OpenClaw hook over SSH. +func (s *OpenClawService) SendSRT(ctx context.Context, profile *models.OpenClawProfile, srtContent string, title string, transcriptionID string) (*OpenClawSendResult, error) { + if profile == nil { + return nil, fmt.Errorf("profile is required") + } + if strings.TrimSpace(profile.IP) == "" { + return nil, fmt.Errorf("profile ip is required") + } + if strings.TrimSpace(profile.SSHKey) == "" { + return nil, fmt.Errorf("profile ssh key is required") + } + if strings.TrimSpace(profile.HookKey) == "" { + return nil, fmt.Errorf("profile hook key is required") + } + if strings.TrimSpace(srtContent) == "" { + return nil, fmt.Errorf("srt content is empty") + } + + keyPath, cleanupKey, err := writeTempPrivateKey(profile.SSHKey) + if err != nil { + return nil, err + } + defer cleanupKey() + + fileBase := buildRemoteBaseName(title, transcriptionID) + localPath, cleanupSRT, err := writeTempSRT(srtContent) + if err != nil { + return nil, err + } + defer cleanupSRT() + + remotePath := filepath.ToSlash(filepath.Join(openClawRemoteDir, fileBase+".srt")) + + scpArgs := []string{ + "-i", keyPath, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", + localPath, + fmt.Sprintf("%s:%s", profile.IP, remotePath), + } + scpOutput, err := s.runner.Run(ctx, "scp", scpArgs, nil) + if err != nil { + return nil, fmt.Errorf("scp upload failed: %w: %s", err, strings.TrimSpace(string(scpOutput))) + } + + message := strings.TrimSpace(profile.Message) + if message == "" { + message = "Please summarize this meeting transcript." + } + message = fmt.Sprintf("%s\n\nTitle: %s\nTranscription ID: %s\nSRT Path: %s", + message, titleOrFallback(title), transcriptionID, remotePath) + + payload := openClawHookPayload{ + Message: message, + Name: strings.TrimSpace(profile.HookName), + Deliver: true, + } + if payload.Name == "" { + payload.Name = "Scriberr" + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal hook payload: %w", err) + } + + remoteCmd := fmt.Sprintf( + "curl -fsS -X POST %s -H %s -H %s --data-binary @-", + shellQuote(openClawRemoteHookURL), + shellQuote("Authorization: Bearer "+profile.HookKey), + shellQuote("Content-Type: application/json"), + ) + + sshArgs := []string{ + "-i", keyPath, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=3", + profile.IP, + remoteCmd, + } + hookOutput, err := s.runner.Run(ctx, "ssh", sshArgs, payloadJSON) + if err != nil { + return nil, fmt.Errorf("remote hook trigger failed: %w: %s", err, strings.TrimSpace(string(hookOutput))) + } + if hookErr := parseOpenClawHookError(hookOutput); hookErr != "" { + return nil, fmt.Errorf("remote hook returned error: %s", hookErr) + } + + return &OpenClawSendResult{ + RemotePath: remotePath, + SCPOutput: strings.TrimSpace(string(scpOutput)), + HookOutput: strings.TrimSpace(string(hookOutput)), + ProfileName: profile.Name, + }, nil +} + +func writeTempPrivateKey(keyContent string) (string, func(), error) { + file, err := os.CreateTemp("", "scriberr-openclaw-key-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp ssh key: %w", err) + } + cleanup := func() { _ = os.Remove(file.Name()) } + + cleaned := strings.TrimSpace(keyContent) + "\n" + if _, err := file.WriteString(cleaned); err != nil { + _ = file.Close() + cleanup() + return "", nil, fmt.Errorf("failed to write temp ssh key: %w", err) + } + if err := file.Close(); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to close temp ssh key: %w", err) + } + if err := os.Chmod(file.Name(), 0600); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to set ssh key permissions: %w", err) + } + return file.Name(), cleanup, nil +} + +func writeTempSRT(content string) (string, func(), error) { + file, err := os.CreateTemp("", "scriberr-openclaw-*.srt") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp srt: %w", err) + } + cleanup := func() { _ = os.Remove(file.Name()) } + + if _, err := file.WriteString(content); err != nil { + _ = file.Close() + cleanup() + return "", nil, fmt.Errorf("failed to write temp srt: %w", err) + } + if err := file.Close(); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to close temp srt: %w", err) + } + + return file.Name(), cleanup, nil +} + +var invalidFileChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) +var multiUnderscore = regexp.MustCompile(`_+`) + +func buildRemoteBaseName(title, transcriptionID string) string { + base := titleOrFallback(title) + base = strings.ToLower(strings.TrimSpace(base)) + base = strings.ReplaceAll(base, " ", "_") + base = invalidFileChars.ReplaceAllString(base, "_") + base = multiUnderscore.ReplaceAllString(base, "_") + base = strings.Trim(base, "._-") + if base == "" { + base = "meeting" + } + if len(base) > 48 { + base = base[:48] + } + + shortID := strings.TrimSpace(transcriptionID) + if len(shortID) > 12 { + shortID = shortID[:12] + } + shortID = invalidFileChars.ReplaceAllString(shortID, "_") + + tstamp := time.Now().UTC().Format("20060102_150405") + if shortID == "" { + return fmt.Sprintf("%s_%s", base, tstamp) + } + return fmt.Sprintf("%s_%s_%s", base, shortID, tstamp) +} + +func titleOrFallback(title string) string { + trimmed := strings.TrimSpace(title) + if trimmed == "" { + return "Untitled Recording" + } + return trimmed +} + +func shellQuote(input string) string { + return "'" + strings.ReplaceAll(input, "'", `'"'"'`) + "'" +} + +func parseOpenClawHookError(output []byte) string { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "" + } + + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { + return "" + } + + rawErr, ok := payload["error"] + if !ok { + return "" + } + + errMsg, ok := rawErr.(string) + if !ok { + return "" + } + + return strings.TrimSpace(errMsg) +} diff --git a/internal/service/openclaw_service_test.go b/internal/service/openclaw_service_test.go new file mode 100644 index 000000000..888f8b148 --- /dev/null +++ b/internal/service/openclaw_service_test.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "scriberr/internal/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type commandCall struct { + name string + args []string + stdin []byte +} + +type fakeRunner struct { + calls []commandCall + errs map[string]error + outs map[string][]byte +} + +func (r *fakeRunner) Run(_ context.Context, name string, args []string, stdin []byte) ([]byte, error) { + clonedArgs := append([]string(nil), args...) + clonedStdin := append([]byte(nil), stdin...) + r.calls = append(r.calls, commandCall{name: name, args: clonedArgs, stdin: clonedStdin}) + if err, ok := r.errs[name]; ok { + return r.outs[name], err + } + return r.outs[name], nil +} + +func TestOpenClawServiceSendSRTSuccess(t *testing.T) { + runner := &fakeRunner{ + errs: map[string]error{}, + outs: map[string][]byte{ + "scp": []byte("uploaded"), + "ssh": []byte("hook-ok"), + }, + } + svc := NewOpenClawServiceWithRunner(runner) + + profile := &models.OpenClawProfile{ + Name: "prod", + IP: "user@example-host", + SSHKey: "test-private-key-content", + HookKey: "secret-hook-token", + HookName: "Dashboard", + Message: "Summarize this meeting", + } + + result, err := svc.SendSRT(context.Background(), profile, "1\n00:00:00,000 --> 00:00:01,000\nhello\n", "Weekly Sync", "job-123") + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "prod", result.ProfileName) + assert.Contains(t, result.RemotePath, "/tmp/") + assert.Equal(t, "uploaded", result.SCPOutput) + assert.Equal(t, "hook-ok", result.HookOutput) + + require.Len(t, runner.calls, 2) + assert.Equal(t, "scp", runner.calls[0].name) + assert.Equal(t, "ssh", runner.calls[1].name) + + sshStdin := string(runner.calls[1].stdin) + assert.Contains(t, sshStdin, `"message":"Summarize this meeting`) + assert.Contains(t, sshStdin, `"name":"Dashboard"`) + assert.Contains(t, sshStdin, `"deliver":true`) +} + +func TestOpenClawServiceSendSRTValidation(t *testing.T) { + svc := NewOpenClawServiceWithRunner(&fakeRunner{errs: map[string]error{}, outs: map[string][]byte{}}) + + _, err := svc.SendSRT(context.Background(), nil, "x", "title", "id") + require.Error(t, err) + assert.Contains(t, err.Error(), "profile is required") + + profile := &models.OpenClawProfile{ + Name: "p", + IP: "", + SSHKey: "key", + HookKey: "hk", + HookName: "n", + Message: "m", + } + _, err = svc.SendSRT(context.Background(), profile, "x", "title", "id") + require.Error(t, err) + assert.Contains(t, err.Error(), "profile ip") + + profile.IP = "example-host" + profile.SSHKey = "" + _, err = svc.SendSRT(context.Background(), profile, "x", "title", "id") + require.Error(t, err) + assert.Contains(t, err.Error(), "ssh key") + + profile.SSHKey = "key" + profile.HookKey = "" + _, err = svc.SendSRT(context.Background(), profile, "x", "title", "id") + require.Error(t, err) + assert.Contains(t, err.Error(), "hook key") + + profile.HookKey = "hk" + _, err = svc.SendSRT(context.Background(), profile, "", "title", "id") + require.Error(t, err) + assert.Contains(t, err.Error(), "srt content") +} + +func TestOpenClawServiceSendSRTCommandFailure(t *testing.T) { + runner := &fakeRunner{ + errs: map[string]error{ + "scp": errors.New("scp-failed"), + }, + outs: map[string][]byte{ + "scp": []byte("permission denied"), + }, + } + svc := NewOpenClawServiceWithRunner(runner) + + profile := &models.OpenClawProfile{ + Name: "prod", + IP: "user@example-host", + SSHKey: "ssh-key", + HookKey: "hook-key", + HookName: "Dashboard", + Message: "msg", + } + + _, err := svc.SendSRT(context.Background(), profile, "valid srt", "title", "job-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "scp upload failed") + assert.Contains(t, err.Error(), "permission denied") +} + +func TestOpenClawServiceSendSRTHookErrorPayload(t *testing.T) { + runner := &fakeRunner{ + errs: map[string]error{}, + outs: map[string][]byte{ + "scp": []byte("uploaded"), + "ssh": []byte(`{"error":"invalid hook token"}`), + }, + } + svc := NewOpenClawServiceWithRunner(runner) + + profile := &models.OpenClawProfile{ + Name: "prod", + IP: "user@example-host", + SSHKey: "ssh-key", + HookKey: "bad-token", + HookName: "Dashboard", + Message: "msg", + } + + _, err := svc.SendSRT(context.Background(), profile, "valid srt", "title", "job-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "remote hook returned error") + assert.Contains(t, err.Error(), "invalid hook token") +} + +func TestParseOpenClawHookError(t *testing.T) { + assert.Equal(t, "bad request", parseOpenClawHookError([]byte(`{"error":"bad request"}`))) + assert.Equal(t, "", parseOpenClawHookError([]byte(`{"ok":true}`))) + assert.Equal(t, "", parseOpenClawHookError([]byte(`not-json`))) +} + +func TestBuildRemoteBaseName(t *testing.T) { + name := buildRemoteBaseName("Team / Weekly Sync", "abcdef123456789") + assert.True(t, strings.HasPrefix(name, "team_weekly_sync_abcdef123456_")) +} diff --git a/tests/api_handlers_test.go b/tests/api_handlers_test.go index 1a1d46752..0bae5e030 100644 --- a/tests/api_handlers_test.go +++ b/tests/api_handlers_test.go @@ -2,6 +2,7 @@ package tests import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -36,6 +37,23 @@ type APIHandlerTestSuite struct { unifiedProcessor *transcription.UnifiedJobProcessor quickTranscription *transcription.QuickTranscriptionService mockOpenAI *httptest.Server + openClawRunner *fakeOpenClawRunner +} + +type fakeOpenClawRunner struct { + calls []string +} + +func (r *fakeOpenClawRunner) Run(_ context.Context, name string, _ []string, _ []byte) ([]byte, error) { + r.calls = append(r.calls, name) + switch name { + case "scp": + return []byte("scp-ok"), nil + case "ssh": + return []byte("ssh-ok"), nil + default: + return []byte(""), nil + } } func (suite *APIHandlerTestSuite) SetupSuite() { @@ -52,11 +70,14 @@ func (suite *APIHandlerTestSuite) SetupSuite() { chatRepo := repository.NewChatRepository(suite.helper.DB) noteRepo := repository.NewNoteRepository(suite.helper.DB) speakerMappingRepo := repository.NewSpeakerMappingRepository(suite.helper.DB) + openClawProfileRepo := repository.NewOpenClawProfileRepository(suite.helper.DB) refreshTokenRepo := repository.NewRefreshTokenRepository(suite.helper.DB) // Initialize services userService := service.NewUserService(userRepo, suite.helper.AuthService) fileService := service.NewFileService() + suite.openClawRunner = &fakeOpenClawRunner{} + openClawService := service.NewOpenClawServiceWithRunner(suite.openClawRunner) // Initialize services suite.unifiedProcessor = transcription.NewUnifiedJobProcessor(jobRepo, suite.helper.Config.TempDir, suite.helper.Config.TranscriptsDir) @@ -84,7 +105,9 @@ func (suite *APIHandlerTestSuite) SetupSuite() { chatRepo, noteRepo, speakerMappingRepo, + openClawProfileRepo, refreshTokenRepo, + openClawService, suite.taskQueue, suite.unifiedProcessor, suite.quickTranscription, @@ -105,6 +128,7 @@ func (suite *APIHandlerTestSuite) TearDownSuite() { func (suite *APIHandlerTestSuite) SetupTest() { suite.helper.ResetDB(suite.T()) + suite.openClawRunner.calls = nil // Create LLM config pointing to mock server llmConfig := &models.LLMConfig{ @@ -505,6 +529,114 @@ func (suite *APIHandlerTestSuite) TestProfileManagement() { assert.Equal(suite.T(), 200, w.Code) } +func (suite *APIHandlerTestSuite) TestOpenClawProfileManagement() { + profileData := map[string]interface{}{ + "name": "OpenClaw Test", + "ip": "user@example-host", + "ssh_key": "ssh-key", + "hook_key": "hook-key", + "hook_name": "Dashboard", + "message": "Summarize this meeting", + } + + w := suite.makeAuthenticatedRequest("POST", "/api/v1/openclaw/profiles", profileData, true) + assert.Equal(suite.T(), 200, w.Code) + + var created map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &created) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "OpenClaw Test", created["name"]) + assert.Equal(suite.T(), true, created["has_ssh_key"]) + assert.Equal(suite.T(), true, created["has_hook_key"]) + assert.NotContains(suite.T(), created, "ssh_key") + assert.NotContains(suite.T(), created, "hook_key") + + profileID, _ := created["id"].(string) + w = suite.makeAuthenticatedRequest("GET", "/api/v1/openclaw/profiles", nil, true) + assert.Equal(suite.T(), 200, w.Code) + + var list []map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &list) + assert.NoError(suite.T(), err) + assert.GreaterOrEqual(suite.T(), len(list), 1) + + updateData := map[string]interface{}{ + "name": "OpenClaw Updated", + "ip": "user@example-host", + "hook_name": "Dashboard", + "message": "Summarize now", + } + w = suite.makeAuthenticatedRequest("PUT", fmt.Sprintf("/api/v1/openclaw/profiles/%s", profileID), updateData, true) + assert.Equal(suite.T(), 200, w.Code) + + w = suite.makeAuthenticatedRequest("DELETE", fmt.Sprintf("/api/v1/openclaw/profiles/%s", profileID), nil, true) + assert.Equal(suite.T(), 200, w.Code) +} + +func (suite *APIHandlerTestSuite) TestSendTranscriptionToOpenClaw() { + title := "Weekly Sync" + transcript := `{"segments":[{"start":0.0,"end":1.2,"text":"Hello team","speaker":"speaker_00"}]}` + job := models.TranscriptionJob{ + ID: "job-openclaw-send", + Title: &title, + Status: models.StatusCompleted, + AudioPath: "test/path/audio.mp3", + Transcript: &transcript, + } + assert.NoError(suite.T(), suite.helper.DB.Create(&job).Error) + + profile := models.OpenClawProfile{ + ID: "openclaw-profile-send", + Name: "OpenClaw Prod", + IP: "user@example-host", + SSHKey: "ssh-key", + HookKey: "hook-key", + HookName: "Dashboard", + Message: "Summarize this", + } + assert.NoError(suite.T(), suite.helper.DB.Create(&profile).Error) + + reqBody := map[string]string{"profile_id": profile.ID} + w := suite.makeAuthenticatedRequest("POST", fmt.Sprintf("/api/v1/transcription/%s/send-openclaw", job.ID), reqBody, true) + assert.Equal(suite.T(), 200, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "Sent to OpenClaw", response["message"]) + assert.Contains(suite.T(), response, "remote_path") + assert.Equal(suite.T(), "OpenClaw Prod", response["profile_name"]) + assert.Equal(suite.T(), []string{"scp", "ssh"}, suite.openClawRunner.calls) +} + +func (suite *APIHandlerTestSuite) TestSendTranscriptionToOpenClawRequiresJWT() { + title := "JWT Required" + transcript := `{"segments":[{"start":0.0,"end":1.0,"text":"Hello"}]}` + job := models.TranscriptionJob{ + ID: "job-openclaw-jwt", + Title: &title, + Status: models.StatusCompleted, + AudioPath: "test/path/audio.mp3", + Transcript: &transcript, + } + assert.NoError(suite.T(), suite.helper.DB.Create(&job).Error) + + profile := models.OpenClawProfile{ + ID: "openclaw-profile-jwt", + Name: "OpenClaw JWT", + IP: "user@example-host", + SSHKey: "ssh-key", + HookKey: "hook-key", + HookName: "Dashboard", + Message: "Summarize this", + } + assert.NoError(suite.T(), suite.helper.DB.Create(&profile).Error) + + reqBody := map[string]string{"profile_id": profile.ID} + w := suite.makeAuthenticatedRequest("POST", fmt.Sprintf("/api/v1/transcription/%s/send-openclaw", job.ID), reqBody, false) + assert.Equal(suite.T(), 401, w.Code) +} + // Test notes management func (suite *APIHandlerTestSuite) TestNotesManagement() { // Create a transcription job first diff --git a/tests/cli_handlers_test.go b/tests/cli_handlers_test.go index dfa432402..a4968ac57 100644 --- a/tests/cli_handlers_test.go +++ b/tests/cli_handlers_test.go @@ -45,11 +45,13 @@ func (suite *CLIHandlerTestSuite) SetupSuite() { chatRepo := repository.NewChatRepository(suite.helper.DB) noteRepo := repository.NewNoteRepository(suite.helper.DB) speakerMappingRepo := repository.NewSpeakerMappingRepository(suite.helper.DB) + openClawProfileRepo := repository.NewOpenClawProfileRepository(suite.helper.DB) refreshTokenRepo := repository.NewRefreshTokenRepository(suite.helper.DB) // Initialize services userService := service.NewUserService(userRepo, suite.helper.AuthService) fileService := service.NewFileService() + openClawService := service.NewOpenClawService() // Initialize services suite.unifiedProcessor = transcription.NewUnifiedJobProcessor(jobRepo, suite.helper.Config.TempDir, suite.helper.Config.TranscriptsDir) @@ -77,7 +79,9 @@ func (suite *CLIHandlerTestSuite) SetupSuite() { chatRepo, noteRepo, speakerMappingRepo, + openClawProfileRepo, refreshTokenRepo, + openClawService, suite.taskQueue, suite.unifiedProcessor, suite.quickTranscription, diff --git a/tests/database_test.go b/tests/database_test.go index b33c23889..b6f1694a3 100644 --- a/tests/database_test.go +++ b/tests/database_test.go @@ -258,6 +258,44 @@ func (suite *DatabaseTestSuite) TestTranscriptionProfileCRUD() { assert.NoError(suite.T(), result.Error) } +// Test OpenClawProfile model CRUD operations +func (suite *DatabaseTestSuite) TestOpenClawProfileCRUD() { + db := suite.helper.GetDB() + + profile := models.OpenClawProfile{ + ID: "openclaw-profile-crud-123", + Name: "OpenClaw Prod", + IP: "user@example-host", + SSHKey: "ssh-private-key-content", + HookKey: "hook-secret", + HookName: "Dashboard", + Message: "Summarize this meeting", + } + + result := db.Create(&profile) + assert.NoError(suite.T(), result.Error) + assert.NotZero(suite.T(), profile.CreatedAt) + + var found models.OpenClawProfile + result = db.Where("id = ?", profile.ID).First(&found) + assert.NoError(suite.T(), result.Error) + assert.Equal(suite.T(), profile.Name, found.Name) + assert.Equal(suite.T(), profile.IP, found.IP) + assert.Equal(suite.T(), profile.HookName, found.HookName) + + found.Message = "Updated message" + result = db.Save(&found) + assert.NoError(suite.T(), result.Error) + + var updated models.OpenClawProfile + result = db.Where("id = ?", profile.ID).First(&updated) + assert.NoError(suite.T(), result.Error) + assert.Equal(suite.T(), "Updated message", updated.Message) + + result = db.Delete(&updated) + assert.NoError(suite.T(), result.Error) +} + // Test Note model CRUD operations func (suite *DatabaseTestSuite) TestNoteCRUD() { db := suite.helper.GetDB() diff --git a/tests/security_test.go b/tests/security_test.go index 66e4a27ab..cb2548c72 100644 --- a/tests/security_test.go +++ b/tests/security_test.go @@ -71,11 +71,13 @@ func (suite *SecurityTestSuite) SetupSuite() { chatRepo := repository.NewChatRepository(database.DB) noteRepo := repository.NewNoteRepository(database.DB) speakerMappingRepo := repository.NewSpeakerMappingRepository(database.DB) + openClawProfileRepo := repository.NewOpenClawProfileRepository(database.DB) refreshTokenRepo := repository.NewRefreshTokenRepository(database.DB) // Initialize services userService := service.NewUserService(userRepo, suite.authService) fileService := service.NewFileService() + openClawService := service.NewOpenClawService() // Initialize services suite.unifiedProcessor = transcription.NewUnifiedJobProcessor(jobRepo, suite.config.TempDir, suite.config.TranscriptsDir) @@ -104,7 +106,9 @@ func (suite *SecurityTestSuite) SetupSuite() { chatRepo, noteRepo, speakerMappingRepo, + openClawProfileRepo, refreshTokenRepo, + openClawService, suite.taskQueue, suite.unifiedProcessor, suite.quickTranscriptionService, @@ -252,6 +256,7 @@ func (suite *SecurityTestSuite) TestTranscriptionEndpointsUnauthorized() { {"GET", "/api/v1/transcription/test-id/transcript", nil, false}, {"GET", "/api/v1/transcription/test-id/audio", nil, false}, {"PUT", "/api/v1/transcription/test-id/title", map[string]string{"title": "New Title"}, false}, + {"POST", "/api/v1/transcription/test-id/send-openclaw", map[string]string{"profile_id": "profile-1"}, false}, {"GET", "/api/v1/transcription/test-id/summary", nil, false}, {"GET", "/api/v1/transcription/test-id", nil, false}, {"DELETE", "/api/v1/transcription/test-id", nil, false}, @@ -286,6 +291,39 @@ func (suite *SecurityTestSuite) TestTranscriptionEndpointsUnauthorized() { } } +func (suite *SecurityTestSuite) TestOpenClawEndpointsUnauthorized() { + testCases := []struct { + method string + path string + body interface{} + }{ + {"GET", "/api/v1/openclaw/profiles", nil}, + {"POST", "/api/v1/openclaw/profiles", map[string]interface{}{ + "name": "OpenClaw", + "ip": "user@example-host", + "ssh_key": "key", + "hook_key": "hook", + "hook_name": "Dashboard", + "message": "msg", + }}, + {"GET", "/api/v1/openclaw/profiles/123", nil}, + {"PUT", "/api/v1/openclaw/profiles/123", map[string]interface{}{ + "name": "OpenClaw Updated", + "ip": "user@example-host", + "hook_name": "Dashboard", + "message": "msg", + }}, + {"DELETE", "/api/v1/openclaw/profiles/123", nil}, + } + + for _, tc := range testCases { + suite.T().Run(fmt.Sprintf("%s %s", tc.method, tc.path), func(t *testing.T) { + w := suite.makeUnauthenticatedRequest(tc.method, tc.path, tc.body) + assert.Equal(t, 401, w.Code, "Should return 401 Unauthorized for unauthenticated request to %s %s", tc.method, tc.path) + }) + } +} + // Test profile endpoints func (suite *SecurityTestSuite) TestProfileEndpointsUnauthorized() { testCases := []struct { diff --git a/tests/test_helpers.go b/tests/test_helpers.go index b7cd7a560..824d1d25f 100644 --- a/tests/test_helpers.go +++ b/tests/test_helpers.go @@ -96,6 +96,7 @@ func (h *TestHelper) ResetDB(t *testing.T) { &models.ChatSession{}, &models.TranscriptionJobExecution{}, // Assuming this exists based on MockJobRepository &models.TranscriptionJob{}, + &models.OpenClawProfile{}, &models.TranscriptionProfile{}, &models.SummaryTemplate{}, &models.LLMConfig{}, diff --git a/web/frontend/src/features/settings/components/OpenClawProfileSettings.tsx b/web/frontend/src/features/settings/components/OpenClawProfileSettings.tsx new file mode 100644 index 000000000..b19ec2a1a --- /dev/null +++ b/web/frontend/src/features/settings/components/OpenClawProfileSettings.tsx @@ -0,0 +1,326 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; +import { Pencil, Plus, Send, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/features/auth/hooks/useAuth"; + +interface OpenClawProfile { + id: string; + name: string; + ip: string; + hook_name: string; + message: string; + has_ssh_key: boolean; + has_hook_key: boolean; +} + +interface ProfileFormState { + name: string; + ip: string; + ssh_key: string; + hook_key: string; + hook_name: string; + message: string; +} + +const emptyForm: ProfileFormState = { + name: "", + ip: "", + ssh_key: "", + hook_key: "", + hook_name: "Dashboard", + message: "请根据上传的 SRT 输出会议总结,提炼关键决策、行动项和风险。", +}; + +export function OpenClawProfileSettings() { + const { getAuthHeaders } = useAuth(); + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState(emptyForm); + const [sshKeyFileName, setSshKeyFileName] = useState(""); + const sshKeyInputRef = useRef(null); + + const fetchProfiles = useCallback(async () => { + try { + setLoading(true); + setError(""); + const res = await fetch("/api/v1/openclaw/profiles", { + headers: getAuthHeaders(), + }); + if (!res.ok) { + throw new Error("Failed to fetch OpenClaw profiles"); + } + const data = await res.json(); + setProfiles(Array.isArray(data) ? data : []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch OpenClaw profiles"); + } finally { + setLoading(false); + } + }, [getAuthHeaders]); + + useEffect(() => { + void fetchProfiles(); + }, [fetchProfiles]); + + const openCreateDialog = () => { + setEditing(null); + setForm(emptyForm); + setSshKeyFileName(""); + setDialogOpen(true); + }; + + const openEditDialog = (profile: OpenClawProfile) => { + setEditing(profile); + setForm({ + name: profile.name, + ip: profile.ip, + ssh_key: "", + hook_key: "", + hook_name: profile.hook_name, + message: profile.message, + }); + setSshKeyFileName(profile.has_ssh_key ? "已保存 SSH Key,可重新选择文件覆盖" : ""); + setDialogOpen(true); + }; + + const triggerSshKeyFilePicker = () => { + sshKeyInputRef.current?.click(); + }; + + const onSshKeyFileSelected = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + const keyContent = await file.text(); + setForm((state) => ({ ...state, ssh_key: keyContent })); + setSshKeyFileName(file.name); + setError(""); + } catch { + setError("Failed to read SSH key file"); + } finally { + event.target.value = ""; + } + }; + + const onSave = async () => { + setError(""); + if (!form.name.trim() || !form.ip.trim() || !form.hook_name.trim() || !form.message.trim()) { + setError("Name, host, hook name, and message are required"); + return; + } + if (!editing && (!form.ssh_key.trim() || !form.hook_key.trim())) { + setError("SSH key and hook key are required"); + return; + } + + try { + setSaving(true); + const isEditing = !!editing; + const url = isEditing ? `/api/v1/openclaw/profiles/${editing?.id}` : "/api/v1/openclaw/profiles"; + const method = isEditing ? "PUT" : "POST"; + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Failed to save profile"); + } + + setDialogOpen(false); + setEditing(null); + setForm(emptyForm); + setSshKeyFileName(""); + await fetchProfiles(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save profile"); + } finally { + setSaving(false); + } + }; + + const requestDelete = (profile: OpenClawProfile) => { + setDeleting(profile); + setDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (!deleting) return; + try { + const res = await fetch(`/api/v1/openclaw/profiles/${deleting.id}`, { + method: "DELETE", + headers: getAuthHeaders(), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Failed to delete profile"); + } + setDeleteDialogOpen(false); + setDeleting(null); + await fetchProfiles(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete profile"); + } + }; + + return ( +
+ {error && ( +
+

{error}

+
+ )} + +
+
+
+

+ + OpenClaw Profiles +

+

+ 配置远端连接和 OpenClaw hook,用于发送 SRT 并触发总结。 +

+
+ +
+ + {loading ? ( +
Loading profiles...
+ ) : profiles.length === 0 ? ( +
+ No OpenClaw profiles yet. +
+ ) : ( +
+ {profiles.map((profile) => ( +
+
+
{profile.name}
+
{profile.ip}
+
Hook Name: {profile.hook_name}
+
+
+ + +
+
+ ))} +
+ )} +
+ + + + + {editing ? "Edit OpenClaw Profile" : "New OpenClaw Profile"} + + 保存后即可在录音详情页中直接选择该 profile 发送转录结果。 + + + +
+
+ + setForm((state) => ({ ...state, name: e.target.value }))} placeholder="e.g. OpenClaw Prod" /> +
+
+ + setForm((state) => ({ ...state, ip: e.target.value }))} placeholder="user@example-host" /> +
+
+ +
+ + +

+ {sshKeyFileName || (editing ? "未修改,将继续使用已保存 SSH Key" : "未选择文件")} +

+
+
+
+ + setForm((state) => ({ ...state, hook_key: e.target.value }))} + placeholder={editing ? "留空则保留当前值" : "Bearer token value"} + /> +
+
+ + setForm((state) => ({ ...state, hook_name: e.target.value }))} placeholder="Dashboard" /> +
+
+ +