diff --git a/.dockerignore b/.dockerignore index d632da5ea..285cf113e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,8 @@ config/ *.md LICENSE assets/ +docker-compose.yml +workspace/ +picoclaw-workspace/ +config/picoclaw-workspace/ +tmp/ diff --git a/.gitignore b/.gitignore index ce30d749e..cbc6a4e10 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,10 @@ tasks/ # Added by goreleaser init: dist/ + +# Local Dev +docker-compose.yml +workspace/ +picoclaw-workspace/ +config/picoclaw-workspace/ +tmp/ diff --git a/Dockerfile b/Dockerfile index 08a768b77..d3b279f69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ RUN make build FROM alpine:3.23 # Install runtime essentials -RUN apk add --no-cache ca-certificates tzdata curl +RUN apk add --no-cache ca-certificates tzdata curl nodejs npm # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 51b12e8f1..b290c75e3 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sync" "time" "github.com/bwmarrin/discordgo" @@ -25,6 +26,7 @@ type DiscordChannel struct { config config.DiscordConfig transcriber *voice.GroqTranscriber ctx context.Context + typingTasks sync.Map } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -100,6 +102,18 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } + // Stop typing indicator heartbeat for this channel + if stop, ok := c.typingTasks.Load(channelID); ok { + if stopChan, ok := stop.(chan struct{}); ok { + select { + case <-stopChan: + default: + close(stopChan) + } + } + c.typingTasks.Delete(channelID) + } + runes := []rune(msg.Content) if len(runes) == 0 { return nil @@ -286,11 +300,33 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - if err := c.session.ChannelTyping(m.ChannelID); err != nil { - logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{ - "error": err.Error(), - }) + // Start typing indicator heartbeat + stopTyping := make(chan struct{}) + if old, ok := c.typingTasks.Load(m.ChannelID); ok { + if oldChan, ok := old.(chan struct{}); ok { + select { + case <-oldChan: + default: + close(oldChan) + } + } } + c.typingTasks.Store(m.ChannelID, stopTyping) + + go func(chID string, stop chan struct{}) { + // Send initial typing + _ = c.session.ChannelTyping(chID) + ticker := time.NewTicker(7 * time.Second) // Discord typing lasts ~10s + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + _ = c.session.ChannelTyping(chID) + } + } + }(m.ChannelID, stopTyping) // 检查白名单,避免为被拒绝的用户下载附件和转录 if !c.IsAllowed(m.Author.ID) { diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 1ca3fc35a..6c070df40 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -181,10 +181,23 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "" } - pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) - matches := pathPattern.FindAllString(cmd, -1) + // Match absolute paths: Unix (starts with / after space/start/quotes) or Windows (X:\) + // This regex is careful not to match scoped packages like @mastra/core + pathPattern := regexp.MustCompile(`(?:\s|^|["'|&;])(/[^\s\"'|&;]+)|([A-Za-z]:\\[^\\\"'|&;]+)`) + matches := pathPattern.FindAllStringSubmatch(cmd, -1) + + for _, match := range matches { + raw := "" + if match[1] != "" { + raw = match[1] // Unix path + } else if match[2] != "" { + raw = match[2] // Windows path + } + + if raw == "" { + continue + } - for _, raw := range matches { p, err := filepath.Abs(raw) if err != nil { continue