mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
This should help understanding events from the SoundTouch app to the speakers and from speakers to the BMX service.
853 lines
22 KiB
Go
853 lines
22 KiB
Go
package proxy
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Recorder handles persisting HTTP interactions as .http files.
|
|
type Recorder struct {
|
|
BaseDir string
|
|
SessionID string
|
|
SessionDir string
|
|
Patterns PathPatterns
|
|
Redact bool
|
|
counter uint64
|
|
variables map[string]string
|
|
mu sync.Mutex
|
|
queue chan recordingTask
|
|
}
|
|
|
|
type recordingTask struct {
|
|
category string
|
|
req *http.Request
|
|
res *http.Response
|
|
replacements map[string]string
|
|
dir string
|
|
path string
|
|
}
|
|
|
|
// InteractionStats represents statistics for recorded interactions.
|
|
type InteractionStats struct {
|
|
TotalRequests int `json:"total_requests"`
|
|
ByService map[string]int `json:"by_service"`
|
|
BySession map[string]int `json:"by_session"`
|
|
}
|
|
|
|
// Interaction represents a single recorded HTTP interaction.
|
|
type Interaction struct {
|
|
ID string `json:"id"`
|
|
Session string `json:"session"`
|
|
Category string `json:"category"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
File string `json:"file"`
|
|
Counter int `json:"counter"`
|
|
Status int `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
SCMUDCData *EnrichedSCMUDCEvent `json:"scmudc_data,omitempty"`
|
|
}
|
|
|
|
// NewRecorder creates a new HTTP interaction recorder.
|
|
func NewRecorder(baseDir string) *Recorder {
|
|
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
|
|
|
|
r := &Recorder{
|
|
BaseDir: baseDir,
|
|
SessionID: sessionID,
|
|
Patterns: DefaultPatterns(),
|
|
variables: make(map[string]string),
|
|
}
|
|
|
|
// Use environment variable to control async recording, default to true for production
|
|
// but allow disabling it for tests if needed.
|
|
if os.Getenv("RECORDER_ASYNC") != "false" {
|
|
r.queue = make(chan recordingTask, 100)
|
|
go r.worker()
|
|
} else {
|
|
log.Println("[DEBUG_LOG] Recorder starting in synchronous mode")
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// Close stops the recorder and waits for pending tasks to finish.
|
|
func (r *Recorder) Close() {
|
|
if r.queue != nil {
|
|
close(r.queue)
|
|
// We might want to wait here, but for now just closing is a start
|
|
}
|
|
}
|
|
|
|
// Record logs an interaction to the configured category.
|
|
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
|
|
if r.BaseDir == "" {
|
|
return nil
|
|
}
|
|
|
|
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
|
|
dir := r.getRecordingDir(category, sanitizedSegments)
|
|
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
}
|
|
|
|
path := r.getRecordingPath(dir, req.Method)
|
|
|
|
// If we are in async mode, we MUST copy the bodies now because the caller
|
|
// might close them as soon as Record() returns.
|
|
var (
|
|
clonedReq *http.Request
|
|
clonedRes *http.Response
|
|
)
|
|
|
|
if r.queue != nil {
|
|
// Clone request
|
|
clonedReq = req.Clone(req.Context())
|
|
if req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read request body for async recording: %v", err)
|
|
|
|
clonedReq.Body = http.NoBody
|
|
} else {
|
|
// Reset original body for subsequent consumers (though Record is usually called at the end)
|
|
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
// Set body for async task
|
|
clonedReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedReq.ContentLength = int64(len(bodyBytes))
|
|
}
|
|
}
|
|
|
|
// Clone response if present
|
|
if res != nil {
|
|
clonedRes = &http.Response{
|
|
StatusCode: res.StatusCode,
|
|
Header: res.Header.Clone(),
|
|
Request: clonedReq,
|
|
}
|
|
if res.Body != nil {
|
|
bodyBytes, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
log.Printf("failed to read response body for async recording: %v", err)
|
|
|
|
res.Body = http.NoBody
|
|
clonedRes.Body = http.NoBody
|
|
} else {
|
|
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedRes.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
clonedRes.ContentLength = int64(len(bodyBytes))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
clonedReq = req
|
|
clonedRes = res
|
|
}
|
|
|
|
task := recordingTask{
|
|
category: category,
|
|
req: clonedReq,
|
|
res: clonedRes,
|
|
replacements: replacements,
|
|
dir: dir,
|
|
path: path,
|
|
}
|
|
|
|
// For testing purposes or if queue is nil, fallback to synchronous
|
|
if r.queue == nil {
|
|
r.save(task)
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case r.queue <- task:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("recording queue full, dropping interaction for %s", req.URL.Path)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) save(task recordingTask) {
|
|
var (
|
|
buf bytes.Buffer
|
|
enriched *EnrichedSCMUDCEvent
|
|
)
|
|
|
|
// Check if this is a SCMUDC request and enrich it
|
|
|
|
if strings.Contains(task.req.URL.Path, "/v1/scmudc/") && task.req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(task.req.Body)
|
|
if err == nil {
|
|
task.req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
enriched = enrichSCMUDCRequest(bodyBytes)
|
|
}
|
|
}
|
|
|
|
r.writeRequestWithEnrichment(&buf, task.req, task.replacements, enriched)
|
|
|
|
if task.res != nil {
|
|
r.writeResponseWithEnrichment(&buf, task.res, enriched)
|
|
}
|
|
|
|
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
|
log.Printf("failed to write recording to %s: %v", task.path, err)
|
|
}
|
|
|
|
_ = r.updateEnvFile(task.replacements)
|
|
}
|
|
|
|
func (r *Recorder) worker() {
|
|
for task := range r.queue {
|
|
r.save(task)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
|
|
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
|
|
sanitizedSegments := make([]string, 0, len(pathSegments))
|
|
replacements := make(map[string]string)
|
|
|
|
for _, segment := range pathSegments {
|
|
if segment == "" {
|
|
continue
|
|
}
|
|
|
|
sanitized, replacement := r.Patterns.Sanitize(segment)
|
|
sanitizedSegments = append(sanitizedSegments, sanitized)
|
|
|
|
if replacement != "" {
|
|
replacements[segment] = replacement
|
|
}
|
|
}
|
|
|
|
return sanitizedSegments, replacements
|
|
}
|
|
|
|
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) string {
|
|
subDir := "root"
|
|
if len(sanitizedSegments) > 0 {
|
|
subDir = filepath.Join(sanitizedSegments...)
|
|
}
|
|
|
|
return filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir)
|
|
}
|
|
|
|
func (r *Recorder) getRecordingPath(dir, method string) string {
|
|
timestamp := time.Now().Format("20060102-150405.000")
|
|
count := atomic.AddUint64(&r.counter, 1)
|
|
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
|
|
|
|
return filepath.Join(dir, filename)
|
|
}
|
|
|
|
func (r *Recorder) writeRequestWithEnrichment(buf *bytes.Buffer, req *http.Request, replacements map[string]string, enriched *EnrichedSCMUDCEvent) {
|
|
displayURL := req.URL.String()
|
|
for orig, repl := range replacements {
|
|
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
|
|
}
|
|
|
|
fmt.Fprintf(buf, "### %s %s\n", req.Method, displayURL)
|
|
|
|
for orig, repl := range replacements {
|
|
key := strings.Trim(repl, "{}")
|
|
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
|
|
}
|
|
|
|
// Add SCMUDC enriched comments
|
|
if enriched != nil {
|
|
enrichedComments := generateSCMUDCComments(enriched)
|
|
for _, comment := range enrichedComments {
|
|
fmt.Fprintf(buf, "%s\n", comment)
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
|
|
fmt.Fprintf(buf, "Host: %s\n", req.Host)
|
|
|
|
for k, vv := range req.Header {
|
|
if r.Redact && isSensitive(k) {
|
|
fmt.Fprintf(buf, "%s: [REDACTED]\n", k)
|
|
continue
|
|
}
|
|
|
|
for _, v := range vv {
|
|
val := v
|
|
for orig, repl := range replacements {
|
|
val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}")
|
|
}
|
|
|
|
fmt.Fprintf(buf, "%s: %s\n", k, val)
|
|
}
|
|
}
|
|
|
|
buf.WriteString("\n")
|
|
|
|
if req.Body != nil {
|
|
bodyBytes, err := io.ReadAll(req.Body)
|
|
if err == nil {
|
|
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
buf.Write(bodyBytes)
|
|
buf.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) writeResponseWithEnrichment(buf *bytes.Buffer, res *http.Response, enriched *EnrichedSCMUDCEvent) {
|
|
buf.WriteString("\n")
|
|
buf.WriteString("> {% \n")
|
|
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
|
|
|
|
// Add SCMUDC enrichment summary in response
|
|
if enriched != nil {
|
|
buf.WriteString(" //\n")
|
|
buf.WriteString(" // SCMUDC Event Analysis:\n")
|
|
fmt.Fprintf(buf, " // - Origin: %s (%s)\n", getOriginDescription(enriched.Origin), enriched.Origin)
|
|
fmt.Fprintf(buf, " // - Action: %s\n", enriched.Action)
|
|
fmt.Fprintf(buf, " // - Summary: %s\n", enriched.Summary)
|
|
|
|
if enriched.DecodedData != nil {
|
|
fmt.Fprintf(buf, " // - Content: %s\n", enriched.DecodedData.ItemName)
|
|
|
|
if enriched.DecodedData.SourceAccount != "" {
|
|
fmt.Fprintf(buf, " // - Account: %s\n", enriched.DecodedData.SourceAccount)
|
|
}
|
|
}
|
|
}
|
|
|
|
buf.WriteString(" //\n")
|
|
buf.WriteString(" // Headers:\n")
|
|
|
|
for k, vv := range res.Header {
|
|
if r.Redact && isSensitive(k) {
|
|
fmt.Fprintf(buf, " // %s: [REDACTED]\n", k)
|
|
continue
|
|
}
|
|
|
|
for _, v := range vv {
|
|
fmt.Fprintf(buf, " // %s: %s\n", k, v)
|
|
}
|
|
}
|
|
|
|
buf.WriteString("%}\n")
|
|
|
|
if res.Body != nil {
|
|
bodyBytes, err := io.ReadAll(res.Body)
|
|
if err == nil {
|
|
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
|
|
|
contentType := res.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") {
|
|
buf.WriteString("\n/*\n")
|
|
buf.Write(bodyBytes)
|
|
buf.WriteString("\n*/\n")
|
|
} else if len(bodyBytes) > 0 {
|
|
fmt.Fprintf(buf, "\n[Binary response body: %d bytes]\n", len(bodyBytes))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) updateEnvFile(newVars map[string]string) error {
|
|
if len(newVars) == 0 {
|
|
return nil
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
changed := false
|
|
|
|
for orig, repl := range newVars {
|
|
key := strings.Trim(repl, "{}")
|
|
if r.variables[key] != orig {
|
|
r.variables[key] = orig
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if !changed {
|
|
return nil
|
|
}
|
|
|
|
envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json")
|
|
|
|
// Create the structure: {"session": {"key": "val"}}
|
|
content := map[string]map[string]string{
|
|
"session": r.variables,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(content, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(envFile, data, 0644)
|
|
}
|
|
|
|
// GetInteractionStats returns statistics about recorded interactions.
|
|
func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
|
|
stats := &InteractionStats{
|
|
ByService: make(map[string]int),
|
|
BySession: make(map[string]int),
|
|
}
|
|
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
|
return stats, nil
|
|
}
|
|
|
|
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
|
|
stats.TotalRequests++
|
|
|
|
// Extract category (self/upstream) and session from path
|
|
// Path is like: .../interactions/<session>/<category>/...
|
|
rel, err := filepath.Rel(interactionsDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) >= 2 {
|
|
sessionID := parts[0]
|
|
category := parts[1]
|
|
stats.BySession[sessionID]++
|
|
stats.ByService[category]++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return stats, err
|
|
}
|
|
|
|
// ListInteractions returns a list of recorded interactions.
|
|
func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
|
|
interactions := make([]Interaction, 0)
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
|
|
if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
|
|
return interactions, nil
|
|
}
|
|
|
|
err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(interactionsDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
if len(parts) < 3 {
|
|
return nil
|
|
}
|
|
|
|
sessionID, category := parts[0], parts[1]
|
|
if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
|
|
return nil
|
|
}
|
|
|
|
interaction, ok := r.parseInteractionFile(rel, path, parts)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
if sinceFilter != "" && interaction.Timestamp != "" {
|
|
fullTS := r.getFullTimestamp(sessionID, interaction.ID)
|
|
|
|
normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
|
|
if fullTS != "" && fullTS < normalizedSince {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
interactions = append(interactions, interaction)
|
|
|
|
return nil
|
|
})
|
|
|
|
return interactions, err
|
|
}
|
|
|
|
func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
|
|
sessionID, category := parts[0], parts[1]
|
|
filename := parts[len(parts)-1]
|
|
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
|
|
|
timestamp := ""
|
|
method, counter := "UNKNOWN", 0
|
|
|
|
if len(fnParts) >= 1 {
|
|
_, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
|
|
}
|
|
|
|
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
|
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
|
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
|
dateStr := fnParts[1] // yyyyMMdd
|
|
timeStr := fnParts[2] // HHMMSS.sss
|
|
method = fnParts[3]
|
|
|
|
// Format date: yyyyMMdd -> yyyy-MM-dd
|
|
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
|
|
|
// Format time: HHMMSS.sss -> HH:MM:SS.sss
|
|
if len(timeStr) >= 6 {
|
|
time := timeStr[0:2] + ":" + timeStr[2:4] + ":" + timeStr[4:]
|
|
timestamp = date + " " + time
|
|
}
|
|
} else if len(fnParts) >= 5 {
|
|
// Legacy format: count-HH-MM-SS.sss-method.http
|
|
// Extract date from sessionID for backward compatibility
|
|
date := ""
|
|
if len(sessionID) >= 8 {
|
|
date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
|
}
|
|
|
|
timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
|
|
timestamp = timeStr
|
|
|
|
if date != "" {
|
|
timestamp = date + " " + timeStr
|
|
}
|
|
|
|
method = fnParts[4]
|
|
}
|
|
|
|
requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
|
|
if requestPath == "/root" {
|
|
requestPath = "/"
|
|
}
|
|
|
|
interaction := Interaction{
|
|
ID: filename,
|
|
Session: sessionID,
|
|
Category: category,
|
|
Method: method,
|
|
Path: requestPath,
|
|
File: rel,
|
|
Counter: counter,
|
|
Status: r.peekStatus(path),
|
|
Timestamp: timestamp,
|
|
}
|
|
|
|
// Extract SCMUDC enrichment data if this is a SCMUDC request
|
|
if strings.Contains(requestPath, "/v1/scmudc/") {
|
|
interaction.SCMUDCData = r.extractSCMUDCFromFile(path)
|
|
}
|
|
|
|
return interaction, true
|
|
}
|
|
|
|
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
|
|
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
var (
|
|
enriched EnrichedSCMUDCEvent
|
|
foundSCMUDC bool
|
|
bodyStart int
|
|
)
|
|
|
|
// Look for SCMUDC enrichment comments
|
|
|
|
for i, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
switch {
|
|
case strings.HasPrefix(line, "// Origin: "):
|
|
parts := strings.Split(line, " (")
|
|
if len(parts) >= 2 {
|
|
enriched.Origin = strings.TrimSuffix(parts[1], ")")
|
|
foundSCMUDC = true
|
|
}
|
|
case strings.HasPrefix(line, "// Action: "):
|
|
enriched.Action = strings.TrimPrefix(line, "// Action: ")
|
|
case strings.HasPrefix(line, "// Command: "):
|
|
enriched.Command = strings.TrimPrefix(line, "// Command: ")
|
|
case strings.HasPrefix(line, "// Summary: "):
|
|
enriched.Summary = strings.TrimPrefix(line, "// Summary: ")
|
|
case strings.HasPrefix(line, "// - Source: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ContentType = strings.TrimPrefix(line, "// - Source: ")
|
|
case strings.HasPrefix(line, "// - Item: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ItemName = strings.TrimPrefix(line, "// - Item: ")
|
|
case strings.HasPrefix(line, "// - Account: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.SourceAccount = strings.TrimPrefix(line, "// - Account: ")
|
|
case strings.HasPrefix(line, "// - Artwork: "):
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.ArtworkURL = strings.TrimPrefix(line, "// - Artwork: ")
|
|
case line == "// - Presetable: Yes":
|
|
r.ensureDecodedData(&enriched)
|
|
enriched.DecodedData.IsPresetable = true
|
|
case line == "{" && i > 0:
|
|
// Found start of JSON body
|
|
bodyStart = i
|
|
goto endLoop
|
|
}
|
|
}
|
|
|
|
endLoop:
|
|
// If we didn't find enrichment comments but this is a SCMUDC request,
|
|
// try to parse the JSON body directly
|
|
if !foundSCMUDC && bodyStart > 0 {
|
|
if parsed := r.parseSCMUDBody(lines, bodyStart); parsed != nil {
|
|
return parsed
|
|
}
|
|
}
|
|
|
|
if !foundSCMUDC {
|
|
return nil
|
|
}
|
|
|
|
return &enriched
|
|
}
|
|
|
|
func (r *Recorder) ensureDecodedData(enriched *EnrichedSCMUDCEvent) {
|
|
if enriched.DecodedData == nil {
|
|
enriched.DecodedData = &DecodedContent{}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) parseSCMUDBody(lines []string, bodyStart int) *EnrichedSCMUDCEvent {
|
|
var bodyLines []string
|
|
|
|
inBody := false
|
|
braceCount := 0
|
|
|
|
for i := bodyStart; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if strings.TrimSpace(line) == "{" && !inBody {
|
|
inBody = true
|
|
|
|
bodyLines = append(bodyLines, line)
|
|
braceCount = 1
|
|
} else if inBody {
|
|
bodyLines = append(bodyLines, line)
|
|
|
|
braceCount += strings.Count(line, "{") - strings.Count(line, "}")
|
|
if braceCount == 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(bodyLines) > 0 {
|
|
bodyJSON := strings.Join(bodyLines, "\n")
|
|
return enrichSCMUDCRequest([]byte(bodyJSON))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
|
fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
|
|
|
|
// Check if this is the new format: count-yyyyMMdd-HHMMSS.sss-method.http
|
|
// New format has 4 parts and the second part is 8 digits (yyyyMMdd)
|
|
if len(fnParts) == 4 && len(fnParts[1]) == 8 {
|
|
dateStr := fnParts[1] // yyyyMMdd
|
|
timeStr := fnParts[2] // HHMMSS.sss
|
|
|
|
if len(timeStr) >= 6 {
|
|
date := dateStr[0:4] + "-" + dateStr[4:6] + "-" + dateStr[6:8]
|
|
time := timeStr[0:2] + "-" + timeStr[2:4] + "-" + timeStr[4:]
|
|
|
|
return date + "-" + time
|
|
}
|
|
} else if len(fnParts) >= 5 {
|
|
// Legacy format: count-HH-MM-SS.sss-method.http
|
|
if len(sessionID) < 8 {
|
|
return ""
|
|
}
|
|
|
|
date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
|
|
|
|
return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (r *Recorder) peekStatus(path string) int {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
lines := strings.Split(string(content), "\n")
|
|
for _, line := range lines {
|
|
if !strings.Contains(line, "// Response:") {
|
|
continue
|
|
}
|
|
|
|
trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
|
|
trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
|
|
trimmedLine = strings.TrimSpace(trimmedLine)
|
|
|
|
status := 0
|
|
_, _ = fmt.Sscanf(trimmedLine, "%d", &status)
|
|
|
|
return status
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// DeleteSession deletes a specific recording session.
|
|
func (r *Recorder) DeleteSession(sessionID string) error {
|
|
if sessionID == "" {
|
|
return fmt.Errorf("session ID is required")
|
|
}
|
|
|
|
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
|
|
|
|
return os.RemoveAll(sessionDir)
|
|
}
|
|
|
|
// CleanupSessions deletes all but the most recent keepCount sessions.
|
|
func (r *Recorder) CleanupSessions(keepCount int) error {
|
|
interactionsDir := filepath.Join(r.BaseDir, "interactions")
|
|
|
|
entries, err := os.ReadDir(interactionsDir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
var sessions []os.DirEntry
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
sessions = append(sessions, entry)
|
|
}
|
|
}
|
|
|
|
if len(sessions) <= keepCount {
|
|
return nil
|
|
}
|
|
|
|
// Sort sessions by name (timestamp) descending to keep the newest ones
|
|
// Session ID format: 20260102-150405-PID
|
|
sort.Slice(sessions, func(i, j int) bool {
|
|
return sessions[i].Name() > sessions[j].Name()
|
|
})
|
|
|
|
for i := keepCount; i < len(sessions); i++ {
|
|
sessionDir := filepath.Join(interactionsDir, sessions[i].Name())
|
|
if err := os.RemoveAll(sessionDir); err != nil {
|
|
return fmt.Errorf("failed to delete session %s: %w", sessions[i].Name(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetInteractionContent returns the raw content of a recorded interaction.
|
|
func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
|
|
fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
|
|
return os.ReadFile(fullPath)
|
|
}
|
|
|
|
// ArchiveSession creates a .tar.gz archive of the specified session and writes it to w.
|
|
func (r *Recorder) ArchiveSession(sessionID string, w io.Writer) (err error) {
|
|
sessionDir := filepath.Join(r.BaseDir, "interactions", sessionID)
|
|
|
|
info, statErr := os.Stat(sessionDir)
|
|
if statErr != nil {
|
|
return statErr
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("%s is not a directory", sessionID)
|
|
}
|
|
|
|
gw := gzip.NewWriter(w)
|
|
|
|
defer func() {
|
|
if closeErr := gw.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
tw := tar.NewWriter(gw)
|
|
|
|
defer func() {
|
|
if closeErr := tw.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
}
|
|
}()
|
|
|
|
return filepath.Walk(sessionDir, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
|
|
header, hErr := tar.FileInfoHeader(info, info.Name())
|
|
if hErr != nil {
|
|
return hErr
|
|
}
|
|
|
|
rel, rErr := filepath.Rel(sessionDir, path)
|
|
if rErr != nil {
|
|
return rErr
|
|
}
|
|
|
|
header.Name = rel
|
|
|
|
if whErr := tw.WriteHeader(header); whErr != nil {
|
|
return whErr
|
|
}
|
|
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
f, oErr := os.Open(path)
|
|
if oErr != nil {
|
|
return oErr
|
|
}
|
|
defer f.Close()
|
|
|
|
_, cErr := io.Copy(tw, f)
|
|
|
|
return cErr
|
|
})
|
|
}
|