discord.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. package discord
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "mime/multipart"
  10. "net/http"
  11. "os"
  12. "strings"
  13. "time"
  14. "unicode/utf16"
  15. "github.com/mhsanaei/3x-ui/v3/internal/web/locale"
  16. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  17. )
  18. const (
  19. defaultDiscordBaseURL = "https://discord.com/api/v10"
  20. discordUserAgent = "DiscordBot (https://github.com/mhsanaei/3x-ui, 3.x)"
  21. ColorGreen = 0x2ECC71
  22. ColorRed = 0xE74C3C
  23. ColorOrange = 0xF39C12
  24. ColorBlue = 0x3498DB
  25. )
  26. // FileAttachment represents a file attachment to be uploaded with a Discord message.
  27. type FileAttachment struct {
  28. Filename string
  29. Data []byte
  30. }
  31. // MessagePayload represents the Discord create message payload.
  32. type MessagePayload struct {
  33. Content string `json:"content,omitempty"`
  34. Embeds []Embed `json:"embeds,omitempty"`
  35. }
  36. // Embed represents a Discord embed object.
  37. type Embed struct {
  38. Title string `json:"title,omitempty"`
  39. Description string `json:"description,omitempty"`
  40. Color int `json:"color,omitempty"`
  41. Fields []EmbedField `json:"fields,omitempty"`
  42. Footer *EmbedFooter `json:"footer,omitempty"`
  43. Timestamp string `json:"timestamp,omitempty"`
  44. }
  45. // EmbedField represents a field in a Discord embed.
  46. type EmbedField struct {
  47. Name string `json:"name"`
  48. Value string `json:"value"`
  49. Inline bool `json:"inline,omitempty"`
  50. }
  51. // EmbedFooter represents a footer in a Discord embed.
  52. type EmbedFooter struct {
  53. Text string `json:"text"`
  54. }
  55. // DiscordService manages communication with the Discord API.
  56. type DiscordService struct {
  57. settingService service.SettingService
  58. httpClient *http.Client
  59. baseURL string
  60. }
  61. // NewDiscordService creates a new DiscordService.
  62. func NewDiscordService(settingService service.SettingService) *DiscordService {
  63. return &DiscordService{
  64. settingService: settingService,
  65. baseURL: defaultDiscordBaseURL,
  66. }
  67. }
  68. // SetHTTPClient sets a custom HTTP client (useful for unit testing).
  69. func (s *DiscordService) SetHTTPClient(client *http.Client) {
  70. s.httpClient = client
  71. }
  72. // SetBaseURL sets a custom base URL for the Discord API (useful for testing with httptest).
  73. func (s *DiscordService) SetBaseURL(url string) {
  74. s.baseURL = strings.TrimRight(url, "/")
  75. }
  76. func (s *DiscordService) getClient() *http.Client {
  77. if s.httpClient != nil {
  78. return s.httpClient
  79. }
  80. return s.settingService.NewProxiedHTTPClient(10 * time.Second)
  81. }
  82. func (s *DiscordService) getBaseURL() string {
  83. if s.baseURL != "" {
  84. return s.baseURL
  85. }
  86. return defaultDiscordBaseURL
  87. }
  88. func (s *DiscordService) authCredentials() (token string, channelID string, err error) {
  89. rawToken, err := s.settingService.GetDiscordBotToken()
  90. if err != nil || strings.TrimSpace(rawToken) == "" {
  91. return "", "", errors.New("discord bot token is not configured")
  92. }
  93. rawChannel, err := s.settingService.GetDiscordChannelId()
  94. if err != nil || strings.TrimSpace(rawChannel) == "" {
  95. return "", "", errors.New("discord channel id is not configured")
  96. }
  97. cleanToken := strings.TrimSpace(rawToken)
  98. cleanToken = strings.TrimPrefix(cleanToken, "Bot ")
  99. cleanToken = strings.TrimSpace(cleanToken)
  100. if cleanToken == "" {
  101. return "", "", errors.New("discord bot token is not configured")
  102. }
  103. return cleanToken, strings.TrimSpace(rawChannel), nil
  104. }
  105. // discordCharLen counts what Discord's caps count: a rune outside the BMP is
  106. // two units there, so a rune count understates an emoji-bearing field.
  107. func discordCharLen(s string) int {
  108. return len(utf16.Encode([]rune(s)))
  109. }
  110. // RateLimitedError is a 429, carrying the wait Discord asks for so a caller
  111. // sending several messages can back off instead of losing the rest of them.
  112. type RateLimitedError struct {
  113. RetryAfter time.Duration
  114. }
  115. func (e *RateLimitedError) Error() string {
  116. return fmt.Sprintf("discord rate limited (429): retry after %s", e.RetryAfter)
  117. }
  118. func parseDiscordResponse(resp *http.Response) error {
  119. respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
  120. bodyStr := string(respBody)
  121. switch resp.StatusCode {
  122. case http.StatusOK, http.StatusCreated, http.StatusNoContent:
  123. return nil
  124. case http.StatusBadRequest:
  125. return fmt.Errorf("discord bad request (400): %s", bodyStr)
  126. case http.StatusUnauthorized:
  127. return errors.New("discord unauthorized (401): invalid bot token")
  128. case http.StatusForbidden:
  129. return errors.New("discord forbidden (403): bot lacks permissions for channel")
  130. case http.StatusNotFound:
  131. return errors.New("discord not found (404): channel not found")
  132. case http.StatusTooManyRequests:
  133. var limited struct {
  134. RetryAfter float64 `json:"retry_after"`
  135. }
  136. _ = json.Unmarshal(respBody, &limited)
  137. return &RateLimitedError{RetryAfter: time.Duration(limited.RetryAfter * float64(time.Second))}
  138. default:
  139. return fmt.Errorf("discord API error (%d): %s", resp.StatusCode, bodyStr)
  140. }
  141. }
  142. // SendMessage sends a Discord message payload to the configured channel.
  143. func (s *DiscordService) SendMessage(ctx context.Context, payload MessagePayload) error {
  144. if ctx == nil {
  145. ctx = context.Background()
  146. }
  147. cleanToken, channelID, err := s.authCredentials()
  148. if err != nil {
  149. return err
  150. }
  151. bodyBytes, err := json.Marshal(payload)
  152. if err != nil {
  153. return fmt.Errorf("marshal discord payload: %w", err)
  154. }
  155. endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID)
  156. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyBytes))
  157. if err != nil {
  158. return fmt.Errorf("create discord request: %w", err)
  159. }
  160. req.Header.Set("Content-Type", "application/json")
  161. req.Header.Set("Authorization", "Bot "+cleanToken)
  162. req.Header.Set("User-Agent", discordUserAgent)
  163. resp, err := s.getClient().Do(req)
  164. if err != nil {
  165. return fmt.Errorf("discord request failed: %w", err)
  166. }
  167. defer resp.Body.Close()
  168. return parseDiscordResponse(resp)
  169. }
  170. // SendMessageWithFiles sends a Discord message payload with optional file attachments using multipart/form-data.
  171. func (s *DiscordService) SendMessageWithFiles(ctx context.Context, payload MessagePayload, files ...FileAttachment) error {
  172. if len(files) == 0 {
  173. return s.SendMessage(ctx, payload)
  174. }
  175. if ctx == nil {
  176. ctx = context.Background()
  177. }
  178. cleanToken, channelID, err := s.authCredentials()
  179. if err != nil {
  180. return err
  181. }
  182. body := &bytes.Buffer{}
  183. writer := multipart.NewWriter(body)
  184. payloadBytes, err := json.Marshal(payload)
  185. if err != nil {
  186. return fmt.Errorf("marshal discord payload: %w", err)
  187. }
  188. if err := writer.WriteField("payload_json", string(payloadBytes)); err != nil {
  189. return fmt.Errorf("write payload_json: %w", err)
  190. }
  191. for i, file := range files {
  192. part, err := writer.CreateFormFile(fmt.Sprintf("files[%d]", i), file.Filename)
  193. if err != nil {
  194. return fmt.Errorf("create form file part %d: %w", i, err)
  195. }
  196. if _, err := part.Write(file.Data); err != nil {
  197. return fmt.Errorf("write form file part %d: %w", i, err)
  198. }
  199. }
  200. if err := writer.Close(); err != nil {
  201. return fmt.Errorf("close multipart writer: %w", err)
  202. }
  203. endpoint := fmt.Sprintf("%s/channels/%s/messages", s.getBaseURL(), channelID)
  204. req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
  205. if err != nil {
  206. return fmt.Errorf("create discord request: %w", err)
  207. }
  208. req.Header.Set("Content-Type", writer.FormDataContentType())
  209. req.Header.Set("Authorization", "Bot "+cleanToken)
  210. req.Header.Set("User-Agent", discordUserAgent)
  211. resp, err := s.getClient().Do(req)
  212. if err != nil {
  213. return fmt.Errorf("discord request failed: %w", err)
  214. }
  215. defer resp.Body.Close()
  216. return parseDiscordResponse(resp)
  217. }
  218. // SendEmbed is a helper to send an embed payload.
  219. func (s *DiscordService) SendEmbed(ctx context.Context, embed Embed) error {
  220. return s.SendMessage(ctx, MessagePayload{
  221. Embeds: []Embed{embed},
  222. })
  223. }
  224. // translator renders messages in the configured Discord bot language, read once per message.
  225. func translator(settingService service.SettingService) func(key string, params ...string) string {
  226. lang, err := settingService.GetDiscordLang()
  227. if err != nil || lang == "" {
  228. lang = "en-US"
  229. }
  230. return func(key string, params ...string) string {
  231. return locale.I18nForLang(lang, key, params...)
  232. }
  233. }
  234. // SendTest sends a test embed to verify Discord bot configuration.
  235. func (s *DiscordService) SendTest(ctx context.Context) error {
  236. tr := translator(s.settingService)
  237. now := time.Now().UTC().Format(time.RFC3339)
  238. hostname, _ := os.Hostname()
  239. if hostname == "" {
  240. hostname = "3x-ui"
  241. }
  242. embed := Embed{
  243. Title: tr("discord.test.title"),
  244. Description: tr("discord.test.body"),
  245. Color: ColorGreen,
  246. Timestamp: now,
  247. Fields: []EmbedField{
  248. {Name: tr("host"), Value: hostname, Inline: true},
  249. },
  250. Footer: &EmbedFooter{
  251. Text: tr("discord.footer"),
  252. },
  253. }
  254. return s.SendEmbed(ctx, embed)
  255. }