Multiple packages had their own private truncate implementations: - channels/telegram.go: truncateString (byte-based, no "...") - channels/dingtalk.go: truncateStringDingTalk (byte-based, no "...") - voice/transcriber.go: truncateText (byte-based, with "...") All three are functionally equivalent to the existing utils.Truncate, which already handles rune-safe truncation and appends "..." correctly. Replace all private copies with utils.Truncate and delete the dead code.
248 lines
5.9 KiB
Go
248 lines
5.9 KiB
Go
package channels
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
"github.com/sipeed/picoclaw/pkg/voice"
|
|
)
|
|
|
|
type DiscordChannel struct {
|
|
*BaseChannel
|
|
session *discordgo.Session
|
|
config config.DiscordConfig
|
|
transcriber *voice.GroqTranscriber
|
|
}
|
|
|
|
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,
|
|
transcriber: nil,
|
|
}, nil
|
|
}
|
|
|
|
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
|
c.transcriber = transcriber
|
|
}
|
|
|
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
|
logger.InfoC("discord", "Starting Discord bot")
|
|
|
|
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)
|
|
}
|
|
logger.InfoCF("discord", "Discord bot connected", map[string]interface{}{
|
|
"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)
|
|
|
|
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 {
|
|
if !c.IsRunning() {
|
|
return fmt.Errorf("discord bot not running")
|
|
}
|
|
|
|
channelID := msg.ChatID
|
|
if channelID == "" {
|
|
return fmt.Errorf("channel ID is empty")
|
|
}
|
|
|
|
message := msg.Content
|
|
|
|
if _, err := c.session.ChannelMessageSend(channelID, message); err != nil {
|
|
return fmt.Errorf("failed to send discord message: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
senderID := m.Author.ID
|
|
senderName := m.Author.Username
|
|
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
|
senderName += "#" + m.Author.Discriminator
|
|
}
|
|
|
|
content := m.Content
|
|
mediaPaths := []string{}
|
|
|
|
for _, attachment := range m.Attachments {
|
|
isAudio := isAudioFile(attachment.Filename, attachment.ContentType)
|
|
|
|
if isAudio {
|
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
|
if localPath != "" {
|
|
mediaPaths = append(mediaPaths, localPath)
|
|
|
|
transcribedText := ""
|
|
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := c.transcriber.Transcribe(ctx, localPath)
|
|
if err != nil {
|
|
log.Printf("Voice transcription failed: %v", err)
|
|
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", localPath)
|
|
} else {
|
|
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
|
log.Printf("Audio transcribed successfully: %s", result.Text)
|
|
}
|
|
} else {
|
|
transcribedText = fmt.Sprintf("[audio: %s]", localPath)
|
|
}
|
|
|
|
if content != "" {
|
|
content += "\n"
|
|
}
|
|
content += transcribedText
|
|
} else {
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
|
if content != "" {
|
|
content += "\n"
|
|
}
|
|
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
|
}
|
|
} else {
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
|
if content != "" {
|
|
content += "\n"
|
|
}
|
|
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
|
}
|
|
}
|
|
|
|
if content == "" && len(mediaPaths) == 0 {
|
|
return
|
|
}
|
|
|
|
if content == "" {
|
|
content = "[media only]"
|
|
}
|
|
|
|
logger.DebugCF("discord", "Received message", map[string]interface{}{
|
|
"sender_name": senderName,
|
|
"sender_id": senderID,
|
|
"preview": utils.Truncate(content, 50),
|
|
})
|
|
|
|
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 == ""),
|
|
}
|
|
|
|
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
|
}
|
|
|
|
func isAudioFile(filename, contentType string) bool {
|
|
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
|
|
audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
|
|
|
|
for _, ext := range audioExtensions {
|
|
if strings.HasSuffix(strings.ToLower(filename), ext) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
for _, audioType := range audioTypes {
|
|
if strings.HasPrefix(strings.ToLower(contentType), audioType) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
|
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
|
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
|
log.Printf("Failed to create media directory: %v", err)
|
|
return ""
|
|
}
|
|
|
|
localPath := filepath.Join(mediaDir, filename)
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
log.Printf("Failed to download attachment: %v", err)
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("Failed to download attachment, status: %d", resp.StatusCode)
|
|
return ""
|
|
}
|
|
|
|
out, err := os.Create(localPath)
|
|
if err != nil {
|
|
log.Printf("Failed to create file: %v", err)
|
|
return ""
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, resp.Body)
|
|
if err != nil {
|
|
log.Printf("Failed to write file: %v", err)
|
|
return ""
|
|
}
|
|
|
|
log.Printf("Attachment downloaded successfully to: %s", localPath)
|
|
return localPath
|
|
}
|