Implement scripting interface (#194)

This commit is contained in:
ayntgl 2022-08-27 22:46:19 +05:30 • committed by GitHub
parent 57471984ac
commit 41ed0bc604
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 745 additions and 494 deletions

View file

@ -72,7 +72,7 @@ sudo mv ./discordo /usr/local/bin
### Configuration
A default configuration file is created on first start-up at `$HOME/.config/discordo/config.toml` on Unix, `$HOME/Library/Application Support/discordo/config.toml` on Darwin, and `%AppData%/discordo/config.toml` on Windows. You can configure the default configuration path using the `config` command-line flag.
A default configuration file is created on first start-up at `$HOME/.config/discordo/config.lua` on Unix, `$HOME/Library/Application Support/discordo/config.lua` on Darwin, and `%AppData%/discordo/config.lua` on Windows. You can configure the default configuration path using the `config` command-line flag.
## Disclaimer

View file

@ -1,141 +1,77 @@
package config
import (
"log"
_ "embed"
"io"
"os"
"path/filepath"
"runtime"
"time"
"github.com/BurntSushi/toml"
lua "github.com/yuin/gopher-lua"
)
type IdentifyConfig struct {
UserAgent string `toml:"user_agent"`
Browser string `toml:"browser"`
BrowserVersion string `toml:"browser_version"`
Os string `toml:"os"`
}
type KeysConfig struct {
ToggleGuildsTree string `toml:"toggle_guilds_tree"`
ToggleChannelsTree string `toml:"toggle_channels_tree"`
ToggleMessagesPanel string `toml:"toggle_messages_panel"`
ToggleMessageInput string `toml:"toggle_message_input"`
OpenMessageActionsList string `toml:"open_message_actions_list"`
OpenExternalEditor string `toml:"open_external_editor"`
SelectPreviousMessage string `toml:"select_previous_message"`
SelectNextMessage string `toml:"select_next_message"`
SelectFirstMessage string `toml:"select_first_message"`
SelectLastMessage string `toml:"select_last_message"`
}
type ThemeConfig struct {
Background string `toml:"background"`
Border string `toml:"border"`
Title string `toml:"title"`
}
//go:embed config.lua
var LuaConfig []byte
// Config initializes a new Lua state, loads a configuration file, and defines essential micellaneous fields.
type Config struct {
Mouse bool `toml:"mouse"`
Timestamps bool `toml:"timestamps"`
MessagesLimit uint `toml:"messages_limit"`
Timezone string `toml:"timezone"`
TimeFormat string `toml:"time_format"`
AttachmentDownloadsDir string `toml:"attachment_downloads_dir"`
Identify IdentifyConfig `toml:"identify"`
Theme ThemeConfig `toml:"theme"`
Keys KeysConfig `toml:"keys"`
// Path is the path of the configuration file. Its value is the configuration directory until Load() is called.
Path string
State *lua.LState
}
func New() *Config {
func NewConfig(path string) *Config {
return &Config{
Mouse: true,
Timestamps: false,
MessagesLimit: 50,
Timezone: "Local",
TimeFormat: time.Stamp,
AttachmentDownloadsDir: UserDownloadsDir(),
Identify: IdentifyConfig{
UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
Browser: "Chrome",
BrowserVersion: "104.0.5112.102",
Os: "Linux",
},
Theme: ThemeConfig{
Background: "black",
Border: "white",
Title: "white",
},
Keys: KeysConfig{
ToggleGuildsTree: "Rune[g]",
ToggleChannelsTree: "Rune[c]",
ToggleMessagesPanel: "Rune[m]",
ToggleMessageInput: "Rune[i]",
OpenMessageActionsList: "Rune[a]",
OpenExternalEditor: "Ctrl+E",
SelectPreviousMessage: "Up",
SelectNextMessage: "Down",
SelectFirstMessage: "Home",
SelectLastMessage: "End",
},
Path: path,
State: lua.NewState(),
}
}
func (c *Config) Load(path string) error {
func (c *Config) Load() error {
// Create directories that do not exist and are mentioned in the path recursively.
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
err := os.MkdirAll(c.Path, os.ModePerm)
if err != nil {
return err
}
// If the configuration file does not exist already, create a new file; otherwise, open the existing file with read-write flag.
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, os.ModePerm)
c.Path = filepath.Join(c.Path, "config.lua")
// Open the existing configuration file with read-only flag.
f, err := os.Open(c.Path)
// If the configuration file does not exist, create a new configuration file with the read-write flag.
if os.IsNotExist(err) {
f, err = os.Create(c.Path)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(LuaConfig)
if err != nil {
return err
}
return f.Sync()
}
if err != nil {
return err
}
defer f.Close()
fi, err := f.Stat()
b, err := io.ReadAll(f)
if err != nil {
return err
}
// If the file is empty (the size of the file is zero), write the default configuration to the file.
if fi.Size() == 0 {
return toml.NewEncoder(f).Encode(c)
}
_, err = toml.NewDecoder(f).Decode(&c)
return err
LuaConfig = b
return nil
}
func DefaultPath() string {
path, err := os.UserConfigDir()
if err != nil {
log.Fatal(err)
}
func (c *Config) KeyLua(s *lua.LState) int {
keyTable := s.NewTable()
keyTable.RawSetString("name", s.Get(1))
keyTable.RawSetString("description", s.Get(2))
keyTable.RawSetString("action", s.Get(3))
path += "/discordo/config.toml"
return path
}
func UserDownloadsDir() string {
// We try to set the download folder location to the default Downloads folder
var dlloc string
if runtime.GOOS == "windows" {
h, _ := os.UserHomeDir()
dlloc = h + "\\Downloads"
} else if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
h, _ := os.UserHomeDir()
dlloc = h + "/Downloads"
} else {
dlloc = os.TempDir() // Very lame fallback, I know
}
return dlloc
s.Push(keyTable) // Push the result
return 1 // Number of results
}

127
config/config.lua Normal file
View file

@ -0,0 +1,127 @@
local string = require "string"
-- Whether the mouse is usable or not.
mouse = true
-- The maximum number of messages to fetch and display on the messages panel.
-- Its value must not be lesser than 1 and greater than 100.
messagesLimit = 50
-- Whether to display the timestamp of the message beside the displayed message or not.
timestamps = false
-- The timezone of the timestamps.
-- Learn more: https://pkg.go.dev/time#LoadLocation
timezone = "Local"
-- A textual representation of the time value formatted according to the layout defined by its value.
-- Learn more: https://pkg.go.dev/time#Layout
timeFormat = "3:04PM"
browser = "Chrome"
browserVersion = "104.0.5112.102"
oss = "Linux"
-- Identify properties are connection properties that are dispatched in the IDENTIFY gateway event to trigger the initial handshake with the gateway.
-- Learn more: https://discord.com/developers/docs/topics/gateway#identify
identifyProperties = {
userAgent = string.format(
"Mozilla/5.0 (X11; %s x86_64) AppleWebKit/537.36 (KHTML, like Gecko) %s/%s Safari/537.36",
oss,
browser,
browserVersion
),
browser = browser,
browserVersion = browserVersion,
os = oss
}
-- Keybindings
keys = {
application = {
key(
"Rune[g]",
"Focus the guilds tree widget.",
function(core, event)
core.Application:SetFocus(core.GuildsTree)
end
),
key(
"Rune[c]",
"Focus the channels tree widget.",
function(core, event)
core.Application:SetFocus(core.ChannelsTree)
end
),
key(
"Rune[m]",
"Focus the messages panel widget.",
function(core, event)
core.Application:SetFocus(core.MessagesPanel)
end
),
key(
"Rune[i]",
"Focus the message input widget.",
function(core, event)
core.Application:SetFocus(core.MessageInput)
end
)
},
messagesPanel = {
key(
"Rune[a]",
"Open the message actions list widget.",
function(core, event)
return openMessageActionsList()
end
),
key(
"Up",
"Select the previous message.",
function(core, event)
return selectPreviousMessage()
end
),
key(
"Down",
"Select the next message.",
function(core, event)
return selectNextMessage()
end
),
key(
"Home",
"Select the first message.",
function(core, event)
return selectFirstMessage()
end
),
key(
"End",
"Select the last message.",
function(core, event)
return selectLastMessage()
end
)
},
messageInput = {
key(
"Ctrl+E",
"Open the external editor.",
function()
return openExternalEditor()
end
),
key(
"Ctrl+V",
"Paste the clipboard content.",
function()
return pasteClipboardContent()
end
)
}
}
-- Theme
theme = {background = "#1F2430", border = "white", title = "white"}

7
go.mod
View file

@ -3,14 +3,15 @@ module github.com/ayntgl/discordo
go 1.18
require (
github.com/BurntSushi/toml v1.2.0
github.com/alecthomas/kong v0.6.1
github.com/atotto/clipboard v0.1.4
github.com/diamondburned/arikawa/v3 v3.0.0
github.com/diamondburned/arikawa/v3 v3.1.0
github.com/gdamore/tcell/v2 v2.5.3
github.com/rivo/tview v0.0.0-20220812085834-0e6b21a48e96
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64
github.com/zalando/go-keyring v0.2.1
layeh.com/gopher-luar v1.0.10
)
require (
@ -24,7 +25,7 @@ require (
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.3.4 // indirect
golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24 // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect

17
go.sum
View file

@ -1,5 +1,3 @@
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/alecthomas/kong v0.6.1 h1:1kNhcFepkR+HmasQpbiKDLylIL8yh5B5y1zPp5bJimA=
github.com/alecthomas/kong v0.6.1/go.mod h1:JfHWDzLmbh/puW6I3V7uWenoh56YNVONW+w8eKeUr9I=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
@ -8,6 +6,9 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
@ -16,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diamondburned/arikawa/v3 v3.0.0 h1:VbdX1DtrBLE752IJftZHInVy6v8I3T8vhN9rKGvO6AY=
github.com/diamondburned/arikawa/v3 v3.0.0/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY=
github.com/diamondburned/arikawa/v3 v3.1.0 h1:M7ZyjoCM4o1+rzai2NMXjOSukSrPdP6OxV68WKvKZ6E=
github.com/diamondburned/arikawa/v3 v3.1.0/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
@ -49,17 +52,21 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zalando/go-keyring v0.2.1 h1:MBRN/Z8H4U5wEKXiD67YbDAr5cj/DOStmSga70/2qKc=
github.com/zalando/go-keyring v0.2.1/go.mod h1:g63M2PPn0w5vjmEbwAX3ib5I+41zdm4esSETOn9Y6Dw=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211001092434-39dca1131b70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24 h1:TyKJRhyo17yWxOMCTHKWrc5rddHORMlnZ/j57umaUd8=
golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
@ -78,3 +85,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
layeh.com/gopher-luar v1.0.10 h1:55b0mpBhN9XSshEd2Nz6WsbYXctyBT35azk4POQNSXo=
layeh.com/gopher-luar v1.0.10/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk=

74
main.go
View file

@ -2,12 +2,14 @@ package main
import (
"log"
"os"
"path/filepath"
"github.com/alecthomas/kong"
"github.com/ayntgl/discordo/config"
"github.com/ayntgl/discordo/ui"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
lua "github.com/yuin/gopher-lua"
"github.com/zalando/go-keyring"
)
@ -18,7 +20,7 @@ const (
var cli struct {
Token string `help:"The authentication token."`
Config string `help:"The path of the configuration file." type:"path"`
Config string `help:"The path to the configuration directory." type:"path"`
}
func main() {
@ -31,30 +33,28 @@ func main() {
// Defaults
if cli.Config == "" {
cli.Config = config.DefaultPath()
path, err := os.UserConfigDir()
if err != nil {
log.Fatal(err)
}
cli.Config = filepath.Join(path, name)
}
if cli.Token == "" {
cli.Token, _ = keyring.Get(name, "token")
}
c := config.New()
err := c.Load(cli.Config)
if err != nil {
log.Fatal(err)
}
app := ui.NewApp(cli.Token, c)
c := ui.NewCore(cli.Config)
if cli.Token != "" {
err := app.Connect()
err := c.Run(cli.Token)
if err != nil {
log.Fatal(err)
}
app.DrawMainFlex()
app.SetRoot(app.MainFlex, true)
app.SetFocus(app.GuildsTree)
c.DrawMainFlex()
c.Application.SetRoot(c.MainFlex, true)
c.Application.SetFocus(c.GuildsTree)
} else {
loginForm := ui.NewLoginForm(false)
loginForm.AddButton("Login", func() {
@ -65,21 +65,21 @@ func main() {
}
// Login using the email and password only
lr, err := app.State.Login(email, password)
lr, err := c.State.Login(email, password)
if err != nil {
log.Fatal(err)
}
if lr.Token != "" && !lr.MFA {
app.State.Token = lr.Token
err = app.Connect()
err = c.Run(lr.Token)
if err != nil {
log.Fatal(err)
}
app.DrawMainFlex()
app.SetRoot(app.MainFlex, true)
app.SetFocus(app.GuildsTree)
c.DrawMainFlex()
c.Application.SetRoot(c.MainFlex, true)
c.Application.SetFocus(c.GuildsTree)
go keyring.Set(name, "token", lr.Token)
} else {
// The account has MFA enabled, reattempt login with MFA code and ticket.
@ -90,29 +90,28 @@ func main() {
return
}
lr, err = app.State.TOTP(code, lr.Ticket)
lr, err = c.State.TOTP(code, lr.Ticket)
if err != nil {
log.Fatal(err)
}
app.State.Token = lr.Token
err = app.Connect()
err = c.Run(lr.Token)
if err != nil {
log.Fatal(err)
}
app.DrawMainFlex()
app.SetRoot(app.MainFlex, true)
app.SetFocus(app.GuildsTree)
c.DrawMainFlex()
c.Application.SetRoot(c.MainFlex, true)
c.Application.SetFocus(c.GuildsTree)
go keyring.Set(name, "token", lr.Token)
})
app.SetRoot(mfaLoginForm, true)
c.Application.SetRoot(mfaLoginForm, true)
}
})
app.SetRoot(loginForm, true)
c.Application.SetRoot(loginForm, true)
}
tview.Borders.TopLeftFocus = tview.Borders.TopLeft
@ -128,11 +127,20 @@ func main() {
tview.Borders.Horizontal = 0
tview.Borders.Vertical = 0
tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(app.Config.Theme.Background)
tview.Styles.BorderColor = tcell.GetColor(app.Config.Theme.Border)
tview.Styles.TitleColor = tcell.GetColor(app.Config.Theme.Title)
themeTable, ok := c.Config.State.GetGlobal("theme").(*lua.LTable)
if !ok {
return
}
err = app.Run()
background := themeTable.RawGetString("background")
border := themeTable.RawGetString("border")
title := themeTable.RawGetString("title")
tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(lua.LVAsString(background))
tview.Styles.BorderColor = tcell.GetColor(lua.LVAsString(border))
tview.Styles.TitleColor = tcell.GetColor(lua.LVAsString(title))
err := c.Application.Run()
if err != nil {
log.Fatal(err)
}

205
ui/app.go
View file

@ -1,205 +0,0 @@
package ui
import (
"context"
"strings"
"github.com/ayntgl/discordo/config"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/state"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type App struct {
*tview.Application
MainFlex *tview.Flex
GuildsTree *GuildsTree
ChannelsTree *ChannelsTree
MessagesPanel *MessagesPanel
MessageInput *MessageInput
Config *config.Config
State *state.State
}
func NewApp(token string, c *config.Config) *App {
app := &App{
Application: tview.NewApplication(),
MainFlex: tview.NewFlex(),
Config: c,
State: state.NewWithIdentifier(gateway.NewIdentifier(gateway.IdentifyCommand{
Token: token,
Intents: nil,
Properties: gateway.IdentifyProperties{
Browser: c.Identify.Browser,
BrowserUserAgent: c.Identify.UserAgent,
BrowserVersion: c.Identify.BrowserVersion,
OS: c.Identify.Os,
},
// The official client sets the compress field as false.
Compress: false,
})),
}
app.GuildsTree = NewGuildsTree(app)
app.ChannelsTree = NewChannelsTree(app)
app.MessagesPanel = NewMessagesPanel(app)
app.MessageInput = NewMessageInput(app)
app.EnableMouse(app.Config.Mouse)
app.MainFlex.SetInputCapture(app.onInputCapture)
return app
}
func (app *App) Connect() error {
// For user accounts, all of the guilds, the user is in, are dispatched in the READY gateway event.
// Whereas, for bot accounts, the guilds are dispatched discretely in the GUILD_CREATE gateway events.
if !strings.HasPrefix(app.State.Token, "Bot") {
api.UserAgent = app.Config.Identify.UserAgent
app.State.AddHandler(app.onStateReady)
}
app.State.AddHandler(app.onStateGuildCreate)
app.State.AddHandler(app.onStateGuildDelete)
app.State.AddHandler(app.onStateMessageCreate)
return app.State.Open(context.Background())
}
func (app *App) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
if app.MessageInput.HasFocus() {
return e
}
if app.MainFlex.GetItemCount() != 0 {
switch e.Name() {
case app.Config.Keys.ToggleGuildsTree:
app.SetFocus(app.GuildsTree)
return nil
case app.Config.Keys.ToggleChannelsTree:
app.SetFocus(app.ChannelsTree)
return nil
case app.Config.Keys.ToggleMessagesPanel:
app.SetFocus(app.MessagesPanel)
return nil
case app.Config.Keys.ToggleMessageInput:
app.SetFocus(app.MessageInput)
return nil
}
}
return e
}
func (app *App) DrawMainFlex() {
leftFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(app.GuildsTree, 10, 1, false).
AddItem(app.ChannelsTree, 0, 1, false)
rightFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(app.MessagesPanel, 0, 1, false).
AddItem(app.MessageInput, 3, 1, false)
app.MainFlex.
AddItem(leftFlex, 0, 1, false).
AddItem(rightFlex, 0, 4, false)
}
func (app *App) onStateReady(r *gateway.ReadyEvent) {
rootNode := app.GuildsTree.GetRoot()
for _, gf := range r.UserSettings.GuildFolders {
if gf.ID == 0 {
for _, gID := range gf.GuildIDs {
g, err := app.State.Cabinet.Guild(gID)
if err != nil {
return
}
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
rootNode.AddChild(guildNode)
}
} else {
var b strings.Builder
if gf.Color != discord.NullColor {
b.WriteByte('[')
b.WriteString(gf.Color.String())
b.WriteByte(']')
} else {
b.WriteString("[#ED4245]")
}
if gf.Name != "" {
b.WriteString(gf.Name)
} else {
b.WriteString("Folder")
}
b.WriteString("[-]")
folderNode := tview.NewTreeNode(b.String())
rootNode.AddChild(folderNode)
for _, gID := range gf.GuildIDs {
g, err := app.State.Cabinet.Guild(gID)
if err != nil {
return
}
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
folderNode.AddChild(guildNode)
}
}
}
app.GuildsTree.SetCurrentNode(rootNode)
app.SetFocus(app.GuildsTree)
}
func (app *App) onStateGuildCreate(g *gateway.GuildCreateEvent) {
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
rootNode := app.GuildsTree.GetRoot()
rootNode.AddChild(guildNode)
app.Draw()
}
func (app *App) onStateGuildDelete(g *gateway.GuildDeleteEvent) {
rootNode := app.GuildsTree.GetRoot()
var parentNode *tview.TreeNode
rootNode.Walk(func(node, _ *tview.TreeNode) bool {
if node.GetReference() == g.ID {
parentNode = node
return false
}
return true
})
if parentNode != nil {
rootNode.RemoveChild(parentNode)
}
app.Draw()
}
func (app *App) onStateMessageCreate(m *gateway.MessageCreateEvent) {
if app.ChannelsTree.SelectedChannel != nil && app.ChannelsTree.SelectedChannel.ID == m.ChannelID {
_, err := app.MessagesPanel.Write(buildMessage(app, m.Message))
if err != nil {
return
}
if len(app.MessagesPanel.GetHighlights()) == 0 {
app.MessagesPanel.ScrollToEnd()
}
}
}

View file

@ -6,9 +6,10 @@ import (
"time"
"github.com/diamondburned/arikawa/v3/discord"
lua "github.com/yuin/gopher-lua"
)
func buildMessage(app *App, m discord.Message) []byte {
func buildMessage(c *Core, m discord.Message) []byte {
var b strings.Builder
switch m.Type {
@ -20,25 +21,30 @@ func buildMessage(app *App, m discord.Message) []byte {
b.WriteString(m.ID.String())
b.WriteString("\"]")
// Build the message associated with crosspost, channel follow add, pin, or a reply.
buildReferencedMessage(&b, m.ReferencedMessage, app.State.Ready().User.ID)
buildReferencedMessage(&b, m.ReferencedMessage, c.State.Ready().User.ID)
if app.Config.Timestamps {
loc, err := time.LoadLocation(app.Config.Timezone)
timestamps := c.Config.State.GetGlobal("timestamps")
if lua.LVAsBool(timestamps) {
timezone := c.Config.State.GetGlobal("timezone")
loc, err := time.LoadLocation(lua.LVAsString(timezone))
if err != nil {
return nil
}
timeFormat := c.Config.State.GetGlobal("timeFormat")
b.WriteString("[::d]")
b.WriteString(m.Timestamp.Time().In(loc).Format(app.Config.TimeFormat))
b.WriteString(m.Timestamp.Time().In(loc).Format(lua.LVAsString(timeFormat)))
b.WriteString("[::-]")
b.WriteByte(' ')
}
// Build the author of this message.
buildAuthor(&b, m.Author, app.State.Ready().User.ID)
buildAuthor(&b, m.Author, c.State.Ready().User.ID)
// Build the contents of the message.
buildContent(&b, m, app.State.Ready().User.ID)
buildContent(&b, m, c.State.Ready().User.ID)
if m.EditedTimestamp.IsValid() {
b.WriteString(" [::d](edited)[::-]")

View file

@ -5,19 +5,20 @@ import (
"github.com/diamondburned/arikawa/v3/discord"
"github.com/rivo/tview"
lua "github.com/yuin/gopher-lua"
)
type ChannelsTree struct {
*tview.TreeView
app *App
SelectedChannel *discord.Channel
core *Core
}
func NewChannelsTree(app *App) *ChannelsTree {
func NewChannelsTree(c *Core) *ChannelsTree {
ct := &ChannelsTree{
TreeView: tview.NewTreeView(),
app: app,
core: c,
}
ct.SetRoot(tview.NewTreeNode(""))
@ -34,15 +35,15 @@ func NewChannelsTree(app *App) *ChannelsTree {
func (ct *ChannelsTree) onSelected(node *tview.TreeNode) {
ct.SelectedChannel = nil
ct.app.MessagesPanel.SelectedMessage = -1
ct.app.MessagesPanel.
ct.core.MessagesPanel.SelectedMessage = -1
ct.core.MessagesPanel.
Highlight().
Clear().
SetTitle("")
ct.app.MessageInput.SetText("")
ct.core.MessageInput.SetText("")
ref := node.GetReference()
c, err := ct.app.State.Cabinet.Channel(ref.(discord.ChannelID))
c, err := ct.core.State.Cabinet.Channel(ref.(discord.ChannelID))
if err != nil {
return
}
@ -54,29 +55,30 @@ func (ct *ChannelsTree) onSelected(node *tview.TreeNode) {
}
ct.SelectedChannel = c
ct.app.SetFocus(ct.app.MessageInput)
ct.core.Application.SetFocus(ct.core.MessageInput)
title := channelToString(*c)
if c.Topic != "" {
title += " - " + parseMarkdown(c.Topic)
}
ct.app.MessagesPanel.SetTitle(title)
ct.core.MessagesPanel.SetTitle(title)
go func() {
messagesLimit := ct.core.Config.State.GetGlobal("messagesLimit")
// The returned slice will be sorted from latest to oldest.
ms, err := ct.app.State.Messages(c.ID, ct.app.Config.MessagesLimit)
ms, err := ct.core.State.Messages(c.ID, uint(lua.LVAsNumber(messagesLimit)))
if err != nil {
return
}
for i := len(ms) - 1; i >= 0; i-- {
_, err = ct.app.MessagesPanel.Write(buildMessage(ct.app, ms[i]))
_, err = ct.core.MessagesPanel.Write(buildMessage(ct.core, ms[i]))
if err != nil {
return
}
}
ct.app.MessagesPanel.ScrollToEnd()
ct.core.MessagesPanel.ScrollToEnd()
}()
}
@ -88,7 +90,7 @@ func (ct *ChannelsTree) createChannelNode(c discord.Channel) *tview.TreeNode {
}
func (ct *ChannelsTree) createPrivateChannelNodes(rootNode *tview.TreeNode) {
cs, err := ct.app.State.Cabinet.PrivateChannels()
cs, err := ct.core.State.Cabinet.PrivateChannels()
if err != nil {
return
}
@ -103,7 +105,7 @@ func (ct *ChannelsTree) createPrivateChannelNodes(rootNode *tview.TreeNode) {
}
func (ct *ChannelsTree) createGuildChannelNodes(rootNode *tview.TreeNode, gID discord.GuildID) {
cs, err := ct.app.State.Cabinet.Channels(gID)
cs, err := ct.core.State.Cabinet.Channels(gID)
if err != nil {
return
}

261
ui/core.go Normal file
View file

@ -0,0 +1,261 @@
package ui
import (
"context"
"strings"
"github.com/ayntgl/discordo/config"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/state"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
lua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar"
)
// Core is responsible for the following:
// - Initialization of the application, UI elements, configuration, and state.
// - Configuration of the application and state when Run is called.
// - Management of the application and state.
type Core struct {
Application *tview.Application
MainFlex *tview.Flex
GuildsTree *GuildsTree
ChannelsTree *ChannelsTree
MessagesPanel *MessagesPanel
MessageInput *MessageInput
Config *config.Config
State *state.State
}
func NewCore(path string) *Core {
c := &Core{
Application: tview.NewApplication(),
MainFlex: tview.NewFlex(),
Config: config.NewConfig(path),
}
c.MainFlex.SetInputCapture(c.onInputCapture)
c.GuildsTree = NewGuildsTree(c)
c.ChannelsTree = NewChannelsTree(c)
c.MessagesPanel = NewMessagesPanel(c)
c.MessageInput = NewMessageInput(c)
return c
}
func (c *Core) Run(token string) error {
err := c.Config.Load()
if err != nil {
return err
}
c.register()
err = c.Config.State.DoString(string(config.LuaConfig))
if err != nil {
return err
}
c.Application.EnableMouse(lua.LVAsBool(c.Config.State.GetGlobal("mouse")))
identifyProperties, ok := c.Config.State.GetGlobal("identifyProperties").(*lua.LTable)
if !ok {
identifyProperties = c.Config.State.NewTable()
}
userAgent := lua.LVAsString(identifyProperties.RawGetString("userAgent"))
c.State = state.NewWithIdentifier(gateway.NewIdentifier(gateway.IdentifyCommand{
Token: token,
Intents: nil,
Properties: gateway.IdentifyProperties{
Browser: lua.LVAsString(identifyProperties.RawGetString("browser")),
BrowserVersion: lua.LVAsString(identifyProperties.RawGetString("browserVersion")),
BrowserUserAgent: userAgent,
OS: lua.LVAsString(identifyProperties.RawGetString("os")),
},
// The official client sets the compress field as false.
Compress: false,
}))
// For user accounts, all of the guilds, the user is in, are dispatched in the READY gateway event.
// Whereas, for bot accounts, the guilds are dispatched discretely in the GUILD_CREATE gateway events.
if !strings.HasPrefix(c.State.Token, "Bot") {
api.UserAgent = userAgent
c.State.AddHandler(c.onStateReady)
}
c.State.AddHandler(c.onStateGuildCreate)
c.State.AddHandler(c.onStateGuildDelete)
c.State.AddHandler(c.onStateMessageCreate)
return c.State.Open(context.Background())
}
func (c *Core) register() {
c.Config.State.SetGlobal("key", c.Config.State.NewFunction(c.Config.KeyLua))
// Messages panel
c.Config.State.SetGlobal("openMessageActionsList", c.Config.State.NewFunction(c.MessagesPanel.openMessageActionsListLua))
c.Config.State.SetGlobal("selectPreviousMessage", c.Config.State.NewFunction(c.MessagesPanel.selectPreviousMessageLua))
c.Config.State.SetGlobal("selectNextMessage", c.Config.State.NewFunction(c.MessagesPanel.selectNextMessageLua))
c.Config.State.SetGlobal("selectFirstMessage", c.Config.State.NewFunction(c.MessagesPanel.selectFirstMessageLua))
c.Config.State.SetGlobal("selectLastMessage", c.Config.State.NewFunction(c.MessagesPanel.selectLastMessageLua))
// Message input
c.Config.State.SetGlobal("openExternalEditor", c.Config.State.NewFunction(c.MessageInput.openExternalEditorLua))
c.Config.State.SetGlobal("pasteClipboardContent", c.Config.State.NewFunction(c.MessageInput.pasteClipboardContentLua))
}
func (c *Core) DrawMainFlex() {
leftFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(c.GuildsTree, 10, 1, false).
AddItem(c.ChannelsTree, 0, 1, false)
rightFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(c.MessagesPanel, 0, 1, false).
AddItem(c.MessageInput, 3, 1, false)
c.MainFlex.
AddItem(leftFlex, 0, 1, false).
AddItem(rightFlex, 0, 4, false)
}
func (c *Core) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
if c.MessageInput.HasFocus() {
return e
}
// If the main flex is nil, that is, it is not initialized yet, then the login form is currently focused.
if c.MainFlex == nil {
return e
}
keysTable, ok := c.Config.State.GetGlobal("keys").(*lua.LTable)
if !ok {
return e
}
applicationTable, ok := keysTable.RawGetString("application").(*lua.LTable)
if !ok {
return e
}
var fn lua.LValue
applicationTable.ForEach(func(k, v lua.LValue) {
keyTable := v.(*lua.LTable)
if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) {
fn = keyTable.RawGetString("action")
}
})
c.Config.State.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, luar.New(c.Config.State, c), luar.New(c.Config.State, e))
// Returned value
ret, ok := c.Config.State.Get(-1).(*lua.LUserData)
if !ok {
return e
}
// Remove returned value
c.Config.State.Pop(1)
return ret.Value.(*tcell.EventKey)
}
func (c *Core) onStateReady(r *gateway.ReadyEvent) {
rootNode := c.GuildsTree.GetRoot()
for _, gf := range r.UserSettings.GuildFolders {
if gf.ID == 0 {
for _, gID := range gf.GuildIDs {
g, err := c.State.Cabinet.Guild(gID)
if err != nil {
return
}
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
rootNode.AddChild(guildNode)
}
} else {
var b strings.Builder
if gf.Color != discord.NullColor {
b.WriteByte('[')
b.WriteString(gf.Color.String())
b.WriteByte(']')
} else {
b.WriteString("[#ED4245]")
}
if gf.Name != "" {
b.WriteString(gf.Name)
} else {
b.WriteString("Folder")
}
b.WriteString("[-]")
folderNode := tview.NewTreeNode(b.String())
rootNode.AddChild(folderNode)
for _, gID := range gf.GuildIDs {
g, err := c.State.Cabinet.Guild(gID)
if err != nil {
return
}
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
folderNode.AddChild(guildNode)
}
}
}
c.GuildsTree.SetCurrentNode(rootNode)
c.Application.SetFocus(c.GuildsTree)
}
func (c *Core) onStateGuildCreate(g *gateway.GuildCreateEvent) {
guildNode := tview.NewTreeNode(g.Name)
guildNode.SetReference(g.ID)
rootNode := c.GuildsTree.GetRoot()
rootNode.AddChild(guildNode)
c.Application.Draw()
}
func (c *Core) onStateGuildDelete(g *gateway.GuildDeleteEvent) {
rootNode := c.GuildsTree.GetRoot()
var parentNode *tview.TreeNode
rootNode.Walk(func(node, _ *tview.TreeNode) bool {
if node.GetReference() == g.ID {
parentNode = node
return false
}
return true
})
if parentNode != nil {
rootNode.RemoveChild(parentNode)
}
c.Application.Draw()
}
func (c *Core) onStateMessageCreate(m *gateway.MessageCreateEvent) {
if c.ChannelsTree.SelectedChannel != nil && c.ChannelsTree.SelectedChannel.ID == m.ChannelID {
_, err := c.MessagesPanel.Write(buildMessage(c, m.Message))
if err != nil {
return
}
if len(c.MessagesPanel.GetHighlights()) == 0 {
c.MessagesPanel.ScrollToEnd()
}
}
}

View file

@ -7,13 +7,13 @@ import (
type GuildsTree struct {
*tview.TreeView
app *App
core *Core
}
func NewGuildsTree(app *App) *GuildsTree {
func NewGuildsTree(c *Core) *GuildsTree {
gt := &GuildsTree{
TreeView: tview.NewTreeView(),
app: app,
core: c,
}
rootNode := tview.NewTreeNode("")
@ -32,15 +32,15 @@ func NewGuildsTree(app *App) *GuildsTree {
}
func (gt *GuildsTree) onSelected(node *tview.TreeNode) {
gt.app.ChannelsTree.SelectedChannel = nil
gt.app.MessagesPanel.SelectedMessage = -1
rootNode := gt.app.ChannelsTree.GetRoot()
gt.core.ChannelsTree.SelectedChannel = nil
gt.core.MessagesPanel.SelectedMessage = -1
rootNode := gt.core.ChannelsTree.GetRoot()
rootNode.ClearChildren()
gt.app.MessagesPanel.
gt.core.MessagesPanel.
Highlight().
Clear().
SetTitle("")
gt.app.MessageInput.SetText("")
gt.core.MessageInput.SetText("")
// If the selected node has children (guild folder), expand the selected node if it is collapsed, otherwise collapse.
if len(node.GetChildren()) != 0 {
@ -51,11 +51,11 @@ func (gt *GuildsTree) onSelected(node *tview.TreeNode) {
ref := node.GetReference()
// If the reference of the selected node is nil, it must be the direct messages node.
if ref == nil {
gt.app.ChannelsTree.createPrivateChannelNodes(rootNode)
gt.core.ChannelsTree.createPrivateChannelNodes(rootNode)
} else { // Guild
gt.app.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
gt.core.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
}
gt.app.ChannelsTree.SetCurrentNode(rootNode)
gt.app.SetFocus(gt.app.ChannelsTree)
gt.core.ChannelsTree.SetCurrentNode(rootNode)
gt.core.Application.SetFocus(gt.core.ChannelsTree)
}

View file

@ -17,26 +17,25 @@ var linkRegex = regexp.MustCompile("https?://.+")
type MessageActionsList struct {
*tview.List
app *App
core *Core
message *discord.Message
}
func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList {
func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList {
mal := &MessageActionsList{
List: tview.NewList(),
app: app,
core: c,
message: m,
}
mal.ShowSecondaryText(false)
mal.SetDoneFunc(func() {
app.
SetRoot(app.MainFlex, true).
SetFocus(app.MessagesPanel)
c.Application.SetRoot(c.MainFlex, true)
c.Application.SetFocus(c.MessagesPanel)
})
// If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
if hasPermission(app.State, app.ChannelsTree.SelectedChannel.ID, discord.PermissionSendMessages) {
if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionSendMessages) {
mal.AddItem("Reply", "", 'r', mal.replyAction)
mal.AddItem("Mention Reply", "", 'R', mal.mentionReplyAction)
}
@ -54,8 +53,8 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList {
go open.Run(l)
}
app.SetRoot(app.MainFlex, true)
app.SetFocus(app.MessagesPanel)
c.Application.SetRoot(c.MainFlex, true)
c.Application.SetFocus(c.MessagesPanel)
})
}
@ -66,7 +65,7 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList {
}
// If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
if hasPermission(app.State, app.ChannelsTree.SelectedChannel.ID, discord.PermissionManageMessages) {
if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionManageMessages) {
mal.AddItem("Delete", "", 'd', mal.deleteAction)
}
@ -82,35 +81,32 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList {
}
func (mal *MessageActionsList) replyAction() {
mal.app.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag())
mal.core.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag())
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessageInput)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessageInput)
}
func (mal *MessageActionsList) mentionReplyAction() {
mal.app.MessageInput.SetTitle("[@] Replying to " + mal.message.Author.Tag())
mal.core.MessageInput.SetTitle("[@] Replying to " + mal.message.Author.Tag())
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessageInput)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessageInput)
}
func (mal *MessageActionsList) selectReplyAction() {
ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
ms, err := mal.core.State.Cabinet.Messages(mal.message.ChannelID)
if err != nil {
return
}
mal.app.MessagesPanel.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID)
mal.app.MessagesPanel.
mal.core.MessagesPanel.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID)
mal.core.MessagesPanel.
Highlight(mal.message.ReferencedMessage.ID.String()).
ScrollToHighlight()
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}
func (mal *MessageActionsList) openAttachmentAction() {
@ -136,14 +132,19 @@ func (mal *MessageActionsList) openAttachmentAction() {
go open.Run(f.Name())
}
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}
func (mal *MessageActionsList) downloadAttachmentAction() {
for _, a := range mal.message.Attachments {
f, err := os.Create(filepath.Join(mal.app.Config.AttachmentDownloadsDir, a.Filename))
path, err := os.UserHomeDir()
if err != nil {
path = os.TempDir()
}
path = filepath.Join(path, "Downloads", a.Filename)
f, err := os.Create(path)
if err != nil {
return
}
@ -162,40 +163,38 @@ func (mal *MessageActionsList) downloadAttachmentAction() {
f.Write(d)
}
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}
func (mal *MessageActionsList) deleteAction() {
mal.app.MessagesPanel.Clear()
mal.core.MessagesPanel.Clear()
err := mal.app.State.MessageRemove(mal.message.ChannelID, mal.message.ID)
err := mal.core.State.MessageRemove(mal.message.ChannelID, mal.message.ID)
if err != nil {
return
}
err = mal.app.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown")
err = mal.core.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown")
if err != nil {
return
}
// The returned slice will be sorted from latest to oldest.
ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID)
ms, err := mal.core.State.Cabinet.Messages(mal.message.ChannelID)
if err != nil {
return
}
for i := len(ms) - 1; i >= 0; i-- {
_, err = mal.app.MessagesPanel.Write(buildMessage(mal.app, ms[i]))
_, err = mal.core.MessagesPanel.Write(buildMessage(mal.core, ms[i]))
if err != nil {
return
}
}
mal.app.
SetRoot(mal.app.MainFlex, true).
SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}
func (mal *MessageActionsList) copyContentAction() {
@ -204,8 +203,8 @@ func (mal *MessageActionsList) copyContentAction() {
return
}
mal.app.SetRoot(mal.app.MainFlex, true)
mal.app.SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}
func (mal *MessageActionsList) copyIDAction() {
@ -214,6 +213,6 @@ func (mal *MessageActionsList) copyIDAction() {
return
}
mal.app.SetRoot(mal.app.MainFlex, true)
mal.app.SetFocus(mal.app.MessagesPanel)
mal.core.Application.SetRoot(mal.core.MainFlex, true)
mal.core.Application.SetFocus(mal.core.MessagesPanel)
}

View file

@ -12,17 +12,19 @@ import (
"github.com/diamondburned/arikawa/v3/utils/json/option"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
lua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar"
)
type MessageInput struct {
*tview.InputField
app *App
core *Core
}
func NewMessageInput(app *App) *MessageInput {
func NewMessageInput(c *Core) *MessageInput {
mi := &MessageInput{
InputField: tview.NewInputField(),
app: app,
core: c,
}
mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
@ -38,29 +40,65 @@ func NewMessageInput(app *App) *MessageInput {
}
func (mi *MessageInput) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
keysTable, ok := mi.core.Config.State.GetGlobal("keys").(*lua.LTable)
if !ok {
return e
}
messageInputTable, ok := keysTable.RawGetString("messageInput").(*lua.LTable)
if !ok {
return e
}
var fn lua.LValue
messageInputTable.ForEach(func(k, v lua.LValue) {
keyTable := v.(*lua.LTable)
if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) {
fn = keyTable.RawGetString("action")
}
})
if fn != nil {
mi.core.Config.State.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, luar.New(mi.core.Config.State, mi.core), luar.New(mi.core.Config.State, e))
// Returned value
ret, ok := mi.core.Config.State.Get(-1).(*lua.LUserData)
if !ok {
return e
}
// Remove returned value
mi.core.Config.State.Pop(1)
ev, ok := ret.Value.(*tcell.EventKey)
if ok {
return ev
}
}
// Defaults
switch e.Name() {
case "Enter":
return mi.sendMessage()
case "Ctrl+V":
return mi.pasteFromClipboard()
case "Esc":
mi.
SetText("").
SetTitle("")
mi.app.SetFocus(mi.app.MainFlex)
mi.core.Application.SetFocus(mi.core.MainFlex)
mi.app.MessagesPanel.SelectedMessage = -1
mi.app.MessagesPanel.Highlight()
mi.core.MessagesPanel.SelectedMessage = -1
mi.core.MessagesPanel.Highlight()
return nil
case mi.app.Config.Keys.OpenExternalEditor:
return mi.openExternalEditor()
}
return e
}
func (mi *MessageInput) sendMessage() *tcell.EventKey {
if mi.app.ChannelsTree.SelectedChannel == nil {
if mi.core.ChannelsTree.SelectedChannel == nil {
return nil
}
@ -69,13 +107,14 @@ func (mi *MessageInput) sendMessage() *tcell.EventKey {
return nil
}
ms, err := mi.app.State.Messages(mi.app.ChannelsTree.SelectedChannel.ID, mi.app.Config.MessagesLimit)
messagesLimit := mi.core.Config.State.GetGlobal("messagesLimit")
ms, err := mi.core.State.Messages(mi.core.ChannelsTree.SelectedChannel.ID, uint(lua.LVAsNumber(messagesLimit)))
if err != nil {
return nil
}
if len(mi.app.MessagesPanel.GetHighlights()) != 0 {
mID, err := discord.ParseSnowflake(mi.app.MessagesPanel.GetHighlights()[0])
if len(mi.core.MessagesPanel.GetHighlights()) != 0 {
mID, err := discord.ParseSnowflake(mi.core.MessagesPanel.GetHighlights()[0])
if err != nil {
return nil
}
@ -92,36 +131,36 @@ func (mi *MessageInput) sendMessage() *tcell.EventKey {
d.AllowedMentions.RepliedUser = option.True
}
go mi.app.State.SendMessageComplex(m.ChannelID, d)
go mi.core.State.SendMessageComplex(m.ChannelID, d)
mi.app.MessagesPanel.SelectedMessage = -1
mi.app.MessagesPanel.Highlight()
mi.core.MessagesPanel.SelectedMessage = -1
mi.core.MessagesPanel.Highlight()
mi.SetTitle("")
} else {
go mi.app.State.SendMessage(mi.app.ChannelsTree.SelectedChannel.ID, t)
go mi.core.State.SendMessage(mi.core.ChannelsTree.SelectedChannel.ID, t)
}
mi.SetText("")
return nil
}
func (mi *MessageInput) pasteFromClipboard() *tcell.EventKey {
func (mi *MessageInput) pasteClipboardContentLua(s *lua.LState) int {
text, _ := clipboard.ReadAll()
text = mi.GetText() + text
mi.SetText(text)
return nil
return returnNilLua(s)
}
func (mi *MessageInput) openExternalEditor() *tcell.EventKey {
func (mi *MessageInput) openExternalEditorLua(s *lua.LState) int {
e := os.Getenv("EDITOR")
if e == "" {
return nil
return returnNilLua(s)
}
f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
if err != nil {
return nil
return returnNilLua(s)
}
defer os.Remove(f.Name())
@ -129,7 +168,7 @@ func (mi *MessageInput) openExternalEditor() *tcell.EventKey {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
mi.app.Suspend(func() {
mi.core.Application.Suspend(func() {
err = cmd.Run()
if err != nil {
return
@ -138,9 +177,9 @@ func (mi *MessageInput) openExternalEditor() *tcell.EventKey {
b, err := io.ReadAll(f)
if err != nil {
return nil
return returnNilLua(s)
}
mi.SetText(string(b))
return nil
return returnNilLua(s)
}

View file

@ -4,21 +4,24 @@ import (
"github.com/diamondburned/arikawa/v3/discord"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
lua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar"
)
type MessagesPanel struct {
*tview.TextView
app *App
// The index of the currently selected message.
// The index of the currently selected message. A negative index indicates that there is no currently selected message.
SelectedMessage int
core *Core
}
func NewMessagesPanel(app *App) *MessagesPanel {
func NewMessagesPanel(c *Core) *MessagesPanel {
mp := &MessagesPanel{
TextView: tview.NewTextView(),
app: app,
// Negative index indicates that there is no currently selected message.
TextView: tview.NewTextView(),
SelectedMessage: -1,
core: c,
}
mp.SetDynamicColors(true)
@ -26,7 +29,7 @@ func NewMessagesPanel(app *App) *MessagesPanel {
mp.SetWordWrap(true)
mp.SetInputCapture(mp.onInputCapture)
mp.SetChangedFunc(func() {
mp.app.Draw()
mp.core.Application.Draw()
})
mp.SetTitle("Messages")
@ -38,30 +41,60 @@ func NewMessagesPanel(app *App) *MessagesPanel {
}
func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
if mp.app.ChannelsTree.SelectedChannel == nil {
if mp.core.ChannelsTree.SelectedChannel == nil {
return nil
}
// Messages should return messages ordered from latest to earliest.
ms, err := mp.app.State.Cabinet.Messages(mp.app.ChannelsTree.SelectedChannel.ID)
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return nil
}
keysTable, ok := mp.core.Config.State.GetGlobal("keys").(*lua.LTable)
if !ok {
return e
}
messagesPanel, ok := keysTable.RawGetString("messagesPanel").(*lua.LTable)
if !ok {
return e
}
var fn lua.LValue
messagesPanel.ForEach(func(k, v lua.LValue) {
keyTable := v.(*lua.LTable)
if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) {
fn = keyTable.RawGetString("action")
}
})
if fn != nil {
mp.core.Config.State.CallByParam(lua.P{
Fn: fn,
NRet: 1,
Protect: true,
}, luar.New(mp.core.Config.State, mp.core), luar.New(mp.core.Config.State, e))
// Returned value
ret, ok := mp.core.Config.State.Get(-1).(*lua.LUserData)
if !ok {
return e
}
// Remove returned value
mp.core.Config.State.Pop(1)
ev, ok := ret.Value.(*tcell.EventKey)
if ok {
return ev
}
}
// Defaults
switch e.Name() {
case mp.app.Config.Keys.SelectPreviousMessage:
return mp.selectPreviousMessage(ms)
case mp.app.Config.Keys.SelectNextMessage:
return mp.selectNextMessage(ms)
case mp.app.Config.Keys.SelectFirstMessage:
return mp.selectFirstMessage(ms)
case mp.app.Config.Keys.SelectLastMessage:
return mp.selectLastMessage(ms)
case mp.app.Config.Keys.OpenMessageActionsList:
return mp.openMessageActionsList(ms)
case "Esc":
mp.SelectedMessage = -1
mp.app.SetFocus(mp.app.MainFlex)
mp.core.Application.SetFocus(mp.core.MainFlex)
mp.
Clear().
Highlight().
@ -72,7 +105,13 @@ func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
return e
}
func (mp *MessagesPanel) selectPreviousMessage(ms []discord.Message) *tcell.EventKey {
func (mp *MessagesPanel) selectPreviousMessageLua(s *lua.LState) int {
// Messages should return messages ordered from latest to earliest.
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return returnNilLua(s)
}
// If there are no highlighted regions, select the latest (last) message in the messages panel.
if len(mp.GetHighlights()) == 0 {
mp.SelectedMessage = 0
@ -85,13 +124,18 @@ func (mp *MessagesPanel) selectPreviousMessage(ms []discord.Message) *tcell.Even
}
}
mp.
Highlight(ms[mp.SelectedMessage].ID.String()).
ScrollToHighlight()
return nil
mp.Highlight(ms[mp.SelectedMessage].ID.String())
mp.ScrollToHighlight()
return returnNilLua(s)
}
func (mp *MessagesPanel) selectNextMessage(ms []discord.Message) *tcell.EventKey {
func (mp *MessagesPanel) selectNextMessageLua(s *lua.LState) int {
// Messages should return messages ordered from latest to earliest.
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return returnNilLua(s)
}
// If there are no highlighted regions, select the latest (last) message in the messages panel.
if len(mp.GetHighlights()) == 0 {
mp.SelectedMessage = 0
@ -107,42 +151,60 @@ func (mp *MessagesPanel) selectNextMessage(ms []discord.Message) *tcell.EventKey
mp.
Highlight(ms[mp.SelectedMessage].ID.String()).
ScrollToHighlight()
return nil
return returnNilLua(s)
}
func (mp *MessagesPanel) selectFirstMessage(ms []discord.Message) *tcell.EventKey {
func (mp *MessagesPanel) selectFirstMessageLua(s *lua.LState) int {
// Messages should return messages ordered from latest to earliest.
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return returnNilLua(s)
}
mp.SelectedMessage = len(ms) - 1
mp.
Highlight(ms[mp.SelectedMessage].ID.String()).
ScrollToHighlight()
return nil
return returnNilLua(s)
}
func (mp *MessagesPanel) selectLastMessage(ms []discord.Message) *tcell.EventKey {
func (mp *MessagesPanel) selectLastMessageLua(s *lua.LState) int {
// Messages should return messages ordered from latest to earliest.
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return returnNilLua(s)
}
mp.SelectedMessage = 0
mp.
Highlight(ms[mp.SelectedMessage].ID.String()).
ScrollToHighlight()
return nil
return returnNilLua(s)
}
func (mp *MessagesPanel) openMessageActionsList(ms []discord.Message) *tcell.EventKey {
func (mp *MessagesPanel) openMessageActionsListLua(s *lua.LState) int {
// Messages should return messages ordered from latest to earliest.
ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID)
if err != nil || len(ms) == 0 {
return returnNilLua(s)
}
hs := mp.GetHighlights()
if len(hs) == 0 {
return nil
return returnNilLua(s)
}
mID, err := discord.ParseSnowflake(hs[0])
if err != nil {
return nil
return returnNilLua(s)
}
_, m := findMessageByID(ms, discord.MessageID(mID))
if m == nil {
return nil
return returnNilLua(s)
}
actionsList := NewMessageActionsList(mp.app, m)
mp.app.SetRoot(actionsList, true)
return nil
actionsList := NewMessageActionsList(mp.core, m)
mp.core.Application.SetRoot(actionsList, true)
return returnNilLua(s)
}

View file

@ -6,6 +6,7 @@ import (
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/state"
lua "github.com/yuin/gopher-lua"
)
var (
@ -66,3 +67,8 @@ func hasPermission(s *state.State, cID discord.ChannelID, p discord.Permissions)
return perm&p == p
}
func returnNilLua(s *lua.LState) int {
s.Push(lua.LNil) // Push the result
return 1 // Number of results
}