attachment_handler.go 7.2 KB

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