mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 21:37:33 +08:00
### Summary As title, related to #17520 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
847 lines
21 KiB
Go
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
|
|
}
|