Files
agent-desk-ng/internal/services/discord_inbound_service.go

177 lines
4.8 KiB
Go

package services
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"log/slog"
"strings"
"agent-desk/internal/discord"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/openidentity"
)
var DiscordInboundService = newDiscordInboundService()
func newDiscordInboundService() *discordInboundService {
return &discordInboundService{}
}
type discordInboundService struct{}
// HandleWebhook processes an incoming webhook or gateway payload from Discord.
func (s *discordInboundService) HandleWebhook(ctx context.Context, channelID string, secretHeader string, rawPayload []byte) error {
channelID = strings.TrimSpace(channelID)
var channel *models.Channel
if channelID != "" {
channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeDiscord, enums.StatusOk)
}
if channel == nil {
channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeDiscord, enums.StatusOk)
}
if channel == nil {
return errorsx.InvalidParam("discord channel not found or disabled")
}
cfg, err := ChannelService.ParseDiscordChannelConfig(channel.ConfigJSON)
if err != nil || cfg == nil {
return errorsx.InvalidParam("discord channel config invalid")
}
// Compared in constant time: a byte-wise != leaks how much of the prefix
// matched through response timing.
if cfg.WebhookSecret != "" &&
subtle.ConstantTimeCompare([]byte(strings.TrimSpace(secretHeader)), []byte(cfg.WebhookSecret)) != 1 {
return errorsx.UnauthorizedI18n("error.auth.invalidSignature")
}
var payload discord.WebhookPayload
if err := json.Unmarshal(rawPayload, &payload); err != nil {
return fmt.Errorf("unmarshal discord payload failed: %w", err)
}
author := payload.Author
text := strings.TrimSpace(payload.Content)
msgID := payload.ID
targetChannelID := payload.ChannelID
guildID := payload.GuildID
attachments := payload.Attachments
embeds := payload.Embeds
if payload.Message != nil {
if author == nil {
author = &payload.Message.Author
}
if text == "" {
text = strings.TrimSpace(payload.Message.Content)
}
if msgID == "" {
msgID = payload.Message.ID
}
if targetChannelID == "" {
targetChannelID = payload.Message.ChannelID
}
if guildID == "" {
guildID = payload.Message.GuildID
}
if len(attachments) == 0 && len(payload.Message.Attachments) > 0 {
attachments = payload.Message.Attachments
}
if len(embeds) == 0 && len(payload.Message.Embeds) > 0 {
embeds = payload.Message.Embeds
}
}
if author == nil || author.Bot || strings.TrimSpace(author.ID) == "" {
return nil // Ignore bot messages or invalid authors
}
// Honour the channel's guild scope. A bot can be invited to several servers,
// and without these checks GuildID and ChannelScope would be stored
// configuration that silently does nothing.
if cfg.GuildID != "" && guildID != cfg.GuildID {
slog.Debug("ignoring discord message from an out-of-scope guild",
"guild_id", guildID,
"channel", channel.ID,
)
return nil
}
if cfg.ChannelScope == "dm_only" && guildID != "" {
slog.Debug("ignoring discord guild message, channel is dm_only",
"guild_id", guildID,
"channel", channel.ID,
)
return nil
}
if text == "" && len(attachments) > 0 {
firstAtt := attachments[0]
if firstAtt.Filename != "" {
text = fmt.Sprintf("[%s] %s", firstAtt.Filename, firstAtt.URL)
} else {
text = firstAtt.URL
}
}
if text == "" && len(attachments) == 0 && len(embeds) == 0 {
return nil // Ignore empty messages
}
if text == "" && len(embeds) > 0 {
text = embeds[0].Description
if text == "" {
text = embeds[0].Title
}
}
// 1. Resolve customer identity
externalID := author.ID
name := strings.TrimSpace(author.GlobalName)
if name == "" {
name = strings.TrimSpace(author.Username)
}
if name == "" {
name = fmt.Sprintf("Discord User %s", author.ID)
}
externalUser := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceDiscord,
ExternalID: externalID,
ExternalName: name,
}
// 2. Create or match Conversation
conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
if err != nil {
return fmt.Errorf("create discord conversation failed: %w", err)
}
// 3. Send message through MessageService
clientMsgID := fmt.Sprintf("discord_%s_%s", targetChannelID, msgID)
payloadMap := map[string]any{
"discord_message_id": msgID,
"discord_channel_id": targetChannelID,
"discord_guild_id": guildID,
"discord_user_id": author.ID,
"discord_attachments": attachments,
}
payloadBytes, _ := json.Marshal(payloadMap)
_, err = MessageService.SendCustomerMessage(
conversation.ID,
clientMsgID,
enums.IMMessageTypeText,
text,
string(payloadBytes),
externalUser,
)
if err != nil {
return fmt.Errorf("send customer message failed: %w", err)
}
return nil
}