discordo.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. package main
  2. import (
  3. "fmt"
  4. "sort"
  5. "strings"
  6. "github.com/atotto/clipboard"
  7. "github.com/bwmarrin/discordgo"
  8. "github.com/gdamore/tcell/v2"
  9. "github.com/gen2brain/beeep"
  10. "github.com/rigormorrtiss/discordo/ui"
  11. "github.com/rigormorrtiss/discordo/util"
  12. "github.com/rivo/tview"
  13. "github.com/zalando/go-keyring"
  14. )
  15. var (
  16. app *tview.Application
  17. loginWidget *tview.Form
  18. guildsWidget *tview.TreeView
  19. messagesWidget *tview.TextView
  20. inputWidget *tview.InputField
  21. mainFlex *tview.Flex
  22. config *util.Config
  23. session *discordgo.Session
  24. selectedChannel *discordgo.Channel
  25. selectedMessage *discordgo.Message
  26. )
  27. func main() {
  28. config = util.LoadConfig()
  29. tview.Styles = config.Theme
  30. app = tview.NewApplication()
  31. app.EnableMouse(config.Mouse)
  32. app.SetInputCapture(onAppInputCapture)
  33. guildsWidget = ui.NewGuildsWidget()
  34. guildsWidget.SetSelectedFunc(onGuildsWidgetSelected)
  35. messagesWidget = ui.NewMessagesWidget(app)
  36. messagesWidget.SetInputCapture(onMessagesWidgetInputCapture)
  37. inputWidget = ui.NewInputWidget()
  38. inputWidget.SetInputCapture(onInputWidgetInputCapture)
  39. mainFlex = ui.NewMainFlex(
  40. guildsWidget,
  41. messagesWidget,
  42. inputWidget,
  43. )
  44. token := config.Token
  45. if t, _ := keyring.Get("discordo", "token"); t != "" {
  46. token = t
  47. }
  48. if token != "" {
  49. app.
  50. SetRoot(mainFlex, true).
  51. SetFocus(guildsWidget)
  52. session = newSession()
  53. session.Token = token
  54. session.Identify.Token = token
  55. if err := session.Open(); err != nil {
  56. panic(err)
  57. }
  58. } else {
  59. loginWidget = ui.NewLoginWidget(onLoginFormLoginButtonSelected, false)
  60. app.SetRoot(loginWidget, true)
  61. }
  62. if err := app.Run(); err != nil {
  63. panic(err)
  64. }
  65. }
  66. func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
  67. if e.Modifiers() == tcell.ModAlt {
  68. switch e.Rune() {
  69. case '1':
  70. app.SetFocus(guildsWidget)
  71. case '2':
  72. app.SetFocus(messagesWidget)
  73. case '3':
  74. app.SetFocus(inputWidget)
  75. }
  76. }
  77. return e
  78. }
  79. func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) {
  80. for i, m := range ms {
  81. if mID == m.ID {
  82. return i, m
  83. }
  84. }
  85. return -1, nil
  86. }
  87. func onMessagesWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey {
  88. if selectedChannel == nil {
  89. return nil
  90. }
  91. switch {
  92. case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up
  93. ms := selectedChannel.Messages
  94. if len(ms) == 0 {
  95. return nil
  96. }
  97. hs := messagesWidget.GetHighlights()
  98. if len(hs) == 0 {
  99. messagesWidget.
  100. Highlight(ms[len(ms)-1].ID).
  101. ScrollToHighlight()
  102. } else {
  103. idx, _ := findByMessageID(ms, hs[0])
  104. if idx == -1 || idx == 0 {
  105. return nil
  106. }
  107. messagesWidget.
  108. Highlight(ms[idx-1].ID).
  109. ScrollToHighlight()
  110. }
  111. return nil
  112. case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down
  113. ms := selectedChannel.Messages
  114. if len(ms) == 0 {
  115. return nil
  116. }
  117. hs := messagesWidget.GetHighlights()
  118. if len(hs) == 0 {
  119. messagesWidget.
  120. Highlight(ms[len(ms)-1].ID).
  121. ScrollToHighlight()
  122. } else {
  123. idx, _ := findByMessageID(ms, hs[0])
  124. if idx == -1 || idx == len(ms)-1 {
  125. return nil
  126. }
  127. messagesWidget.
  128. Highlight(ms[idx+1].ID).
  129. ScrollToHighlight()
  130. }
  131. return nil
  132. case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top
  133. ms := selectedChannel.Messages
  134. if len(ms) == 0 {
  135. return nil
  136. }
  137. messagesWidget.
  138. Highlight(ms[0].ID).
  139. ScrollToHighlight()
  140. case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom
  141. ms := selectedChannel.Messages
  142. if len(ms) == 0 {
  143. return nil
  144. }
  145. messagesWidget.
  146. Highlight(ms[len(ms)-1].ID).
  147. ScrollToHighlight()
  148. case e.Rune() == 'r': // Inline reply
  149. ms := selectedChannel.Messages
  150. if len(ms) == 0 {
  151. return nil
  152. }
  153. hs := messagesWidget.GetHighlights()
  154. if len(hs) == 0 {
  155. return nil
  156. }
  157. _, selectedMessage = findByMessageID(ms, hs[0])
  158. inputWidget.SetTitle(
  159. "Replying to " + selectedMessage.Author.Username,
  160. )
  161. app.SetFocus(inputWidget)
  162. }
  163. return e
  164. }
  165. func onInputWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey {
  166. // If the "Alt" modifier key is pressed, do not handle the event.
  167. if e.Modifiers() == tcell.ModAlt {
  168. return nil
  169. }
  170. switch e.Key() {
  171. case tcell.KeyEnter:
  172. if selectedChannel == nil {
  173. return nil
  174. }
  175. t := strings.TrimSpace(inputWidget.GetText())
  176. if t == "" {
  177. return nil
  178. }
  179. if selectedMessage != nil {
  180. inputWidget.SetTitle("")
  181. go session.ChannelMessageSendReply(
  182. selectedMessage.ChannelID,
  183. t,
  184. selectedMessage.Reference(),
  185. )
  186. selectedMessage = nil
  187. } else {
  188. go session.ChannelMessageSend(selectedChannel.ID, t)
  189. }
  190. inputWidget.SetText("")
  191. case tcell.KeyCtrlV:
  192. text, _ := clipboard.ReadAll()
  193. text = inputWidget.GetText() + text
  194. inputWidget.SetText(text)
  195. case tcell.KeyEscape:
  196. inputWidget.SetTitle("")
  197. selectedMessage = nil
  198. }
  199. return e
  200. }
  201. func newSession() *discordgo.Session {
  202. s, err := discordgo.New()
  203. if err != nil {
  204. panic(err)
  205. }
  206. s.UserAgent = config.UserAgent
  207. s.Identify.Compress = false
  208. s.Identify.Intents = 0
  209. s.Identify.LargeThreshold = 0
  210. s.Identify.Properties.Device = ""
  211. s.Identify.Properties.Browser = "Chrome"
  212. s.Identify.Properties.OS = "Linux"
  213. s.AddHandlerOnce(onSessionReady)
  214. s.AddHandler(onSessionMessageCreate)
  215. return s
  216. }
  217. func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
  218. sort.Slice(r.Guilds, func(a, b int) bool {
  219. found := false
  220. for _, gID := range r.Settings.GuildPositions {
  221. if found {
  222. if gID == r.Guilds[b].ID {
  223. return true
  224. }
  225. } else {
  226. if gID == r.Guilds[a].ID {
  227. found = true
  228. }
  229. }
  230. }
  231. return false
  232. })
  233. n := guildsWidget.GetRoot()
  234. for _, g := range r.Guilds {
  235. gn := tview.NewTreeNode(g.Name).
  236. SetReference(g.ID)
  237. n.AddChild(gn)
  238. }
  239. guildsWidget.SetCurrentNode(n)
  240. }
  241. func onSessionMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
  242. if selectedChannel == nil {
  243. selectedChannel = &discordgo.Channel{ID: ""}
  244. }
  245. if selectedChannel.ID != m.ChannelID {
  246. if config.Notifications {
  247. for _, u := range m.Mentions {
  248. if u.ID == s.State.User.ID {
  249. g, err := s.State.Guild(m.GuildID)
  250. if err != nil {
  251. return
  252. }
  253. c, err := s.State.Channel(m.ChannelID)
  254. if err != nil {
  255. return
  256. }
  257. go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
  258. return
  259. }
  260. }
  261. }
  262. return
  263. }
  264. selectedChannel.Messages = append(selectedChannel.Messages, m.Message)
  265. util.WriteMessage(
  266. messagesWidget,
  267. m.Message,
  268. session.State.Ready.User.ID,
  269. )
  270. }
  271. func onGuildsWidgetSelected(n *tview.TreeNode) {
  272. selectedChannel = nil
  273. selectedMessage = nil
  274. messagesWidget.
  275. Clear().
  276. SetTitle("")
  277. // Unhighlight the already-highlighted regions.
  278. messagesWidget.Highlight()
  279. switch n.GetLevel() {
  280. case 1:
  281. if len(n.GetChildren()) != 0 {
  282. n.SetExpanded(!n.IsExpanded())
  283. return
  284. }
  285. n.ClearChildren()
  286. gID := n.GetReference().(string)
  287. g, err := session.State.Guild(gID)
  288. if err != nil {
  289. return
  290. }
  291. cs := g.Channels
  292. sort.Slice(cs, func(i, j int) bool {
  293. return cs[i].Position < cs[j].Position
  294. })
  295. // Top-level channels
  296. ui.CreateTopLevelChannelsTreeNodes(session.State, n, cs)
  297. // Category channels
  298. ui.CreateCategoryChannelsTreeNodes(session.State, n, cs)
  299. // Second-level channels
  300. ui.CreateSecondLevelChannelsTreeNodes(session.State, guildsWidget, cs)
  301. default:
  302. cID := n.GetReference().(string)
  303. c, err := session.State.Channel(cID)
  304. if err != nil {
  305. return
  306. }
  307. if c.Type == discordgo.ChannelTypeGuildCategory {
  308. n.SetExpanded(!n.IsExpanded())
  309. } else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText {
  310. selectedChannel = c
  311. app.SetFocus(inputWidget)
  312. title := "#" + c.Name
  313. if c.Topic != "" {
  314. title += " - " + c.Topic
  315. }
  316. messagesWidget.
  317. Clear().
  318. SetTitle(title)
  319. go writeMessages(c.ID)
  320. }
  321. }
  322. }
  323. func writeMessages(cID string) {
  324. ms, err := session.ChannelMessages(cID, config.GetMessagesLimit, "", "", "")
  325. if err != nil {
  326. return
  327. }
  328. for i := len(ms) - 1; i >= 0; i-- {
  329. selectedChannel.Messages = append(selectedChannel.Messages, ms[i])
  330. util.WriteMessage(
  331. messagesWidget,
  332. ms[i],
  333. session.State.Ready.User.ID,
  334. )
  335. }
  336. }
  337. func onLoginFormLoginButtonSelected() {
  338. email := loginWidget.GetFormItem(0).(*tview.InputField).GetText()
  339. password := loginWidget.GetFormItem(1).(*tview.InputField).GetText()
  340. if email == "" || password == "" {
  341. return
  342. }
  343. session = newSession()
  344. // Login using the email and password
  345. lr, err := util.Login(session, email, password)
  346. if err != nil {
  347. panic(err)
  348. }
  349. if lr.Token != "" && !lr.MFA {
  350. app.
  351. SetRoot(mainFlex, true).
  352. SetFocus(guildsWidget)
  353. session.Token = lr.Token
  354. session.Identify.Token = lr.Token
  355. if err = session.Open(); err != nil {
  356. panic(err)
  357. }
  358. go keyring.Set("discordo", "token", lr.Token)
  359. } else if lr.MFA {
  360. // The account has MFA enabled, reattempt login with code and ticket.
  361. loginWidget = ui.NewLoginWidget(func() {
  362. code := loginWidget.GetFormItem(0).(*tview.InputField).GetText()
  363. if code == "" {
  364. return
  365. }
  366. lr, err = util.TOTP(session, code, lr.Ticket)
  367. if err != nil {
  368. panic(err)
  369. }
  370. app.
  371. SetRoot(mainFlex, true).
  372. SetFocus(guildsWidget)
  373. session.Token = lr.Token
  374. session.Identify.Token = lr.Token
  375. if err = session.Open(); err != nil {
  376. panic(err)
  377. }
  378. go keyring.Set("discordo", "token", lr.Token)
  379. }, true)
  380. app.SetRoot(loginWidget, true)
  381. }
  382. }