Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,10 @@ QDRANT_GRPC_PORT=6334

# Webhook & Organization Sync
# ORG_SYNC_SECRET=your-webhook-hmac-secret

# Discord Bot Channel
# Deployment-wide bot token, used when a Discord channel does not carry its own.
# DISCORD_BOT_TOKEN=your-discord-bot-token
# DISCORD_CLIENT_ID=your-discord-application-id
# DISCORD_CLIENT_SECRET=your-discord-client-secret
# DISCORD_PUBLIC_KEY=your-discord-application-public-key
5 changes: 5 additions & 0 deletions internal/bootstrap/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,8 @@ func registerThirdZaloRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.ZaloPostWebhook)
group.POST("/webhook/:channel_id", third.ZaloPostWebhook)
}

func registerThirdDiscordRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.DiscordPostWebhook)
group.POST("/webhook/:channel_id", third.DiscordPostWebhook)
}
1 change: 1 addition & 0 deletions internal/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ func addRouter(app *gin.Engine) {
registerThirdWechatRoutes(thirdGroup.Group("/wechat"))
registerThirdTelegramRoutes(thirdGroup.Group("/telegram"))
registerThirdZaloRoutes(thirdGroup.Group("/zalo"))
registerThirdDiscordRoutes(thirdGroup.Group("/discord"))
}

type spaShellRewrite struct {
Expand Down
139 changes: 139 additions & 0 deletions internal/discord/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package discord

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

const defaultBaseURL = "https://discord.com/api/v10"

type Client struct {
botToken string
baseURL string
httpClient *http.Client
}

func NewClient(botToken string) *Client {
return &Client{
botToken: strings.TrimSpace(botToken),
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}

func (c *Client) SetBaseURL(url string) {
if strings.TrimSpace(url) != "" {
c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/")
}
}

func (c *Client) GetMe(ctx context.Context) (*User, error) {
var user User
if err := c.doRequest(ctx, http.MethodGet, "/users/@me", nil, &user); err != nil {
return nil, err
}
return &user, nil
}

func (c *Client) CreateDMChannel(ctx context.Context, recipientID string) (*Channel, error) {
if strings.TrimSpace(recipientID) == "" {
return nil, fmt.Errorf("recipient_id is required")
}
req := CreateDMRequest{RecipientID: strings.TrimSpace(recipientID)}
var channel Channel
if err := c.doRequest(ctx, http.MethodPost, "/users/@me/channels", req, &channel); err != nil {
return nil, err
}
return &channel, nil
}

func (c *Client) SendMessage(ctx context.Context, channelID string, content string) (*Message, error) {
channelID = strings.TrimSpace(channelID)
if channelID == "" {
return nil, fmt.Errorf("channel_id is required")
}
if strings.TrimSpace(content) == "" {
return nil, fmt.Errorf("content is required")
}

req := SendMessageRequest{Content: content}
var msg Message
endpoint := fmt.Sprintf("/channels/%s/messages", channelID)
if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil {
return nil, err
}
return &msg, nil
}

func (c *Client) SendEmbedMessage(ctx context.Context, channelID string, content string, embeds []Embed) (*Message, error) {
channelID = strings.TrimSpace(channelID)
if channelID == "" {
return nil, fmt.Errorf("channel_id is required")
}

req := SendMessageRequest{
Content: content,
Embeds: embeds,
}
var msg Message
endpoint := fmt.Sprintf("/channels/%s/messages", channelID)
if err := c.doRequest(ctx, http.MethodPost, endpoint, req, &msg); err != nil {
return nil, err
}
return &msg, nil
}

func (c *Client) doRequest(ctx context.Context, method, path string, payload any, result any) error {
if c.botToken == "" {
return fmt.Errorf("discord bot token is required")
}

endpoint := fmt.Sprintf("%s%s", c.baseURL, path)

var bodyReader io.Reader
if payload != nil {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal discord request failed: %w", err)
}
bodyReader = bytes.NewBuffer(bodyBytes)
}

req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader)
if err != nil {
return fmt.Errorf("create discord request failed: %w", err)
}

req.Header.Set("Authorization", "Bot "+c.botToken)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}

res, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("discord http request failed: %w", err)
}
defer res.Body.Close()

bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("read discord response failed: %w", err)
}

if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("discord api error (%d): %s", res.StatusCode, string(bodyBytes))
}

if result != nil {
if err := json.Unmarshal(bodyBytes, result); err != nil {
return fmt.Errorf("unmarshal discord response failed: %w (body: %s)", err, string(bodyBytes))
}
}
return nil
}
90 changes: 90 additions & 0 deletions internal/discord/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package discord

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestDiscordSendMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bot test_token" {
t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization"))
}
if r.URL.Path != "/channels/789/messages" {
t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"123456","channel_id":"789","content":"hello"}`))
}))
defer server.Close()

client := NewClient("test_token")
client.SetBaseURL(server.URL)

resp, err := client.SendMessage(context.Background(), "789", "hello")
if err != nil {
t.Fatalf("SendMessage failed: %v", err)
}
if resp.ID != "123456" {
t.Errorf("expected ID 123456, got %s", resp.ID)
}
}

func TestDiscordSendEmbedMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bot test_token" {
t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization"))
}
if r.URL.Path != "/channels/789/messages" {
t.Errorf("expected path /channels/789/messages, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"embed_123","channel_id":"789","content":"Check image"}`))
}))
defer server.Close()

client := NewClient("test_token")
client.SetBaseURL(server.URL)

embed := Embed{
Title: "Screenshot",
Image: &EmbedMedia{URL: "https://example.com/img.png"},
}
resp, err := client.SendEmbedMessage(context.Background(), "789", "Check image", []Embed{embed})
if err != nil {
t.Fatalf("SendEmbedMessage failed: %v", err)
}
if resp.ID != "embed_123" {
t.Errorf("expected ID embed_123, got %s", resp.ID)
}
}

func TestDiscordCreateDMChannel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bot test_token" {
t.Errorf("expected Bot test_token, got %s", r.Header.Get("Authorization"))
}
if r.URL.Path != "/users/@me/channels" {
t.Errorf("expected path /users/@me/channels, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":"dm_chan_123","type":1}`))
}))
defer server.Close()

client := NewClient("test_token")
client.SetBaseURL(server.URL)

resp, err := client.CreateDMChannel(context.Background(), "user_999")
if err != nil {
t.Fatalf("CreateDMChannel failed: %v", err)
}
if resp.ID != "dm_chan_123" {
t.Errorf("expected ID dm_chan_123, got %s", resp.ID)
}
}
80 changes: 80 additions & 0 deletions internal/discord/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package discord

// User represents a Discord user.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Discriminator string `json:"discriminator,omitempty"`
GlobalName string `json:"global_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Bot bool `json:"bot,omitempty"`
}

// Channel represents a Discord channel (Guild Text, DM, Thread, etc.).
type Channel struct {
ID string `json:"id"`
Type int `json:"type"`
GuildID string `json:"guild_id,omitempty"`
Name string `json:"name,omitempty"`
}

// Attachment represents a file or image uploaded to Discord.
type Attachment struct {
ID string `json:"id"`
Filename string `json:"filename"`
URL string `json:"url"`
ProxyURL string `json:"proxy_url,omitempty"`
ContentType string `json:"content_type,omitempty"`
Size int64 `json:"size,omitempty"`
}

// EmbedMedia represents an image/video/thumbnail inside an Embed.
type EmbedMedia struct {
URL string `json:"url"`
}

// Embed represents a Discord rich embed object.
type Embed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
URL string `json:"url,omitempty"`
Color int `json:"color,omitempty"`
Image *EmbedMedia `json:"image,omitempty"`
}

// Message represents a Discord message.
type Message struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id,omitempty"`
Author User `json:"author"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Attachments []Attachment `json:"attachments,omitempty"`
Embeds []Embed `json:"embeds,omitempty"`
}

// SendMessageRequest represents payload for Discord create message API.
type SendMessageRequest struct {
Content string `json:"content,omitempty"`
Embeds []Embed `json:"embeds,omitempty"`
}

// CreateDMRequest represents payload for Discord create DM channel API.
type CreateDMRequest struct {
RecipientID string `json:"recipient_id"`
}

// WebhookPayload represents an incoming message/event from Discord Gateway or Webhook.
type WebhookPayload struct {
ID string `json:"id,omitempty"`
Type int `json:"type,omitempty"`
GuildID string `json:"guild_id,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
Author *User `json:"author,omitempty"`
Content string `json:"content,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Embeds []Embed `json:"embeds,omitempty"`
Message *Message `json:"message,omitempty"`
}
39 changes: 39 additions & 0 deletions internal/handlers/third/discord_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package third

import (
"bytes"
"io"
"net/http"
"strings"

"agent-desk/internal/services"

"github.com/gin-gonic/gin"
)

// DiscordPostWebhook receives incoming Webhook events from Discord.
func DiscordPostWebhook(ctx *gin.Context) {
channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
channelID = strings.TrimSpace(ctx.Query("channel_id"))
}

secretHeader := ctx.GetHeader("X-Discord-Secret-Token")
if secretHeader == "" {
secretHeader = ctx.GetHeader("X-Webhook-Secret")
}

bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

if err := services.DiscordInboundService.HandleWebhook(ctx.Request.Context(), channelID, secretHeader, bodyBytes); err != nil {
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}

ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
Loading
Loading