discordo/discordo.go

443 lines
10 KiB
Go

package main
import (
"fmt"
"sort"
"strings"
"github.com/atotto/clipboard"
"github.com/gdamore/tcell/v2"
"github.com/gen2brain/beeep"
"github.com/rigormorrtiss/discordgo"
"github.com/rigormorrtiss/discordo/ui"
"github.com/rigormorrtiss/discordo/util"
"github.com/rivo/tview"
"github.com/zalando/go-keyring"
)
var (
app *tview.Application
loginForm *tview.Form
guildsView *tview.TreeView
messagesView *tview.TextView
messageInputField *tview.InputField
mainFlex *tview.Flex
config *util.Config
session *discordgo.Session
selectedChannel *discordgo.Channel
selectedMessage *discordgo.Message
)
func main() {
config = util.NewConfig()
tview.Styles = config.Theme
app = tview.NewApplication()
app.EnableMouse(config.Mouse)
app.SetInputCapture(onAppInputCapture)
guildsView = ui.NewGuildsView()
guildsView.SetSelectedFunc(onGuildsTreeViewSelected)
messagesView = ui.NewMessagesView(app)
messagesView.SetInputCapture(onMessagesViewInputCapture)
messageInputField = ui.NewMessageInputField()
messageInputField.SetInputCapture(onMessageInputFieldInputCapture)
mainFlex = ui.NewMainFlex(
guildsView,
messagesView,
messageInputField,
)
token := config.Token
if t, _ := keyring.Get("discordo", "token"); t != "" {
token = t
}
if token != "" {
app.
SetRoot(mainFlex, true).
SetFocus(guildsView)
session = newSession()
session.Token = token
session.Identify.Token = token
if err := session.Open(); err != nil {
panic(err)
}
} else {
loginForm = ui.NewLoginForm(onLoginFormLoginButtonSelected, false)
app.SetRoot(loginForm, true)
}
if err := app.Run(); err != nil {
panic(err)
}
}
func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey {
if e.Modifiers() == tcell.ModAlt {
switch e.Rune() {
case '1':
app.SetFocus(guildsView)
case '2':
app.SetFocus(messagesView)
case '3':
app.SetFocus(messageInputField)
}
}
return e
}
func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) {
for i, m := range ms {
if mID == m.ID {
return i, m
}
}
return -1, nil
}
func onMessagesViewInputCapture(e *tcell.EventKey) *tcell.EventKey {
if selectedChannel == nil {
return nil
}
switch {
case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up
ms := selectedChannel.Messages
hs := messagesView.GetHighlights()
// If there are no currently highlighted message, highlight the last
// message in the TextView.
if len(hs) == 0 {
messagesView.
Highlight(ms[len(ms)-1].ID).
ScrollToHighlight()
} else {
// Find the index of the currently highlighted message in the
// *discordgo.Channel.Messages slice.
idx, _ := findByMessageID(ms, hs[0])
// If the index of the currently highlighted message is equal to
// zero
// (first message in the TextView), do not handle the event.
if idx == -1 || idx == 0 {
return nil
}
// Highlight the message just before the currently highlighted
// message.
messagesView.
Highlight(ms[idx-1].ID).
ScrollToHighlight()
}
return nil
case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down
ms := selectedChannel.Messages
hs := messagesView.GetHighlights()
// If there are no currently highlighted message, highlight the last
// message in the TextView.
if len(hs) == 0 {
messagesView.
Highlight(ms[len(ms)-1].ID).
ScrollToHighlight()
} else {
// Find the index of the highlighted message in the
// *discordgo.Channel.Messages slice.
idx, _ := findByMessageID(ms, hs[0])
// If the index of the currently highlighted message is equal to the
// total number of elements in the *discordgo.Channel.Messages
// slice, do not handle the event.
if idx == -1 || idx == len(ms)-1 {
return nil
}
// Highlight the message just after the currently highlighted
// message.
messagesView.
Highlight(ms[idx+1].ID).
ScrollToHighlight()
}
return nil
case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top
ms := selectedChannel.Messages
// Highlight the last message in the selectedChannel.Messages slice
// (the first message rendered in the TextView).
messagesView.
Highlight(ms[0].ID).
ScrollToHighlight()
case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom
ms := selectedChannel.Messages
// Highlight the first message in the selectedChannel.Messages slice
// (the last message rendered in the TextView).
messagesView.
Highlight(ms[len(ms)-1].ID).
ScrollToHighlight()
case e.Rune() == 'r': // Reply
ms := selectedChannel.Messages
hs := messagesView.GetHighlights()
if len(hs) == 0 {
return nil
}
_, selectedMessage = findByMessageID(ms, hs[0])
messageInputField.SetTitle(
"Replying to " + selectedMessage.Author.Username,
)
app.SetFocus(messageInputField)
}
return e
}
func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey {
// If the "Alt" modifier key is pressed, do not handle the event.
if e.Modifiers() == tcell.ModAlt {
return nil
}
switch e.Key() {
case tcell.KeyEnter:
if selectedChannel == nil {
return nil
}
t := strings.TrimSpace(messageInputField.GetText())
if t == "" {
return nil
}
if selectedMessage != nil {
messageInputField.SetTitle("")
go session.ChannelMessageSendReply(
selectedMessage.ChannelID,
t,
selectedMessage.Reference(),
)
selectedMessage = nil
} else {
go session.ChannelMessageSend(selectedChannel.ID, t)
}
messageInputField.SetText("")
case tcell.KeyCtrlV:
text, _ := clipboard.ReadAll()
text = messageInputField.GetText() + text
messageInputField.SetText(text)
case tcell.KeyEscape: // Cancel
messageInputField.SetTitle("")
selectedMessage = nil
}
return e
}
func newSession() *discordgo.Session {
s, err := discordgo.New()
if err != nil {
panic(err)
}
s.UserAgent = config.UserAgent
s.Identify.Compress = false
s.Identify.Intents = 0
s.Identify.LargeThreshold = 0
s.Identify.Properties.Device = ""
s.Identify.Properties.Browser = "Chrome"
s.Identify.Properties.OS = "Linux"
s.AddHandlerOnce(onSessionReady)
s.AddHandler(onSessionMessageCreate)
return s
}
func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) {
sort.Slice(r.Guilds, func(a, b int) bool {
found := false
for _, gID := range r.Settings.GuildPositions {
if found {
if gID == r.Guilds[b].ID {
return true
}
} else {
if gID == r.Guilds[a].ID {
found = true
}
}
}
return false
})
n := guildsView.GetRoot()
for _, g := range r.Guilds {
gn := tview.NewTreeNode(g.Name).
SetReference(g.ID)
n.AddChild(gn)
}
guildsView.SetCurrentNode(n)
}
func onSessionMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
if selectedChannel == nil {
selectedChannel = &discordgo.Channel{ID: ""}
}
if selectedChannel.ID != m.ChannelID {
if config.Notifications {
for _, u := range m.Mentions {
// If the client user account is mentioned in the message content, send a desktop notification with details.
if u.ID == s.State.User.ID {
g, err := s.State.Guild(m.GuildID)
if err != nil {
return
}
c, err := s.State.Channel(m.ChannelID)
if err != nil {
return
}
go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "")
return
}
}
}
return
}
selectedChannel.Messages = append(selectedChannel.Messages, m.Message)
util.WriteMessage(
messagesView,
m.Message,
session.State.Ready.User.ID,
)
}
func onGuildsTreeViewSelected(n *tview.TreeNode) {
selectedChannel = nil
selectedMessage = nil
messagesView.
Clear().
SetTitle("")
switch n.GetLevel() {
case 1:
if len(n.GetChildren()) != 0 {
n.SetExpanded(!n.IsExpanded())
return
}
n.ClearChildren()
gID := n.GetReference().(string)
g, _ := session.State.Guild(gID)
cs := g.Channels
sort.Slice(cs, func(i, j int) bool {
return cs[i].Position < cs[j].Position
})
// Top-level channels
ui.CreateTopLevelChannelsTreeNodes(session.State, n, cs)
// Category channels
ui.CreateCategoryChannelsTreeNodes(session.State, n, cs)
// Second-level channels
ui.CreateSecondLevelChannelsTreeNodes(session.State, guildsView, cs)
default:
cID := n.GetReference().(string)
c, _ := session.State.Channel(cID)
if c.Type == discordgo.ChannelTypeGuildCategory {
n.SetExpanded(!n.IsExpanded())
} else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText {
selectedChannel = c
app.SetFocus(messageInputField)
title := "#" + c.Name
if c.Topic != "" {
title += " - " + c.Topic
}
messagesView.
Clear().
SetTitle(title)
go writeMessages(c.ID)
}
}
}
func writeMessages(cID string) {
msgs, _ := session.ChannelMessages(cID, config.GetMessagesLimit, "", "", "")
for i := len(msgs) - 1; i >= 0; i-- {
selectedChannel.Messages = append(selectedChannel.Messages, msgs[i])
util.WriteMessage(
messagesView,
msgs[i],
session.State.Ready.User.ID,
)
}
}
func onLoginFormLoginButtonSelected() {
email := loginForm.GetFormItem(0).(*tview.InputField).GetText()
password := loginForm.GetFormItem(1).(*tview.InputField).GetText()
if email == "" || password == "" {
return
}
session = newSession()
// Try to login without TOTP
lr, err := util.Login(session, email, password)
if err != nil {
panic(err)
}
if lr.Token != "" && !lr.MFA {
app.
SetRoot(mainFlex, true).
SetFocus(guildsView)
session.Token = lr.Token
session.Identify.Token = lr.Token
if err = session.Open(); err != nil {
panic(err)
}
go keyring.Set("discordo", "token", lr.Token)
} else if lr.MFA {
loginForm = ui.NewLoginForm(func() {
code := loginForm.GetFormItem(0).(*tview.InputField).GetText()
if code == "" {
return
}
lr, err = util.TOTP(session, code, lr.Ticket)
if err != nil {
panic(err)
}
app.
SetRoot(mainFlex, true).
SetFocus(guildsView)
session.Token = lr.Token
session.Identify.Token = lr.Token
if err = session.Open(); err != nil {
panic(err)
}
go keyring.Set("discordo", "token", lr.Token)
}, true)
app.SetRoot(loginForm, true)
}
}