package logging import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "sync" "time" ) // LogEntry represents one message log record. type LogEntry struct { Timestamp time.Time `json:"timestamp"` Direction string `json:"direction"` // "incoming" or "outgoing" Platform string `json:"platform"` ChannelID string `json:"channel_id"` SenderID string `json:"sender_id"` SenderName string `json:"sender_name"` Content string `json:"content"` ContentType string `json:"content_type"` MessageID string `json:"message_id,omitempty"` Success bool `json:"success"` Error string `json:"error,omitempty"` } // LogListener receives log entries as they are written. type LogListener func(LogEntry) // Logger writes message logs to per-platform JSONL files. type Logger struct { mu sync.Mutex dir string files map[string]*os.File listeners []LogListener } // OnLog registers a listener that is called for every log entry written. // The listener is called synchronously; avoid heavy work in the callback. func (l *Logger) OnLog(fn LogListener) { l.mu.Lock() defer l.mu.Unlock() l.listeners = append(l.listeners, fn) } // NewLogger creates a Logger, ensuring the log directory exists. func NewLogger(dir string) (*Logger, error) { if err := os.MkdirAll(dir, 0750); err != nil { return nil, fmt.Errorf("create log dir: %w", err) } return &Logger{ dir: dir, files: make(map[string]*os.File), }, nil } // Log writes a log entry to the appropriate platform log file. func (l *Logger) Log(entry LogEntry) error { if entry.Timestamp.IsZero() { entry.Timestamp = time.Now() } f, err := l.getOrCreateFile(entry.Platform) if err != nil { return err } data, err := json.Marshal(entry) if err != nil { return fmt.Errorf("marshal log entry: %w", err) } l.mu.Lock() if _, err := f.Write(append(data, '\n')); err != nil { l.mu.Unlock() return fmt.Errorf("write log: %w", err) } if err := f.Sync(); err != nil { l.mu.Unlock() return err } listeners := make([]LogListener, len(l.listeners)) copy(listeners, l.listeners) l.mu.Unlock() // Notify listeners outside the lock. for _, fn := range listeners { fn(entry) } return nil } // ReadLogs reads the last N log entries for a platform, newest first. func (l *Logger) ReadLogs(platform string, limit int) ([]LogEntry, error) { if limit <= 0 || limit > 1000 { limit = 1000 } l.mu.Lock() // Flush any pending writes to the file before reading. if f, ok := l.files[platform]; ok { f.Sync() } l.mu.Unlock() path := filepath.Join(l.dir, platform+".log") f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return []LogEntry{}, nil } return nil, fmt.Errorf("open log file: %w", err) } defer f.Close() // Read all lines, keep only the last `limit`. var lines []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { lines = append(lines, scanner.Text()) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("read log file: %w", err) } // Take last N lines and reverse. start := len(lines) - limit if start < 0 { start = 0 } lines = lines[start:] entries := make([]LogEntry, 0, len(lines)) for i := len(lines) - 1; i >= 0; i-- { var entry LogEntry if err := json.Unmarshal([]byte(lines[i]), &entry); err != nil { continue // Skip corrupted lines. } entries = append(entries, entry) } return entries, nil } // Close closes all open log file handles. func (l *Logger) Close() error { l.mu.Lock() defer l.mu.Unlock() for _, f := range l.files { f.Close() } l.files = make(map[string]*os.File) return nil } func (l *Logger) getOrCreateFile(platform string) (*os.File, error) { l.mu.Lock() defer l.mu.Unlock() if f, ok := l.files[platform]; ok { return f, nil } path := filepath.Join(l.dir, platform+".log") f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) if err != nil { return nil, fmt.Errorf("open log file %s: %w", path, err) } l.files[platform] = f return f, nil }