fix: improve error handling, add godoc, and fix bugs across core packages
- pkg/pid: log os.Remove errors instead of ignoring, fix Windows syscall - pkg/session: add godoc to all exported methods, handle os.MkdirAll error - pkg/logger: fix nil err in Errorf, handle logFile.Close errors, add godoc - pkg/cron: add input validation in AddJob, add godoc to all exported methods - pkg/config: log SaveConfig errors during migration, add godoc Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84e42d6904
commit
dde74c83aa
11 changed files with 95 additions and 37 deletions
|
|
@ -340,6 +340,7 @@ type WhatsAppConfig struct {
|
||||||
UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
|
UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
|
||||||
SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
|
SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1027,7 +1028,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
|
return nil, fmt.Errorf("failed to load existing security config: %w", secErr)
|
||||||
}
|
}
|
||||||
defer func(cfg *Config) {
|
defer func(cfg *Config) {
|
||||||
_ = SaveConfig(path, cfg)
|
if saveErr := SaveConfig(path, cfg); saveErr != nil {
|
||||||
|
logger.ErrorF("failed to save config after v0 migration", map[string]any{"error": saveErr})
|
||||||
|
}
|
||||||
}(cfg)
|
}(cfg)
|
||||||
case 1:
|
case 1:
|
||||||
// V1→V2 migration: infer Enabled and migrate channel config fields
|
// V1→V2 migration: infer Enabled and migrate channel config fields
|
||||||
|
|
@ -1061,7 +1064,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(cfg *Config) {
|
defer func(cfg *Config) {
|
||||||
_ = SaveConfig(path, cfg)
|
if saveErr := SaveConfig(path, cfg); saveErr != nil {
|
||||||
|
logger.ErrorF("failed to save config after v1 migration", map[string]any{"error": saveErr})
|
||||||
|
}
|
||||||
}(cfg)
|
}(cfg)
|
||||||
logger.InfoF(
|
logger.InfoF(
|
||||||
"config migrate success",
|
"config migrate success",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ func canonicalGatewayLogLevel(level logger.LogLevel) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeGatewayLogLevel maps a user-provided log level string to a valid zerolog level.
|
||||||
func normalizeGatewayLogLevel(logLevel string) string {
|
func normalizeGatewayLogLevel(logLevel string) string {
|
||||||
if level, ok := logger.ParseLevel(logLevel); ok {
|
if level, ok := logger.ParseLevel(logLevel); ok {
|
||||||
return canonicalGatewayLogLevel(level)
|
return canonicalGatewayLogLevel(level)
|
||||||
|
|
@ -61,7 +62,10 @@ func ResolveGatewayLogLevel(path string) string {
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = json.Unmarshal(data, &cfg)
|
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
|
||||||
|
// Malformed config — fall through to default log level.
|
||||||
|
_ = jsonErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
|
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -68,6 +69,8 @@ type CronService struct {
|
||||||
gronx *gronx.Gronx
|
gronx *gronx.Gronx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewCronService creates a cron service that persists jobs to storePath.
|
||||||
|
// onJob is called when a job fires; it may be nil if SetOnJob is used later.
|
||||||
func NewCronService(storePath string, onJob JobHandler) *CronService {
|
func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||||
cs := &CronService{
|
cs := &CronService{
|
||||||
storePath: storePath,
|
storePath: storePath,
|
||||||
|
|
@ -80,6 +83,7 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||||
return cs
|
return cs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start begins the cron run loop in a background goroutine.
|
||||||
func (cs *CronService) Start() error {
|
func (cs *CronService) Start() error {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -107,6 +111,7 @@ func (cs *CronService) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop signals the run loop to exit and waits for it to finish.
|
||||||
func (cs *CronService) Stop() {
|
func (cs *CronService) Stop() {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -365,12 +370,14 @@ func (cs *CronService) getNextWakeMS() *int64 {
|
||||||
return nextWake
|
return nextWake
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load reads persisted jobs from disk, replacing the in-memory store.
|
||||||
func (cs *CronService) Load() error {
|
func (cs *CronService) Load() error {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
return cs.loadStore()
|
return cs.loadStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOnJob replaces the callback invoked when a job fires.
|
||||||
func (cs *CronService) SetOnJob(handler JobHandler) {
|
func (cs *CronService) SetOnJob(handler JobHandler) {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -404,12 +411,18 @@ func (cs *CronService) saveStoreUnsafe() error {
|
||||||
return fileutil.WriteFileAtomic(cs.storePath, data, 0o600)
|
return fileutil.WriteFileAtomic(cs.storePath, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddJob creates and persists a new cron job. Returns an error if the
|
||||||
|
// schedule expression is invalid or required fields are empty.
|
||||||
func (cs *CronService) AddJob(
|
func (cs *CronService) AddJob(
|
||||||
name string,
|
name string,
|
||||||
schedule CronSchedule,
|
schedule CronSchedule,
|
||||||
message string,
|
message string,
|
||||||
channel, to string,
|
channel, to string,
|
||||||
) (*CronJob, error) {
|
) (*CronJob, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, fmt.Errorf("job name must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -447,6 +460,7 @@ func (cs *CronService) AddJob(
|
||||||
return &job, nil
|
return &job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateJob replaces a job's definition and persists the change.
|
||||||
func (cs *CronService) UpdateJob(job *CronJob) error {
|
func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -464,6 +478,7 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||||
return fmt.Errorf("job not found")
|
return fmt.Errorf("job not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveJob deletes a job by ID and returns true if it was found.
|
||||||
func (cs *CronService) RemoveJob(jobID string) bool {
|
func (cs *CronService) RemoveJob(jobID string) bool {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -493,6 +508,7 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableJob toggles a job's enabled state and recomputes its next run time.
|
||||||
func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
defer cs.mu.Unlock()
|
defer cs.mu.Unlock()
|
||||||
|
|
@ -522,6 +538,7 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListJobs returns a snapshot of all jobs, optionally including disabled ones.
|
||||||
func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
defer cs.mu.RUnlock()
|
||||||
|
|
@ -540,6 +557,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
|
||||||
return enabled
|
return enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status returns a summary of the cron service state for diagnostics.
|
||||||
func (cs *CronService) Status() map[string]any {
|
func (cs *CronService) Status() map[string]any {
|
||||||
cs.mu.RLock()
|
cs.mu.RLock()
|
||||||
defer cs.mu.RUnlock()
|
defer cs.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ func formatFieldValue(i any) string {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLevel sets the global minimum log level.
|
||||||
func SetLevel(level LogLevel) {
|
func SetLevel(level LogLevel) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
@ -117,12 +118,14 @@ func SetLevel(level LogLevel) {
|
||||||
zerolog.SetGlobalLevel(level)
|
zerolog.SetGlobalLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConsoleLevel adjusts the log level for console output only.
|
||||||
func SetConsoleLevel(level LogLevel) {
|
func SetConsoleLevel(level LogLevel) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
logger = logger.Level(level)
|
logger = logger.Level(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableConsole silences console output by redirecting it to io.Discard.
|
||||||
func DisableConsole() {
|
func DisableConsole() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
@ -130,6 +133,7 @@ func DisableConsole() {
|
||||||
logger = logger.Output(io.MultiWriter(writers...))
|
logger = logger.Output(io.MultiWriter(writers...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnableConsole restores console output after a DisableConsole call.
|
||||||
func EnableConsole() {
|
func EnableConsole() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
@ -137,6 +141,7 @@ func EnableConsole() {
|
||||||
logger = logger.Output(io.MultiWriter(writers...))
|
logger = logger.Output(io.MultiWriter(writers...))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLevel returns the current global log level.
|
||||||
func GetLevel() LogLevel {
|
func GetLevel() LogLevel {
|
||||||
mu.RLock()
|
mu.RLock()
|
||||||
defer mu.RUnlock()
|
defer mu.RUnlock()
|
||||||
|
|
@ -186,15 +191,18 @@ func EnableFileLogging(filePath string) error {
|
||||||
return fmt.Errorf("failed to open log file: %w", err)
|
return fmt.Errorf("failed to open log file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close old file if exists
|
// Close old file if exists; log but don't fail on close error
|
||||||
|
// since we're replacing it with a new file.
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
logFile.Close()
|
if closeErr := logFile.Close(); closeErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: failed to close old log file: %v\n", closeErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logFile = newFile
|
logFile = newFile
|
||||||
|
|
||||||
if len(writers) != 1 {
|
if len(writers) != 1 {
|
||||||
return fmt.Errorf("failed to configure file logging: %w", err)
|
return fmt.Errorf("invalid writers state: expected 1 writer, got %d", len(writers))
|
||||||
}
|
}
|
||||||
|
|
||||||
writers = append(writers, logFile)
|
writers = append(writers, logFile)
|
||||||
|
|
@ -203,12 +211,15 @@ func EnableFileLogging(filePath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisableFileLogging closes the log file and removes it from the output writers.
|
||||||
func DisableFileLogging() {
|
func DisableFileLogging() {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if logFile != nil {
|
if logFile != nil {
|
||||||
logFile.Close()
|
if closeErr := logFile.Close(); closeErr != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", closeErr)
|
||||||
|
}
|
||||||
logFile = nil
|
logFile = nil
|
||||||
}
|
}
|
||||||
if len(writers) > 1 {
|
if len(writers) > 1 {
|
||||||
|
|
@ -217,6 +228,8 @@ func DisableFileLogging() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigureFromEnv reads PICOCLAW_LOG_FILE and PICOCLAW_LOG_LEVEL from the
|
||||||
|
// environment and applies them to the logger configuration.
|
||||||
func ConfigureFromEnv() {
|
func ConfigureFromEnv() {
|
||||||
if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" {
|
if logFile := os.Getenv("PICOCLAW_LOG_FILE"); logFile != "" {
|
||||||
if strings.HasPrefix(logFile, "~/") {
|
if strings.HasPrefix(logFile, "~/") {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
|
||||||
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
|
logger.Warnf("not running (PID: %d) so will remove the pid file: %s", data.PID, pidPath)
|
||||||
}
|
}
|
||||||
// Stale PID file; process no longer exists → clean up.
|
// Stale PID file; process no longer exists → clean up.
|
||||||
os.Remove(pidPath)
|
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.Warnf("failed to remove stale pid file %s: %v", pidPath, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data := &PidFileData{
|
data := &PidFileData{
|
||||||
|
|
@ -91,7 +93,7 @@ func WritePidFile(homePath, host string, port int) (*PidFileData, error) {
|
||||||
return nil, fmt.Errorf("failed to write pid file: %w", err)
|
return nil, fmt.Errorf("failed to write pid file: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp, pidPath); err != nil {
|
if err := os.Rename(tmp, pidPath); err != nil {
|
||||||
os.Remove(tmp)
|
_ = os.Remove(tmp) // best-effort cleanup of temp file
|
||||||
return nil, fmt.Errorf("failed to rename pid file: %w", err)
|
return nil, fmt.Errorf("failed to rename pid file: %w", err)
|
||||||
}
|
}
|
||||||
logger.Debugf("wrote pid file: %s success", pidPath)
|
logger.Debugf("wrote pid file: %s success", pidPath)
|
||||||
|
|
@ -115,7 +117,9 @@ func ReadPidFileWithCheck(homePath string) *PidFileData {
|
||||||
|
|
||||||
if !isProcessRunning(data.PID) {
|
if !isProcessRunning(data.PID) {
|
||||||
logger.Debugf("process not running, remove pid file: %s", pidPath)
|
logger.Debugf("process not running, remove pid file: %s", pidPath)
|
||||||
os.Remove(pidPath)
|
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.Warnf("failed to remove stale pid file %s: %v", pidPath, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +141,9 @@ func RemovePidFile(homePath string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("remove pid file: %s", pidPath)
|
logger.Infof("remove pid file: %s", pidPath)
|
||||||
os.Remove(pidPath)
|
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.Warnf("failed to remove pid file %s: %v", pidPath, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPidFileUnlocked reads the PID file without acquiring the lock.
|
// readPidFileUnlocked reads the PID file without acquiring the lock.
|
||||||
|
|
|
||||||
|
|
@ -23,19 +23,19 @@ func isProcessRunning(pid int) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
handle, _, err := procOpenProcess.Call(
|
handle, _, _ := procOpenProcess.Call(
|
||||||
uintptr(processQueryLimitedInformation),
|
uintptr(processQueryLimitedInformation),
|
||||||
0,
|
0,
|
||||||
uintptr(pid),
|
uintptr(pid),
|
||||||
)
|
)
|
||||||
if handle == 0 || err != nil {
|
if handle == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer procCloseHandle.Call(handle)
|
defer procCloseHandle.Call(handle) //nolint:errcheck
|
||||||
|
|
||||||
var exitCode uint32
|
var exitCode uint32
|
||||||
ret, _, err := procGetExitCodeProcess.Call(handle, uintptr(unsafe.Pointer(&exitCode)))
|
ret, _, _ := procGetExitCodeProcess.Call(handle, uintptr(unsafe.Pointer(&exitCode)))
|
||||||
if ret == 0 || err != nil {
|
if ret == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return exitCode == stillActive
|
return exitCode == stillActive
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,21 @@ func NewJSONLBackend(store memory.Store) *JSONLBackend {
|
||||||
return &JSONLBackend{store: store}
|
return &JSONLBackend{store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddMessage appends a text message to the session (fire-and-forget).
|
||||||
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
|
func (b *JSONLBackend) AddMessage(sessionKey, role, content string) {
|
||||||
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
|
if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil {
|
||||||
log.Printf("session: add message: %v", err)
|
log.Printf("session: add message: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddFullMessage appends a complete message (with tool calls, reasoning, etc.) to the session.
|
||||||
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
|
func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||||
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
|
if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil {
|
||||||
log.Printf("session: add full message: %v", err)
|
log.Printf("session: add full message: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHistory returns the message history for a session, or an empty slice on error.
|
||||||
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||||
msgs, err := b.store.GetHistory(context.Background(), key)
|
msgs, err := b.store.GetHistory(context.Background(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -41,6 +44,7 @@ func (b *JSONLBackend) GetHistory(key string) []providers.Message {
|
||||||
return msgs
|
return msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSummary returns the session summary, or empty string on error.
|
||||||
func (b *JSONLBackend) GetSummary(key string) string {
|
func (b *JSONLBackend) GetSummary(key string) string {
|
||||||
summary, err := b.store.GetSummary(context.Background(), key)
|
summary, err := b.store.GetSummary(context.Background(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -50,18 +54,21 @@ func (b *JSONLBackend) GetSummary(key string) string {
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSummary replaces the session summary (fire-and-forget).
|
||||||
func (b *JSONLBackend) SetSummary(key, summary string) {
|
func (b *JSONLBackend) SetSummary(key, summary string) {
|
||||||
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
|
if err := b.store.SetSummary(context.Background(), key, summary); err != nil {
|
||||||
log.Printf("session: set summary: %v", err)
|
log.Printf("session: set summary: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHistory replaces the full message history for a session (fire-and-forget).
|
||||||
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
|
func (b *JSONLBackend) SetHistory(key string, history []providers.Message) {
|
||||||
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
|
if err := b.store.SetHistory(context.Background(), key, history); err != nil {
|
||||||
log.Printf("session: set history: %v", err)
|
log.Printf("session: set history: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TruncateHistory keeps only the last keepLast messages in a session (fire-and-forget).
|
||||||
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
func (b *JSONLBackend) TruncateHistory(key string, keepLast int) {
|
||||||
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
|
if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil {
|
||||||
log.Printf("session: truncate history: %v", err)
|
log.Printf("session: truncate history: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -32,13 +33,17 @@ func NewSessionManager(storage string) *SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if storage != "" {
|
if storage != "" {
|
||||||
os.MkdirAll(storage, 0o700)
|
if err := os.MkdirAll(storage, 0o700); err != nil {
|
||||||
|
log.Printf("session: failed to create storage dir %s: %v", storage, err)
|
||||||
|
}
|
||||||
sm.loadSessions()
|
sm.loadSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
return sm
|
return sm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrCreate returns the session for key, creating a new empty one if it doesn't exist.
|
||||||
|
// The returned session is never nil.
|
||||||
func (sm *SessionManager) GetOrCreate(key string) *Session {
|
func (sm *SessionManager) GetOrCreate(key string) *Session {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,13 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request,
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
// Read a small amount for the error message.
|
// Read a small amount for the error message; ignore read errors
|
||||||
|
// since the HTTP status itself is the primary error signal.
|
||||||
errBody := make([]byte, 512)
|
errBody := make([]byte, 512)
|
||||||
n, _ := io.ReadFull(resp.Body, errBody)
|
n, readErr := io.ReadFull(resp.Body, errBody)
|
||||||
|
if readErr != nil && readErr != io.ErrUnexpectedEOF && readErr != io.EOF {
|
||||||
|
return "", fmt.Errorf("HTTP %d (body unreadable: %w)", resp.StatusCode, readErr)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n]))
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(errBody[:n]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,10 +60,12 @@ func DownloadToFile(ctx context.Context, client *http.Client, req *http.Request,
|
||||||
"path": tmpPath,
|
"path": tmpPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup helper — removes the temp file on any error.
|
// Cleanup helper — best-effort removal of the temp file on error.
|
||||||
|
// Close/Remove errors are intentionally ignored since this only runs
|
||||||
|
// on failure paths where the primary error is already captured.
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
_ = tmpFile.Close()
|
tmpFile.Close()
|
||||||
_ = os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optionally limit the download size.
|
// Optionally limit the download size.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ var skipTags = map[string]bool{
|
||||||
"nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true,
|
"nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlikelyKeywords is allocated once at package level to avoid repeated
|
||||||
|
// allocation on every call to isUnlikelyNode.
|
||||||
|
var unlikelyKeywords = []string{
|
||||||
|
"menu", "nav", "footer", "sidebar", "cookie", "banner",
|
||||||
|
"sponsor", "advert", "popup", "modal", "newsletter", "share", "social",
|
||||||
|
}
|
||||||
|
|
||||||
func isSafeHref(href string) bool {
|
func isSafeHref(href string) bool {
|
||||||
lower := strings.ToLower(strings.TrimSpace(href))
|
lower := strings.ToLower(strings.TrimSpace(href))
|
||||||
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") ||
|
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") ||
|
||||||
|
|
@ -82,21 +89,6 @@ func isUnlikelyNode(n *html.Node) bool {
|
||||||
strings.Contains(classId, "content") {
|
strings.Contains(classId, "content") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
unlikelyKeywords := []string{
|
|
||||||
"menu",
|
|
||||||
"nav",
|
|
||||||
"footer",
|
|
||||||
"sidebar",
|
|
||||||
"cookie",
|
|
||||||
"banner",
|
|
||||||
"sponsor",
|
|
||||||
"advert",
|
|
||||||
"popup",
|
|
||||||
"modal",
|
|
||||||
"newsletter",
|
|
||||||
"share",
|
|
||||||
"social",
|
|
||||||
}
|
|
||||||
for _, keyword := range unlikelyKeywords {
|
for _, keyword := range unlikelyKeywords {
|
||||||
if strings.Contains(classId, keyword) {
|
if strings.Contains(classId, keyword) {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import (
|
||||||
|
|
||||||
var audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
var audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
||||||
|
|
||||||
|
// AudioFormat returns the audio format (e.g. "mp3", "wav") for the given file path
|
||||||
|
// based on its extension, or an error if the format is unsupported.
|
||||||
func AudioFormat(path string) (string, error) {
|
func AudioFormat(path string) (string, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
for _, supportedExt := range audioExtensions {
|
for _, supportedExt := range audioExtensions {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue