Files
ragflow/internal/channels/discord.go
Haruko386 39d8609d6c feat[Go]: add discord chat bot for chat channel (#17557)
### Summary

As title, related to #17520 

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-31 10:06:39 +08:00

847 lines
21 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package channels
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"ragflow/internal/channels/core"
)
const (
defaultDiscordAPIBaseURL = "https://discord.com/api/v10"
discordGatewayVersion = "10"
defaultDiscordTimeout = 30 * time.Second
discordHandshakeTimeout = 30 * time.Second
discordReadTimeout = 2 * time.Minute
discordWriteTimeout = 10 * time.Second
discordReconnectDelay = 5 * time.Second
discordMessageQueueSize = 64
discordMessageTTL = time.Hour
discordChatWorkerIdle = 5 * time.Minute
discordDefaultIntents = 1<<0 | 1<<9 | 1<<12 | 1<<15
discordSendMaxAttempts = 3
discordMaxRetryAfter = 30 * time.Second
)
type discordAccount struct {
AccountID string
Token string
APIBaseURL string
GatewayURL string
Timeout time.Duration
Intents int
}
type discordChannel struct {
account discordAccount
mu sync.Mutex
cancel context.CancelFunc
conn *websocket.Conn
handler core.MessageHandler
client *http.Client
selfID string
seen map[string]time.Time
workers map[string]*discordChatWorker
writeMu sync.Mutex
lastSeq atomic.Int64
hasSeq atomic.Bool
}
type discordChatWorker struct {
queue chan core.IncomingMessage
}
type discordGatewayPayload struct {
Op int `json:"op"` // Gateway opcode, which indicates the payload type
D json.RawMessage `json:"d"` // Event data
S *int64 `json:"s"` // Sequence number of event used for resuming sessions and heartbeating
T string `json:"t"` // Event name
}
type discordGatewayBotResponse struct {
URL string `json:"url"`
}
type discordGatewayHello struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
type discordGatewayReady struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}
type discordMessageCreate struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
GuildID string `json:"guild_id"`
Content string `json:"content"`
ChannelType *int `json:"channel_type"`
Author struct {
ID string `json:"id"`
Bot bool `json:"bot"`
} `json:"author"`
}
type discordRateLimitError struct {
RetryAfter time.Duration
Global bool
Response string
}
// Error formats a Discord rate-limit response with retry metadata.
func (e *discordRateLimitError) Error() string {
scope := "route"
if e.Global {
scope = "global"
}
return fmt.Sprintf("discord api rate limited (%s), retry_after=%s, response: %s", scope, e.RetryAfter, e.Response)
}
// newDiscordChannel creates a Discord bot channel with default REST and Gateway settings.
func newDiscordChannel(account discordAccount) *discordChannel {
account.Token = discordBotToken(account.Token)
if account.APIBaseURL == "" {
account.APIBaseURL = defaultDiscordAPIBaseURL
}
if account.Timeout <= 0 {
account.Timeout = defaultDiscordTimeout
}
if account.Intents == 0 {
account.Intents = discordDefaultIntents
}
return &discordChannel{
account: account,
client: &http.Client{Timeout: account.Timeout},
seen: map[string]time.Time{},
workers: map[string]*discordChatWorker{},
}
}
// newDiscordChannelFromConfig builds a Discord channel from chat_channel.config.credential.
func newDiscordChannelFromConfig(accountID string, cfg map[string]any) (*discordChannel, error) {
token := discordBotToken(firstString(cfg, "token", "bot_token"))
if token == "" {
return nil, fmt.Errorf("discord account %q is missing token", accountID)
}
timeout := defaultDiscordTimeout
if raw, ok := cfg["timeout_secs"]; ok {
if parsed := durationSeconds(raw); parsed > 0 {
timeout = parsed
}
}
return newDiscordChannel(discordAccount{
AccountID: accountID,
Token: token,
APIBaseURL: firstString(cfg, "api_base_url", "api_url"),
GatewayURL: firstString(cfg, "gateway_url"),
Timeout: timeout,
Intents: discordIntentsFromConfig(cfg),
}), nil
}
// ChannelID returns the platform identifier used by the chat-channel runtime.
func (c *discordChannel) ChannelID() string {
return "discord"
}
// AccountID returns the chat_channel.id bound to this Discord runtime instance.
func (c *discordChannel) AccountID() string {
return c.account.AccountID
}
// SetMessageHandler installs the RAGFlow bridge invoked for inbound Discord messages.
func (c *discordChannel) SetMessageHandler(handler core.MessageHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.handler = handler
}
// Start begins the Discord Gateway receive loop.
func (c *discordChannel) Start(ctx context.Context) error {
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.mu.Unlock()
go c.run(runCtx)
return nil
}
// Stop cancels the Discord Gateway loop and closes the websocket connection.
func (c *discordChannel) Stop(ctx context.Context) error {
c.mu.Lock()
cancel := c.cancel
conn := c.conn
c.cancel = nil
c.conn = nil
c.selfID = ""
c.seen = map[string]time.Time{}
c.workers = map[string]*discordChatWorker{}
c.mu.Unlock()
if cancel != nil {
cancel()
}
if conn != nil {
_ = conn.Close()
}
return nil
}
// Send posts an outgoing RAGFlow answer to a Discord channel.
func (c *discordChannel) Send(ctx context.Context, msg core.OutgoingMessage) error {
if strings.TrimSpace(msg.ChatID) == "" {
return errors.New("chat_id is required")
}
if strings.TrimSpace(msg.Text) == "" {
return nil
}
payload := map[string]any{
"content": msg.Text,
"allowed_mentions": map[string]any{"parse": []string{}},
}
if strings.TrimSpace(msg.ReplyToMessageID) != "" {
payload["message_reference"] = map[string]any{
"message_id": msg.ReplyToMessageID,
"channel_id": msg.ChatID,
"fail_if_not_exists": false,
}
}
path := "/channels/" + url.PathEscape(msg.ChatID) + "/messages"
var lastErr error
for attempt := 1; attempt <= discordSendMaxAttempts; attempt++ {
err := c.requestJSON(ctx, http.MethodPost, path, payload, nil)
if err == nil {
return nil
}
var rateLimitErr *discordRateLimitError
if !errors.As(err, &rateLimitErr) {
return err
}
lastErr = err
if attempt == discordSendMaxAttempts || rateLimitErr.RetryAfter > discordMaxRetryAfter {
return err
}
wait := rateLimitErr.RetryAfter
if wait <= 0 {
wait = time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(wait):
}
}
return lastErr
}
// run reconnects to Discord Gateway until the channel is stopped.
func (c *discordChannel) run(ctx context.Context) {
for ctx.Err() == nil {
if err := c.runGateway(ctx); err != nil && ctx.Err() == nil {
log.Printf("[discord:%s] gateway error: %v", c.account.AccountID, err)
}
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
case <-time.After(discordReconnectDelay):
}
}
}
// runGateway connects one Discord Gateway websocket session and reads events.
func (c *discordChannel) runGateway(ctx context.Context) error {
// get websocket URL
wsURL, err := c.gatewayURL(ctx)
if err != nil {
return err
}
// set websocket connection
header := http.Header{}
dialer := *websocket.DefaultDialer
dialer.HandshakeTimeout = discordHandshakeTimeout
conn, _, err := dialer.DialContext(ctx, wsURL, header)
if err != nil {
return err
}
defer conn.Close()
c.setConn(conn)
defer c.clearConn(conn)
closeOnCancel := make(chan struct{})
defer close(closeOnCancel)
go func() {
select {
case <-ctx.Done():
_ = conn.Close()
case <-closeOnCancel:
}
}()
hello, err := c.readHello(conn)
if err != nil {
return err
}
if err := c.writeGatewayPayload(conn, 2, map[string]any{
"token": c.account.Token,
"intents": c.account.Intents,
"properties": map[string]string{
"os": "linux",
"browser": "ragflow",
"device": "ragflow",
},
}); err != nil {
return err
}
heartbeatDone := make(chan struct{})
defer close(heartbeatDone)
go c.heartbeatLoop(ctx, conn, time.Duration(hello.HeartbeatInterval)*time.Millisecond, heartbeatDone)
for ctx.Err() == nil {
_ = conn.SetReadDeadline(time.Now().Add(discordReadTimeout))
var payload discordGatewayPayload
if err := conn.ReadJSON(&payload); err != nil {
return err
}
if payload.S != nil {
c.lastSeq.Store(*payload.S)
c.hasSeq.Store(true)
}
if err := c.handleGatewayPayload(ctx, conn, payload); err != nil {
return err
}
}
return ctx.Err()
}
// readHello waits for the Discord Gateway hello frame that carries the heartbeat interval.
func (c *discordChannel) readHello(conn *websocket.Conn) (discordGatewayHello, error) {
_ = conn.SetReadDeadline(time.Now().Add(discordReadTimeout))
for {
var payload discordGatewayPayload
if err := conn.ReadJSON(&payload); err != nil {
return discordGatewayHello{}, err
}
if payload.Op != 10 {
continue
}
var hello discordGatewayHello
if err := json.Unmarshal(payload.D, &hello); err != nil {
return discordGatewayHello{}, err
}
if hello.HeartbeatInterval <= 0 {
return discordGatewayHello{}, errors.New("discord gateway hello is missing heartbeat interval")
}
return hello, nil
}
}
// heartbeatLoop keeps the Discord Gateway websocket session alive.
func (c *discordChannel) heartbeatLoop(ctx context.Context, conn *websocket.Conn, interval time.Duration, done <-chan struct{}) {
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-done:
return
case <-ticker.C:
if err := c.writeHeartbeat(conn); err != nil {
_ = conn.Close()
return
}
}
}
}
// handleGatewayPayload processes Discord Gateway opcodes and dispatch events.
func (c *discordChannel) handleGatewayPayload(ctx context.Context, conn *websocket.Conn, payload discordGatewayPayload) error {
switch payload.Op {
case 0:
return c.handleDispatch(ctx, payload)
case 1:
return c.writeHeartbeat(conn)
case 7:
return errors.New("discord gateway requested reconnect")
case 9:
return errors.New("discord gateway invalid session")
case 11:
return nil
default:
return nil
}
}
// handleDispatch converts supported Discord Gateway dispatches to chat-channel messages.
func (c *discordChannel) handleDispatch(ctx context.Context, payload discordGatewayPayload) error {
switch payload.T {
case "READY":
var ready discordGatewayReady
if err := json.Unmarshal(payload.D, &ready); err != nil {
return err
}
c.mu.Lock()
c.selfID = ready.User.ID
c.mu.Unlock()
log.Printf("[discord:%s] connected as bot id %s", c.account.AccountID, ready.User.ID)
case "MESSAGE_CREATE":
c.handleMessageCreate(ctx, payload.D)
}
return nil
}
// handleMessageCreate filters and queues inbound Discord messages for RAGFlow.
func (c *discordChannel) handleMessageCreate(ctx context.Context, raw json.RawMessage) {
var message discordMessageCreate
if err := json.Unmarshal(raw, &message); err != nil {
return
}
if message.ID == "" || message.ChannelID == "" || message.Author.ID == "" {
return
}
if message.Author.Bot {
return
}
c.mu.Lock()
selfID := c.selfID
c.mu.Unlock()
if selfID != "" && message.Author.ID == selfID {
return
}
rawMap := map[string]any{}
_ = json.Unmarshal(raw, &rawMap)
incoming := core.IncomingMessage{
Channel: c.ChannelID(),
AccountID: c.account.AccountID,
ChatID: message.ChannelID,
ChatType: discordChatType(message.GuildID, message.ChannelType),
MessageID: message.ID,
SenderID: message.Author.ID,
Text: message.Content,
Raw: rawMap,
}
_ = c.enqueueIncoming(ctx, incoming)
}
// enqueueIncoming schedules Discord message handling and marks it seen after a successful handoff.
func (c *discordChannel) enqueueIncoming(ctx context.Context, incoming core.IncomingMessage) bool {
if ctx.Err() != nil {
return false
}
now := time.Now()
var worker *discordChatWorker
startWorker := false
queueFull := false
c.mu.Lock()
c.pruneSeenLocked(now)
if _, ok := c.seen[incoming.MessageID]; ok {
c.mu.Unlock()
return false
}
if c.workers == nil {
c.workers = map[string]*discordChatWorker{}
}
worker = c.workers[incoming.ChatID]
if worker == nil {
worker = &discordChatWorker{queue: make(chan core.IncomingMessage, discordMessageQueueSize)}
c.workers[incoming.ChatID] = worker
startWorker = true
}
select {
case worker.queue <- incoming:
c.seen[incoming.MessageID] = now
c.mu.Unlock()
if startWorker {
go c.runChatWorker(ctx, incoming.ChatID, worker)
}
return true
case <-ctx.Done():
c.mu.Unlock()
default:
queueFull = true
c.mu.Unlock()
}
if startWorker {
go c.runChatWorker(ctx, incoming.ChatID, worker)
}
if queueFull {
log.Printf("[discord:%s] dropping message %s for chat %s: queue is full", c.account.AccountID, incoming.MessageID, incoming.ChatID)
}
return false
}
// runChatWorker processes one Discord chat's inbound messages sequentially.
func (c *discordChannel) runChatWorker(ctx context.Context, chatID string, worker *discordChatWorker) {
idle := time.NewTimer(discordChatWorkerIdle)
defer idle.Stop()
for {
select {
case <-ctx.Done():
return
case msg := <-worker.queue:
if ctx.Err() != nil {
return
}
c.handleIncoming(ctx, msg)
resetTimer(idle, discordChatWorkerIdle)
case <-idle.C:
if c.retireChatWorker(chatID, worker) {
return
}
resetTimer(idle, discordChatWorkerIdle)
}
}
}
// retireChatWorker removes an idle Discord chat worker only while it is current and empty.
func (c *discordChannel) retireChatWorker(chatID string, worker *discordChatWorker) bool {
c.mu.Lock()
defer c.mu.Unlock()
current := c.workers[chatID]
if current != worker {
return true
}
if len(worker.queue) > 0 {
return false
}
delete(c.workers, chatID)
return true
}
// handleIncoming invokes the installed RAGFlow bridge for one queued Discord message.
func (c *discordChannel) handleIncoming(ctx context.Context, incoming core.IncomingMessage) {
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler == nil {
return
}
if err := handler(ctx, incoming); err != nil {
log.Printf("[discord:%s] message handler error: %v", c.account.AccountID, err)
}
}
// pruneSeenLocked removes expired duplicate-tracking entries while c.mu is held.
func (c *discordChannel) pruneSeenLocked(now time.Time) {
for key, ts := range c.seen {
if now.Sub(ts) > discordMessageTTL {
delete(c.seen, key)
}
}
}
// requestJSON sends an authenticated JSON request to the Discord REST API.
func (c *discordChannel) requestJSON(ctx context.Context, method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
payload, _ := json.Marshal(body)
reader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.account.APIBaseURL, "/")+"/"+strings.TrimLeft(path, "/"), reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", discordAuthHeader(c.account.Token))
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
if resp.StatusCode >= 400 {
if resp.StatusCode == http.StatusTooManyRequests {
return newDiscordRateLimitError(resp.Header, respBody)
}
return fmt.Errorf("discord api status: %d, response: %s", resp.StatusCode, string(respBody))
}
if out != nil && len(bytes.TrimSpace(respBody)) > 0 {
return json.Unmarshal(respBody, out)
}
return nil
}
// newDiscordRateLimitError extracts Discord 429 retry metadata from headers and response body.
func newDiscordRateLimitError(header http.Header, body []byte) *discordRateLimitError {
var payload struct {
RetryAfter any `json:"retry_after"`
Global bool `json:"global"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &payload)
retryAfter := retryAfterFromHeader(header)
if retryAfter <= 0 {
retryAfter = retryAfterFromValue(payload.RetryAfter)
}
return &discordRateLimitError{
RetryAfter: retryAfter,
Global: payload.Global,
Response: strings.TrimSpace(string(body)),
}
}
// retryAfterFromHeader converts Discord Retry-After headers to a duration.
func retryAfterFromHeader(header http.Header) time.Duration {
for _, key := range []string{"Retry-After", "X-RateLimit-Reset-After"} {
if duration := retryAfterFromValue(header.Get(key)); duration > 0 {
return duration
}
}
return 0
}
// retryAfterFromValue converts Discord retry_after seconds into a duration.
func retryAfterFromValue(value any) time.Duration {
switch v := value.(type) {
case float64:
return time.Duration(v * float64(time.Second))
case int:
return time.Duration(v) * time.Second
case int64:
return time.Duration(v) * time.Second
case json.Number:
n, err := v.Float64()
if err != nil {
return 0
}
return time.Duration(n * float64(time.Second))
case string:
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return 0
}
return time.Duration(n * float64(time.Second))
default:
return 0
}
}
// gatewayURL returns the websocket URL used to receive Discord Gateway events.
func (c *discordChannel) gatewayURL(ctx context.Context) (string, error) {
raw := strings.TrimSpace(c.account.GatewayURL)
if raw == "" {
var response discordGatewayBotResponse
if err := c.requestJSON(ctx, http.MethodGet, "/gateway/bot", nil, &response); err != nil {
return "", err
}
raw = response.URL
}
if raw == "" {
return "", errors.New("discord gateway url is empty")
}
parsed, err := url.Parse(raw)
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
case "ws", "wss":
default:
return "", fmt.Errorf("unsupported discord gateway scheme %q", parsed.Scheme)
}
q := parsed.Query()
q.Set("v", discordGatewayVersion)
q.Set("encoding", "json")
parsed.RawQuery = q.Encode()
return parsed.String(), nil
}
// writeHeartbeat sends the latest acknowledged Discord Gateway sequence.
func (c *discordChannel) writeHeartbeat(conn *websocket.Conn) error {
var data any
if c.hasSeq.Load() {
data = c.lastSeq.Load()
}
return c.writeGatewayPayload(conn, 1, data)
}
// writeGatewayPayload writes one JSON frame to Discord Gateway.
func (c *discordChannel) writeGatewayPayload(conn *websocket.Conn, op int, data any) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = conn.SetWriteDeadline(time.Now().Add(discordWriteTimeout))
return conn.WriteJSON(map[string]any{
"op": op,
"d": data,
})
}
// setConn records the current websocket so Stop can close it.
func (c *discordChannel) setConn(conn *websocket.Conn) {
c.mu.Lock()
defer c.mu.Unlock()
c.conn = conn
}
// clearConn removes a websocket only if it still belongs to the current Gateway run.
func (c *discordChannel) clearConn(conn *websocket.Conn) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == conn {
c.conn = nil
}
}
// discordBotToken normalizes a configured Discord token for Gateway identify.
func discordBotToken(token string) string {
token = strings.TrimSpace(token)
lower := strings.ToLower(token)
if strings.HasPrefix(lower, "bot ") {
return strings.TrimSpace(token[4:])
}
if strings.HasPrefix(lower, "bearer ") {
return strings.TrimSpace(token[7:])
}
return token
}
// discordAuthHeader formats the configured Discord bot token for REST API calls.
func discordAuthHeader(token string) string {
token = discordBotToken(token)
if token == "" {
return ""
}
return "Bot " + token
}
// discordIntentsFromConfig reads optional Discord gateway intents from credential config.
func discordIntentsFromConfig(cfg map[string]any) int {
for _, key := range []string{"intents", "gateway_intents"} {
if value, ok := intValue(cfg[key]); ok && value > 0 {
return value
}
}
return discordDefaultIntents
}
// discordChatType maps Discord channel metadata onto RAGFlow chat-channel chat types.
func discordChatType(guildID string, channelType *int) string {
if channelType != nil {
switch *channelType {
case 1:
return "p2p"
case 10, 11, 12:
return "thread"
case 0, 2, 5, 13, 15, 16:
return "group"
}
}
if strings.TrimSpace(guildID) == "" {
return "p2p"
}
return "group"
}
// intValue converts common JSON-decoded config values to int.
func intValue(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
case json.Number:
n, err := v.Int64()
return int(n), err == nil
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
return n, err == nil
default:
return 0, false
}
}
// durationSeconds converts a config value measured in seconds to a duration.
func durationSeconds(value any) time.Duration {
if seconds, ok := intValue(value); ok && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return 0
}