attachment_handler.go 7.1 KB

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