discord.go 7.9 KB

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