feat(discord): Add typing heartbeat and fix safety guard for scoped npm packages

This commit is contained in:
Andrew 2026-02-16 17:07:48 +00:00
parent e60728cf96
commit 2dafadf079
5 changed files with 69 additions and 8 deletions

View file

@ -8,3 +8,8 @@ config/
*.md *.md
LICENSE LICENSE
assets/ assets/
docker-compose.yml
workspace/
picoclaw-workspace/
config/picoclaw-workspace/
tmp/

7
.gitignore vendored
View file

@ -44,3 +44,10 @@ tasks/
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
# Local Dev
docker-compose.yml
workspace/
picoclaw-workspace/
config/picoclaw-workspace/
tmp/

View file

@ -24,7 +24,7 @@ RUN make build
FROM alpine:3.23 FROM alpine:3.23
# Install runtime essentials # 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 # Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"sync"
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@ -25,6 +26,7 @@ type DiscordChannel struct {
config config.DiscordConfig config config.DiscordConfig
transcriber *voice.GroqTranscriber transcriber *voice.GroqTranscriber
ctx context.Context ctx context.Context
typingTasks sync.Map
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { 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") 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) runes := []rune(msg.Content)
if len(runes) == 0 { if len(runes) == 0 {
return nil return nil
@ -286,11 +300,33 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return return
} }
if err := c.session.ChannelTyping(m.ChannelID); err != nil { // Start typing indicator heartbeat
logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{ stopTyping := make(chan struct{})
"error": err.Error(), 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) { if !c.IsAllowed(m.Author.ID) {

View file

@ -181,10 +181,23 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "" return ""
} }
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) // Match absolute paths: Unix (starts with / after space/start/quotes) or Windows (X:\)
matches := pathPattern.FindAllString(cmd, -1) // 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) p, err := filepath.Abs(raw)
if err != nil { if err != nil {
continue continue