feat(discord): Add typing heartbeat and fix safety guard for scoped npm packages
This commit is contained in:
parent
e60728cf96
commit
2dafadf079
5 changed files with 69 additions and 8 deletions
|
|
@ -8,3 +8,8 @@ config/
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
assets/
|
assets/
|
||||||
|
docker-compose.yml
|
||||||
|
workspace/
|
||||||
|
picoclaw-workspace/
|
||||||
|
config/picoclaw-workspace/
|
||||||
|
tmp/
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue