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
LICENSE
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:
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
# 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 \

View file

@ -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) {

View file

@ -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