attachment_handler.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. package chat
  2. import (
  3. "fmt"
  4. "io"
  5. "log/slog"
  6. "net/http"
  7. "net/url"
  8. "os"
  9. "os/exec"
  10. "path/filepath"
  11. "strings"
  12. "github.com/ayn2op/discordo/internal/consts"
  13. "github.com/ayn2op/discordo/internal/ui"
  14. "github.com/ayn2op/tview/layers"
  15. "github.com/diamondburned/arikawa/v3/discord"
  16. "github.com/skratchdot/open-golang/open"
  17. )
  18. const maxAttachmentSize = 100 * 1024 * 1024 // 100 MB
  19. var supportedImageTypes = map[string]bool{
  20. "image/jpeg": true,
  21. "image/png": true,
  22. "image/webp": true,
  23. "image/gif": true,
  24. }
  25. func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error) {
  26. parsed, err := url.Parse(attachment.URL)
  27. if err != nil {
  28. return "", fmt.Errorf("invalid attachment URL: %w", err)
  29. }
  30. if parsed.Scheme != "https" {
  31. return "", fmt.Errorf("refusing non-HTTPS attachment URL: %s", parsed.Scheme)
  32. }
  33. resp, err := http.Get(attachment.URL)
  34. if err != nil {
  35. return "", fmt.Errorf("failed to fetch attachment: %w", err)
  36. }
  37. defer resp.Body.Close()
  38. if resp.StatusCode != http.StatusOK {
  39. return "", fmt.Errorf("unexpected status %d fetching attachment", resp.StatusCode)
  40. }
  41. dir := filepath.Join(consts.CacheDir(), "attachments")
  42. if err := os.MkdirAll(dir, 0700); err != nil {
  43. return "", fmt.Errorf("failed to create attachments dir: %w", err)
  44. }
  45. safeName := filepath.Base(attachment.Filename)
  46. if safeName == "." || safeName == ".." {
  47. safeName = "attachment"
  48. }
  49. path := filepath.Join(dir, safeName)
  50. file, err := os.Create(path)
  51. if err != nil {
  52. return "", fmt.Errorf("failed to create attachment file: %w", err)
  53. }
  54. defer file.Close()
  55. if _, err := io.Copy(file, io.LimitReader(resp.Body, maxAttachmentSize)); err != nil {
  56. return "", fmt.Errorf("failed to write attachment file: %w", err)
  57. }
  58. return path, nil
  59. }
  60. func terminalGeometry() string {
  61. out, err := exec.Command("xdotool", "getactivewindow", "getwindowgeometry", "--shell").Output()
  62. if err != nil {
  63. return ""
  64. }
  65. var x, y, w, h string
  66. for _, line := range strings.Split(string(out), "\n") {
  67. switch {
  68. case strings.HasPrefix(line, "X="):
  69. x = strings.TrimPrefix(line, "X=")
  70. case strings.HasPrefix(line, "Y="):
  71. y = strings.TrimPrefix(line, "Y=")
  72. case strings.HasPrefix(line, "WIDTH="):
  73. w = strings.TrimPrefix(line, "WIDTH=")
  74. case strings.HasPrefix(line, "HEIGHT="):
  75. h = strings.TrimPrefix(line, "HEIGHT=")
  76. }
  77. }
  78. if w != "" && h != "" && x != "" && y != "" {
  79. return w + "x" + h + "+" + x + "+" + y
  80. }
  81. return ""
  82. }
  83. func viewerArgs(viewer, path string, customArgs []string) []string {
  84. if len(customArgs) > 0 {
  85. args := make([]string, 0, len(customArgs)+2)
  86. args = append(args, viewer)
  87. args = append(args, customArgs...)
  88. args = append(args, path)
  89. return args
  90. }
  91. if strings.HasSuffix(viewer, "mpv") || strings.Contains(viewer, "mpv ") {
  92. args := []string{viewer, "--force-window", "--loop-file=inf"}
  93. if geom := terminalGeometry(); geom != "" {
  94. args = append(args, "--geometry="+geom)
  95. }
  96. args = append(args, path)
  97. return args
  98. }
  99. return []string{viewer, path}
  100. }
  101. func (ml *messagesList) openAttachment(attachment discord.Attachment) {
  102. path, err := ml.downloadToCache(attachment)
  103. if err != nil {
  104. slog.Error("failed to download attachment", "err", err, "url", attachment.URL)
  105. return
  106. }
  107. viewer := ml.cfg.ImageViewer
  108. if viewer != "" && supportedImageTypes[attachment.ContentType] {
  109. if _, err := exec.LookPath(viewer); err != nil {
  110. slog.Error("image viewer not found in PATH", "viewer", viewer, "err", err)
  111. return
  112. }
  113. args := viewerArgs(viewer, path, ml.cfg.ImageViewerArgs)
  114. cmd := exec.Command(args[0], args[1:]...)
  115. cmd.Stdin = os.Stdin
  116. cmd.Stdout = os.Stdout
  117. cmd.Stderr = os.Stderr
  118. ml.chatView.app.Suspend(func() {
  119. if err := cmd.Run(); err != nil {
  120. slog.Error("failed to run image viewer", "viewer", viewer, "err", err)
  121. }
  122. })
  123. return
  124. }
  125. if err := open.Start(path); err != nil {
  126. slog.Error("failed to open attachment file", "err", err, "path", path)
  127. }
  128. }
  129. func (ml *messagesList) saveAttachmentImage(attachment discord.Attachment) {
  130. path, err := ml.downloadToCache(attachment)
  131. if err != nil {
  132. slog.Error("failed to download attachment", "err", err, "url", attachment.URL)
  133. return
  134. }
  135. saveDir := ml.cfg.ImageSaveDir
  136. if saveDir == "" {
  137. saveDir = "."
  138. }
  139. if err := os.MkdirAll(saveDir, 0700); err != nil {
  140. slog.Error("failed to create save directory", "err", err, "path", saveDir)
  141. return
  142. }
  143. safeName := filepath.Base(attachment.Filename)
  144. if safeName == "." || safeName == ".." {
  145. safeName = "attachment"
  146. }
  147. destPath := filepath.Join(saveDir, safeName)
  148. src, err := os.Open(path)
  149. if err != nil {
  150. slog.Error("failed to open cached file", "err", err, "path", path)
  151. return
  152. }
  153. defer src.Close()
  154. dst, err := os.Create(destPath)
  155. if err != nil {
  156. slog.Error("failed to create save file", "err", err, "path", destPath)
  157. return
  158. }
  159. defer dst.Close()
  160. if _, err := io.Copy(dst, src); err != nil {
  161. slog.Error("failed to save image", "err", err)
  162. return
  163. }
  164. slog.Info("image saved", "path", destPath)
  165. }
  166. func (ml *messagesList) saveImage() {
  167. msg, err := ml.selectedMessage()
  168. if err != nil {
  169. slog.Error("failed to get selected message", "err", err)
  170. return
  171. }
  172. var images []discord.Attachment
  173. for _, a := range msg.Attachments {
  174. if supportedImageTypes[a.ContentType] {
  175. images = append(images, a)
  176. }
  177. }
  178. if len(images) == 0 {
  179. return
  180. }
  181. if len(images) == 1 {
  182. go ml.saveAttachmentImage(images[0])
  183. return
  184. }
  185. var items []attachmentItem
  186. for _, a := range images {
  187. attachment := a
  188. items = append(items, attachmentItem{
  189. label: attachment.Filename,
  190. open: func() { go ml.saveAttachmentImage(attachment) },
  191. })
  192. }
  193. ml.attachmentsPicker.SetItems(items)
  194. ml.showAttachmentsOverlay()
  195. }
  196. func (ml *messagesList) openURL(url string) {
  197. if err := open.Start(url); err != nil {
  198. slog.Error("failed to open URL", "err", err, "url", url)
  199. }
  200. }
  201. func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
  202. var items []attachmentItem
  203. for _, a := range attachments {
  204. attachment := a
  205. action := func() {
  206. if strings.HasPrefix(attachment.ContentType, "image/") {
  207. go ml.openAttachment(attachment)
  208. } else {
  209. go ml.openURL(attachment.URL)
  210. }
  211. }
  212. items = append(items, attachmentItem{
  213. label: attachment.Filename,
  214. open: action,
  215. })
  216. }
  217. for _, u := range urls {
  218. url := u
  219. items = append(items, attachmentItem{
  220. label: url,
  221. open: func() { go ml.openURL(url) },
  222. })
  223. }
  224. ml.attachmentsPicker.SetItems(items)
  225. ml.showAttachmentsOverlay()
  226. }
  227. func (ml *messagesList) showAttachmentsOverlay() {
  228. ml.chatView.
  229. AddLayer(
  230. ui.Centered(ml.attachmentsPicker, ml.cfg.Picker.Width, ml.cfg.Picker.Height),
  231. layers.WithName(attachmentsListLayerName),
  232. layers.WithResize(true),
  233. layers.WithVisible(true),
  234. layers.WithOverlay(),
  235. ).
  236. SendToFront(attachmentsListLayerName)
  237. ml.chatView.app.SetFocus(ml.attachmentsPicker)
  238. }