2026-02-04 11:06:13 +00:00
|
|
|
|
package channels
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"fmt"
|
2026-02-10 09:10:41 +00:00
|
|
|
|
"os"
|
2026-02-19 12:28:58 +00:00
|
|
|
|
"sync"
|
2026-02-10 09:10:41 +00:00
|
|
|
|
"time"
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-11 16:46:48 +00:00
|
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
2026-02-10 09:10:41 +00:00
|
|
|
|
"github.com/sipeed/picoclaw/pkg/voice"
|
2026-02-04 11:06:13 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
const (
|
|
|
|
|
|
transcriptionTimeout = 30 * time.Second
|
|
|
|
|
|
sendTimeout = 10 * time.Second
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
type DiscordChannel struct {
|
|
|
|
|
|
*BaseChannel
|
2026-02-10 09:10:41 +00:00
|
|
|
|
session *discordgo.Session
|
|
|
|
|
|
config config.DiscordConfig
|
|
|
|
|
|
transcriber *voice.GroqTranscriber
|
2026-02-12 04:46:28 +00:00
|
|
|
|
ctx context.Context
|
2026-02-19 12:28:58 +00:00
|
|
|
|
typingMu sync.Mutex
|
|
|
|
|
|
typingStop map[string]chan struct{} // chatID → stop signal
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
|
|
|
|
|
session, err := discordgo.New("Bot " + cfg.Token)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
|
|
|
|
|
|
|
|
|
|
|
|
return &DiscordChannel{
|
|
|
|
|
|
BaseChannel: base,
|
|
|
|
|
|
session: session,
|
|
|
|
|
|
config: cfg,
|
2026-02-10 09:10:41 +00:00
|
|
|
|
transcriber: nil,
|
2026-02-12 04:46:28 +00:00
|
|
|
|
ctx: context.Background(),
|
2026-02-19 12:28:58 +00:00
|
|
|
|
typingStop: make(map[string]chan struct{}),
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 09:10:41 +00:00
|
|
|
|
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
|
|
|
|
c.transcriber = transcriber
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
func (c *DiscordChannel) getContext() context.Context {
|
|
|
|
|
|
if c.ctx == nil {
|
|
|
|
|
|
return context.Background()
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.ctx
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
|
|
|
|
|
logger.InfoC("discord", "Starting Discord bot")
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
c.ctx = ctx
|
2026-02-04 11:06:13 +00:00
|
|
|
|
c.session.AddHandler(c.handleMessage)
|
|
|
|
|
|
|
|
|
|
|
|
if err := c.session.Open(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to open discord session: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
c.setRunning(true)
|
|
|
|
|
|
|
|
|
|
|
|
botUser, err := c.session.User("@me")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to get bot user: %w", err)
|
|
|
|
|
|
}
|
2026-02-12 04:46:28 +00:00
|
|
|
|
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
2026-02-04 11:06:13 +00:00
|
|
|
|
"username": botUser.Username,
|
|
|
|
|
|
"user_id": botUser.ID,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) Stop(ctx context.Context) error {
|
|
|
|
|
|
logger.InfoC("discord", "Stopping Discord bot")
|
|
|
|
|
|
c.setRunning(false)
|
|
|
|
|
|
|
2026-02-19 12:28:58 +00:00
|
|
|
|
// Stop all typing goroutines before closing session
|
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
|
for chatID, stop := range c.typingStop {
|
|
|
|
|
|
close(stop)
|
|
|
|
|
|
delete(c.typingStop, chatID)
|
|
|
|
|
|
}
|
|
|
|
|
|
c.typingMu.Unlock()
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
if err := c.session.Close(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to close discord session: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
2026-02-19 12:28:58 +00:00
|
|
|
|
c.stopTyping(msg.ChatID)
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
if !c.IsRunning() {
|
|
|
|
|
|
return fmt.Errorf("discord bot not running")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
channelID := msg.ChatID
|
|
|
|
|
|
if channelID == "" {
|
|
|
|
|
|
return fmt.Errorf("channel ID is empty")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 05:39:26 +00:00
|
|
|
|
runes := []rune(msg.Content)
|
|
|
|
|
|
if len(runes) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 20:44:25 +00:00
|
|
|
|
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
|
2026-02-16 05:39:26 +00:00
|
|
|
|
|
|
|
|
|
|
for _, chunk := range chunks {
|
|
|
|
|
|
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
// 使用传入的 ctx 进行超时控制
|
|
|
|
|
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
|
go func() {
|
2026-02-16 05:39:26 +00:00
|
|
|
|
_, err := c.session.ChannelMessageSend(channelID, content)
|
2026-02-12 04:46:28 +00:00
|
|
|
|
done <- err
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
|
case err := <-done:
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to send discord message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
case <-sendCtx.Done():
|
|
|
|
|
|
return fmt.Errorf("send message timeout: %w", sendCtx.Err())
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}
|
2026-02-12 04:46:28 +00:00
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
// appendContent 安全地追加内容到现有文本
|
|
|
|
|
|
func appendContent(content, suffix string) string {
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
|
return suffix
|
|
|
|
|
|
}
|
|
|
|
|
|
return content + "\n" + suffix
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
|
|
|
|
|
if m == nil || m.Author == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if m.Author.ID == s.State.User.ID {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
|
|
|
|
|
if !c.IsAllowed(m.Author.ID) {
|
|
|
|
|
|
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
|
|
|
|
|
"user_id": m.Author.ID,
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
senderID := m.Author.ID
|
|
|
|
|
|
senderName := m.Author.Username
|
|
|
|
|
|
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
|
|
|
|
|
senderName += "#" + m.Author.Discriminator
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
content := m.Content
|
2026-02-12 04:46:28 +00:00
|
|
|
|
mediaPaths := make([]string, 0, len(m.Attachments))
|
|
|
|
|
|
localFiles := make([]string, 0, len(m.Attachments))
|
|
|
|
|
|
|
|
|
|
|
|
// 确保临时文件在函数返回时被清理
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
for _, file := range localFiles {
|
|
|
|
|
|
if err := os.Remove(file); err != nil {
|
|
|
|
|
|
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
|
|
|
|
|
|
"file": file,
|
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
|
|
for _, attachment := range m.Attachments {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
|
|
|
|
|
|
if isAudio {
|
|
|
|
|
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
|
|
|
|
|
if localPath != "" {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
localFiles = append(localFiles, localPath)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
|
|
|
|
|
|
transcribedText := ""
|
|
|
|
|
|
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
result, err := c.transcriber.Transcribe(ctx, localPath)
|
2026-02-12 04:46:28 +00:00
|
|
|
|
cancel() // 立即释放context资源,避免在for循环中泄漏
|
|
|
|
|
|
|
2026-02-10 09:10:41 +00:00
|
|
|
|
if err != nil {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
|
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
|
})
|
|
|
|
|
|
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
2026-02-12 04:46:28 +00:00
|
|
|
|
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
|
|
|
|
|
|
"text": result.Text,
|
|
|
|
|
|
})
|
2026-02-10 09:10:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
content = appendContent(content, transcribedText)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
} else {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
|
|
|
|
|
"url": attachment.URL,
|
|
|
|
|
|
"filename": attachment.Filename,
|
|
|
|
|
|
})
|
2026-02-10 09:10:41 +00:00
|
|
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
2026-02-12 04:46:28 +00:00
|
|
|
|
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
2026-02-10 09:10:41 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
2026-02-12 04:46:28 +00:00
|
|
|
|
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if content == "" && len(mediaPaths) == 0 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
|
content = "[media only]"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 12:28:58 +00:00
|
|
|
|
// Start typing after all early returns — guaranteed to have a matching Send()
|
|
|
|
|
|
c.startTyping(m.ChannelID)
|
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
|
logger.DebugCF("discord", "Received message", map[string]any{
|
2026-02-04 11:06:13 +00:00
|
|
|
|
"sender_name": senderName,
|
|
|
|
|
|
"sender_id": senderID,
|
2026-02-11 16:46:48 +00:00
|
|
|
|
"preview": utils.Truncate(content, 50),
|
2026-02-04 11:06:13 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
peerKind := "channel"
|
|
|
|
|
|
peerID := m.ChannelID
|
|
|
|
|
|
if m.GuildID == "" {
|
|
|
|
|
|
peerKind = "direct"
|
|
|
|
|
|
peerID = senderID
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
|
metadata := map[string]string{
|
|
|
|
|
|
"message_id": m.ID,
|
|
|
|
|
|
"user_id": senderID,
|
|
|
|
|
|
"username": m.Author.Username,
|
|
|
|
|
|
"display_name": senderName,
|
|
|
|
|
|
"guild_id": m.GuildID,
|
|
|
|
|
|
"channel_id": m.ChannelID,
|
|
|
|
|
|
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
|
"peer_kind": peerKind,
|
|
|
|
|
|
"peer_id": peerID,
|
2026-02-04 11:06:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
|
|
|
|
|
}
|
2026-02-10 09:10:41 +00:00
|
|
|
|
|
2026-02-19 12:28:58 +00:00
|
|
|
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
|
|
|
|
|
// It stops any existing typing loop for that chatID before starting a new one.
|
|
|
|
|
|
func (c *DiscordChannel) startTyping(chatID string) {
|
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
|
// Stop existing loop for this chatID if any
|
|
|
|
|
|
if stop, ok := c.typingStop[chatID]; ok {
|
|
|
|
|
|
close(stop)
|
|
|
|
|
|
}
|
|
|
|
|
|
stop := make(chan struct{})
|
|
|
|
|
|
c.typingStop[chatID] = stop
|
|
|
|
|
|
c.typingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
|
|
|
|
|
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
|
|
|
|
|
}
|
|
|
|
|
|
ticker := time.NewTicker(8 * time.Second)
|
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
timeout := time.After(5 * time.Minute)
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-stop:
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-timeout:
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
|
|
|
|
|
logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// stopTyping stops the typing indicator loop for the given chatID.
|
|
|
|
|
|
func (c *DiscordChannel) stopTyping(chatID string) {
|
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
|
defer c.typingMu.Unlock()
|
|
|
|
|
|
if stop, ok := c.typingStop[chatID]; ok {
|
|
|
|
|
|
close(stop)
|
|
|
|
|
|
delete(c.typingStop, chatID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 09:10:41 +00:00
|
|
|
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
2026-02-12 04:46:28 +00:00
|
|
|
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
|
|
|
|
LoggerPrefix: "discord",
|
|
|
|
|
|
})
|
2026-02-10 09:10:41 +00:00
|
|
|
|
}
|