Files
goclaw/internal/channels/discord/discord.go
T
Viet TranandClaude Opus 4.6 f3f4c67b36 Initial commit: GoClaw AI agent gateway
Multi-agent AI gateway with WebSocket RPC, HTTP API, and messaging channel integrations.
Go port of OpenClaw with multi-tenant PostgreSQL, per-user isolation, security hardening,
and production observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:58:07 +07:00

230 lines
5.4 KiB
Go

package discord
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/bwmarrin/discordgo"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// Channel connects to Discord via the Bot API using gateway events.
type Channel struct {
*channels.BaseChannel
session *discordgo.Session
config config.DiscordConfig
botUserID string // populated on start
placeholders sync.Map // channelID string → messageID string
}
// New creates a new Discord channel from config.
func New(cfg config.DiscordConfig, msgBus *bus.MessageBus) (*Channel, error) {
session, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, fmt.Errorf("create discord session: %w", err)
}
// Request necessary intents
session.Identify.Intents = discordgo.IntentsGuildMessages |
discordgo.IntentsDirectMessages |
discordgo.IntentsMessageContent
base := channels.NewBaseChannel("discord", msgBus, cfg.AllowFrom)
return &Channel{
BaseChannel: base,
session: session,
config: cfg,
}, nil
}
// Start opens the Discord gateway connection and begins receiving events.
func (c *Channel) Start(_ context.Context) error {
slog.Info("starting discord bot")
c.session.AddHandler(c.handleMessage)
if err := c.session.Open(); err != nil {
return fmt.Errorf("open discord session: %w", err)
}
// Fetch bot identity
user, err := c.session.User("@me")
if err != nil {
c.session.Close()
return fmt.Errorf("fetch discord bot identity: %w", err)
}
c.botUserID = user.ID
c.SetRunning(true)
slog.Info("discord bot connected", "username", user.Username, "id", user.ID)
return nil
}
// Stop closes the Discord gateway connection.
func (c *Channel) Stop(_ context.Context) error {
slog.Info("stopping discord bot")
c.SetRunning(false)
return c.session.Close()
}
// Send delivers an outbound message to a Discord channel.
func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
}
channelID := msg.ChatID
if channelID == "" {
return fmt.Errorf("empty chat ID for discord send")
}
content := msg.Content
// Try to edit the placeholder "Thinking..." message
if pID, ok := c.placeholders.Load(channelID); ok {
c.placeholders.Delete(channelID)
msgID := pID.(string)
// Discord has a 2000-char message limit
editContent := content
if len(editContent) > 2000 {
editContent = editContent[:1997] + "..."
}
if _, err := c.session.ChannelMessageEdit(channelID, msgID, editContent); err == nil {
return nil
}
// Fall through to send new message if edit fails
}
// Send as new message(s), chunking if needed
return c.sendChunked(channelID, content)
}
// sendChunked sends a message, splitting into multiple messages if over 2000 chars.
func (c *Channel) sendChunked(channelID, content string) error {
const maxLen = 2000
for len(content) > 0 {
chunk := content
if len(chunk) > maxLen {
// Try to break at a newline
cutAt := maxLen
if idx := lastIndexByte(content[:maxLen], '\n'); idx > maxLen/2 {
cutAt = idx + 1
}
chunk = content[:cutAt]
content = content[cutAt:]
} else {
content = ""
}
if _, err := c.session.ChannelMessageSend(channelID, chunk); err != nil {
return fmt.Errorf("send discord message: %w", err)
}
}
return nil
}
// handleMessage processes incoming Discord messages.
func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) {
// Ignore bot's own messages
if m.Author == nil || m.Author.ID == c.botUserID {
return
}
// Ignore bot messages
if m.Author.Bot {
return
}
senderID := m.Author.ID
senderName := m.Author.Username
channelID := m.ChannelID
isDM := m.GuildID == ""
// DM/Group policy check (matching TS channel policy pattern)
peerKind := "group"
if isDM {
peerKind = "direct"
}
if !c.CheckPolicy(peerKind, c.config.DMPolicy, c.config.GroupPolicy, senderID) {
slog.Debug("discord message rejected by policy",
"user_id", senderID,
"username", senderName,
"peer_kind", peerKind,
)
return
}
// Check allowlist (for "open" policy, still apply allowlist if configured)
if !c.IsAllowed(senderID) {
slog.Debug("discord message rejected by allowlist",
"user_id", senderID,
"username", senderName,
)
return
}
// Build content
content := m.Content
// Append attachment URLs
for _, att := range m.Attachments {
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[attachment: %s]", att.URL)
}
if content == "" {
content = "[empty message]"
}
slog.Debug("discord message received",
"sender_id", senderID,
"channel_id", channelID,
"is_dm", isDM,
"preview", channels.Truncate(content, 50),
)
// Send typing indicator
_ = c.session.ChannelTyping(channelID)
// Send placeholder "Thinking..." message
placeholder, err := c.session.ChannelMessageSend(channelID, "Thinking...")
if err == nil {
c.placeholders.Store(channelID, placeholder.ID)
}
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
"username": senderName,
"guild_id": m.GuildID,
"channel_id": channelID,
"is_dm": fmt.Sprintf("%t", isDM),
}
c.HandleMessage(senderID, channelID, content, nil, metadata, peerKind)
}
// lastIndexByte returns the last index of byte c in s, or -1.
func lastIndexByte(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}