From c2bf89e44d7a70fe982cd2fed5027b6d98d3c596 Mon Sep 17 00:00:00 2001 From: sin <50749166+sinjs@users.noreply.github.com> Date: Sun, 21 Jul 2024 22:34:13 +0200 Subject: [PATCH] first commit --- .github/workflows/build.yaml | 27 +++ .gitignore | 4 + LICENSE | 21 ++ README.md | 111 +++++++++++ cmd/guilds_tree.go | 239 ++++++++++++++++++++++ cmd/main_flex.go | 69 +++++++ cmd/message_input.go | 159 +++++++++++++++ cmd/messages_text.go | 344 ++++++++++++++++++++++++++++++++ cmd/run.go | 68 +++++++ cmd/state.go | 96 +++++++++ go.mod | 30 +++ go.sum | 104 ++++++++++ internal/config/config.go | 77 +++++++ internal/config/keys.go | 74 +++++++ internal/config/theme.go | 47 +++++ internal/constants/constants.go | 5 + internal/logger/logger.go | 41 ++++ internal/markdown/markdown.go | 22 ++ internal/ui/login_form.go | 95 +++++++++ main.go | 30 +++ 20 files changed, 1663 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/guilds_tree.go create mode 100644 cmd/main_flex.go create mode 100644 cmd/message_input.go create mode 100644 cmd/messages_text.go create mode 100644 cmd/run.go create mode 100644 cmd/state.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/keys.go create mode 100644 internal/config/theme.go create mode 100644 internal/constants/constants.go create mode 100644 internal/logger/logger.go create mode 100644 internal/markdown/markdown.go create mode 100644 internal/ui/login_form.go create mode 100644 main.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..c98c785 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,27 @@ +name: build +on: [push] + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest, macos-13] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Build + run: go build -ldflags "-s -w" . + + - uses: actions/upload-artifact@v4 + if: ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + with: + name: clicord_${{ runner.os }}_${{ runner.arch }} + path: | + clicord + clicord.exe diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a084a8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +clicord* + +# Visual Studio Code +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ae882e2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 ayn2op + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..314c799 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# clicord + +A Discord terminal client. Heavily work-in-progress, expect breaking changes. + +## Important Disclaimer + +> [!CAUTION] +> Automated user accounts or "self-bots" are against Discord's Terms of Service. I am not +> responsible for any loss caused by using "self-bots" or clicord. This client is against the Terms +> of Service and makes little effort in hiding that it is a custom client, for example by not +> sending typing indicators or other data that the regular Discord client does. + +## Installation + +### Building from source + +```bash +git clone https://github.com/sinjs/clicord.git +cd clicord +go build . +``` + +### Linux clipboard support + +- `xclip` or `xsel` for X11 (`apt install xclip`) +- `wl-clipboard` for Wayland (`apt install wl-clipboard`) + +## Usage + +1. Run the `clicord` executable. + + > [!WARNING] + > It is safer to log in using an authentication token instead of using the UI, since Discord + > monitors their authentication endpoints closely and thus increases your ban risk. + > + > Instead, to log in using a token, provide the `token` command-line flag to the executable + > (eg: `--token "MTI2NDU5Nzk0NTY4OTU3NTU5MQ.VGVTdA.Z28-XdheSB-HVwaWQgcGV-29uCg"`). The token is + > stored in the default keyring. + +2. Enter your email and password (and 2fa code if required) and click on the "Login" button to + continue. + +## Configuration + +The configuration file allows you to configure and customize the behavior, keybindings, and theme of +the application. The file is not created automatically, it uses the default config embedded into the +application. To configure clicord, create the config file at one of these locations: + +- Linux: `$XDG_CONFIG_HOME/clicord/config.toml` or `$HOME/.config/clicord/config.toml` +- MacOS: `$HOME/Library/Application Support/clicord/config.toml` +- Windows: `%AppData%/clicord/config.toml` + +```toml +mouse = true # Allows mouse usage + +timestamps = true # If enabled, message timestamps will be displayed +timestamps_before_author = true # If enabled, timestamps will be displayed before the author name instead of directly after it +timestamps_format = "3:04PM" # The timestamp format should be one of these values: https://pkg.go.dev/time#pkg-constants + +# These settings are used to emulate a different Discord client. See https://docs.discord.sex/reference#example-client-properties-(web) for example values for different platforms +user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.3" +os = "Windows" +browser = "Chrome" +device = "" + +messages_limit = 50 # The amount of messages to fetch in one channel +editor = "default" # Any executable for an editor, for example `nvim`. By default it uses the $EDITOR environment variable, or if not found, `vi` + +# Keybindings: These keybinds are equivalent to the result of https://pkg.go.dev/github.com/gdamore/tcell#EventKey.Name +[keys] +focus_guilds_tree = "Ctrl+G" +focus_messages_text = "Ctrl+T" +focus_message_input = "Ctrl+P" +toggle_guild_tree = "Ctrl+B" +select_previous = "Rune[k]" +select_next = "Rune[j]" +select_first = "Rune[g]" +select_last = "Rune[G]" + +[keys.guilds_tree] +select_current = "Enter" + +[keys.messages_text] +select_reply = "Rune[s]" +reply = "Rune[r]" +reply_mention = "Rune[R]" +delete = "Rune[d]" +yank = "Rune[y]" +open = "Rune[o]" + +[keys.message_input] +send = "Enter" +editor = "Ctrl+E" +cancel = "Esc" + +# Themes can change the visuals of the client +[theme] +border = true +border_color = "default" +border_padding = [0, 0, 1, 1] +title_color = "default" +background_color = "default" + +[theme.guilds_tree] +auto_expand_folders = true +graphics = true + +[theme.messages_text] +author_color = "pink" +reply_indicator = "╭ " +``` diff --git a/cmd/guilds_tree.go b/cmd/guilds_tree.go new file mode 100644 index 0000000..da14aa3 --- /dev/null +++ b/cmd/guilds_tree.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "fmt" + "log" + "sort" + "strings" + + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type GuildsTree struct { + *tview.TreeView + + selectedChannelID discord.ChannelID +} + +func newGuildsTree() *GuildsTree { + gt := &GuildsTree{ + TreeView: tview.NewTreeView(), + } + + root := tview.NewTreeNode("") + gt.SetRoot(root) + + gt.SetTopLevel(1) + gt.SetGraphics(cfg.Theme.GuildsTree.Graphics) + gt.SetBackgroundColor(tcell.GetColor(cfg.Theme.BackgroundColor)) + gt.SetSelectedFunc(gt.onSelected) + + gt.SetTitle("Guilds") + gt.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor)) + gt.SetTitleAlign(tview.AlignLeft) + + p := cfg.Theme.BorderPadding + gt.SetBorder(cfg.Theme.Border) + gt.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor)) + gt.SetBorderPadding(p[0], p[1], p[2], p[3]) + + gt.SetInputCapture(gt.onInputCapture) + return gt +} + +func (gt *GuildsTree) createFolderNode(folder gateway.GuildFolder) { + var name string + if folder.Name == "" { + name = "Folder" + } else { + name = fmt.Sprintf("[%s]%s[-]", folder.Color.String(), folder.Name) + } + + root := gt.GetRoot() + folderNode := tview.NewTreeNode(name) + folderNode.SetExpanded(cfg.Theme.GuildsTree.AutoExpandFolders) + root.AddChild(folderNode) + + for _, gID := range folder.GuildIDs { + g, err := discordState.Cabinet.Guild(gID) + if err != nil { + log.Printf("guild %v not found in state: %v\n", gID, err) + continue + } + + gt.createGuildNode(folderNode, *g) + } +} + +func (gt *GuildsTree) createGuildNode(n *tview.TreeNode, g discord.Guild) { + guildNode := tview.NewTreeNode(g.Name) + guildNode.SetReference(g.ID) + n.AddChild(guildNode) +} + +func (gt *GuildsTree) channelToString(c discord.Channel) string { + var s string + switch c.Type { + case discord.GuildText: + s = "#" + c.Name + case discord.DirectMessage: + r := c.DMRecipients[0] + s = r.Tag() + case discord.GuildVoice: + s = "v-" + c.Name + case discord.GroupDM: + s = c.Name + // If the name of the channel is empty, use the recipients' tags + if s == "" { + rs := make([]string, len(c.DMRecipients)) + for _, r := range c.DMRecipients { + rs = append(rs, r.Tag()) + } + + s = strings.Join(rs, ", ") + } + case discord.GuildAnnouncement: + s = "a-" + c.Name + case discord.GuildStore: + s = "s-" + c.Name + case discord.GuildForum: + s = "f-" + c.Name + default: + s = c.Name + } + + return s +} + +func (gt *GuildsTree) createChannelNode(n *tview.TreeNode, c discord.Channel) *tview.TreeNode { + if c.Type != discord.DirectMessage && c.Type != discord.GroupDM { + ps, err := discordState.Permissions(c.ID, discordState.Ready().User.ID) + if err != nil { + log.Println(err) + return nil + } + + if !ps.Has(discord.PermissionViewChannel) { + return nil + } + } + + channelNode := tview.NewTreeNode(gt.channelToString(c)) + channelNode.SetReference(c.ID) + n.AddChild(channelNode) + return channelNode +} + +func (gt *GuildsTree) createChannelNodes(n *tview.TreeNode, cs []discord.Channel) { + for _, c := range cs { + if c.Type != discord.GuildCategory && !c.ParentID.IsValid() { + gt.createChannelNode(n, c) + } + } + +PARENT_CHANNELS: + for _, c := range cs { + if c.Type == discord.GuildCategory { + for _, nested := range cs { + if nested.ParentID == c.ID { + gt.createChannelNode(n, c) + continue PARENT_CHANNELS + } + } + } + } + + for _, c := range cs { + if c.ParentID.IsValid() { + var parent *tview.TreeNode + n.Walk(func(node, _ *tview.TreeNode) bool { + if node.GetReference() == c.ParentID { + parent = node + return false + } + + return true + }) + + if parent != nil { + gt.createChannelNode(parent, c) + } + } + } +} + +func (gt *GuildsTree) onSelected(n *tview.TreeNode) { + gt.selectedChannelID = 0 + + mainFlex.messagesText.reset() + mainFlex.messageInput.reset() + + if len(n.GetChildren()) != 0 { + n.SetExpanded(!n.IsExpanded()) + return + } + + switch ref := n.GetReference().(type) { + case discord.GuildID: + cs, err := discordState.Cabinet.Channels(ref) + if err != nil { + log.Println(err) + return + } + + sort.Slice(cs, func(i, j int) bool { + return cs[i].Position < cs[j].Position + }) + + gt.createChannelNodes(n, cs) + case discord.ChannelID: + mainFlex.messagesText.drawMsgs(ref) + mainFlex.messagesText.ScrollToEnd() + + c, err := discordState.Cabinet.Channel(ref) + if err != nil { + log.Println(err) + return + } + + mainFlex.messagesText.SetTitle(gt.channelToString(*c)) + + gt.selectedChannelID = ref + app.SetFocus(mainFlex.messageInput) + case nil: // Direct messages + cs, err := discordState.Cabinet.PrivateChannels() + if err != nil { + log.Println(err) + return + } + + sort.Slice(cs, func(i, j int) bool { + return cs[i].LastMessageID > cs[j].LastMessageID + }) + + for _, c := range cs { + gt.createChannelNode(n, c) + } + } +} + +func (gt *GuildsTree) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case cfg.Keys.SelectPrevious: + return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) + case cfg.Keys.SelectNext: + return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) + case cfg.Keys.SelectFirst: + return tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone) + case cfg.Keys.SelectLast: + return tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone) + + case cfg.Keys.GuildsTree.SelectCurrent: + return tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone) + } + + return nil +} diff --git a/cmd/main_flex.go b/cmd/main_flex.go new file mode 100644 index 0000000..ce6b6fc --- /dev/null +++ b/cmd/main_flex.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type MainFlex struct { + *tview.Flex + + guildsTree *GuildsTree + messagesText *MessagesText + messageInput *MessageInput +} + +func newMainFlex() *MainFlex { + mf := &MainFlex{ + Flex: tview.NewFlex(), + + guildsTree: newGuildsTree(), + messagesText: newMessagesText(), + messageInput: newMessageInput(), + } + + mf.init() + mf.SetInputCapture(mf.onInputCapture) + return mf +} + +func (mf *MainFlex) init() { + mf.Clear() + + right := tview.NewFlex() + right.SetDirection(tview.FlexRow) + right.AddItem(mf.messagesText, 0, 1, false) + right.AddItem(mf.messageInput, 3, 1, false) + // The guilds tree is always focused first at start-up. + mf.AddItem(mf.guildsTree, 0, 1, true) + mf.AddItem(right, 0, 4, false) +} + +func (mf *MainFlex) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case cfg.Keys.FocusGuildsTree: + app.SetFocus(mf.guildsTree) + return nil + case cfg.Keys.FocusMessagesText: + app.SetFocus(mf.messagesText) + return nil + case cfg.Keys.FocusMessageInput: + app.SetFocus(mf.messageInput) + return nil + case cfg.Keys.ToggleGuildsTree: + // The guilds tree is visible if the numbers of items is two. + if mf.GetItemCount() == 2 { + mf.RemoveItem(mf.guildsTree) + if mf.guildsTree.HasFocus() { + app.SetFocus(mf) + } + } else { + mf.init() + app.SetFocus(mf.guildsTree) + } + + return nil + } + + return event +} diff --git a/cmd/message_input.go b/cmd/message_input.go new file mode 100644 index 0000000..4cd8264 --- /dev/null +++ b/cmd/message_input.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "log" + "os" + "os/exec" + "strings" + + "github.com/atotto/clipboard" + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/diamondburned/arikawa/v3/utils/json/option" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/sinjs/clicord/internal/constants" +) + +type MessageInput struct { + *tview.TextArea + replyMessageIdx int +} + +func newMessageInput() *MessageInput { + mi := &MessageInput{ + TextArea: tview.NewTextArea(), + replyMessageIdx: -1, + } + + mi.SetTextStyle(tcell.StyleDefault.Background(tcell.GetColor(cfg.Theme.BackgroundColor))) + mi.SetClipboard(func(s string) { + _ = clipboard.WriteAll(s) + }, func() string { + text, _ := clipboard.ReadAll() + return text + }) + + mi.SetInputCapture(mi.onInputCapture) + mi.SetBackgroundColor(tcell.GetColor(cfg.Theme.BackgroundColor)) + + mi.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor)) + mi.SetTitleAlign(tview.AlignLeft) + + p := cfg.Theme.BorderPadding + mi.SetBorder(cfg.Theme.Border) + mi.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor)) + mi.SetBorderPadding(p[0], p[1], p[2], p[3]) + + return mi +} + +func (mi *MessageInput) reset() { + mi.replyMessageIdx = -1 + mi.SetTitle("") + mi.SetText("", true) +} + +func (mi *MessageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case cfg.Keys.MessageInput.Send: + mi.send() + return nil + case cfg.Keys.MessageInput.Editor: + mi.editor() + return nil + case cfg.Keys.MessageInput.Cancel: + mi.reset() + return nil + } + + return event +} + +func (mi *MessageInput) send() { + if !mainFlex.guildsTree.selectedChannelID.IsValid() { + return + } + + text := strings.TrimSpace(mi.GetText()) + if text == "" { + return + } + + if mi.replyMessageIdx != -1 { + ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID) + if err != nil { + log.Println(err) + return + } + + data := api.SendMessageData{ + Content: text, + Reference: &discord.MessageReference{MessageID: ms[mi.replyMessageIdx].ID}, + AllowedMentions: &api.AllowedMentions{RepliedUser: option.False}, + } + + if strings.HasPrefix(mi.GetTitle(), "[@]") { + data.AllowedMentions.RepliedUser = option.True + } + + go func() { + if _, err := discordState.SendMessageComplex(mainFlex.guildsTree.selectedChannelID, data); err != nil { + log.Println("failed to send message:", err) + } + }() + } else { + go func() { + if _, err := discordState.SendMessage(mainFlex.guildsTree.selectedChannelID, text); err != nil { + log.Println("failed to send message:", err) + } + }() + } + + mi.replyMessageIdx = -1 + mi.reset() + + mainFlex.messagesText.Highlight() + mainFlex.messagesText.ScrollToEnd() +} + +func (mi *MessageInput) editor() { + e := cfg.Editor + if e == "default" { + e = os.Getenv("EDITOR") + } + if e == "" { + e = "vi" + } + + f, err := os.CreateTemp("", constants.TmpFilePattern) + if err != nil { + log.Println(err) + return + } + _, _ = f.WriteString(mi.GetText()) + f.Close() + + defer os.Remove(f.Name()) + + cmd := exec.Command(e, f.Name()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + app.Suspend(func() { + err := cmd.Run() + if err != nil { + log.Println(err) + return + } + }) + + msg, err := os.ReadFile(f.Name()) + if err != nil { + log.Println(err) + return + } + + mi.SetText(strings.TrimSpace(string(msg)), true) +} diff --git a/cmd/messages_text.go b/cmd/messages_text.go new file mode 100644 index 0000000..3f4cb6d --- /dev/null +++ b/cmd/messages_text.go @@ -0,0 +1,344 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "log" + "strings" + "time" + + "github.com/atotto/clipboard" + "github.com/diamondburned/arikawa/v3/discord" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/sinjs/clicord/internal/markdown" + "github.com/skratchdot/open-golang/open" +) + +type MessagesText struct { + *tview.TextView + + selectedMessage int +} + +func newMessagesText() *MessagesText { + mt := &MessagesText{ + TextView: tview.NewTextView(), + + selectedMessage: -1, + } + + mt.SetDynamicColors(true) + mt.SetRegions(true) + mt.SetWordWrap(true) + mt.SetInputCapture(mt.onInputCapture) + mt.ScrollToEnd() + mt.SetChangedFunc(func() { + app.Draw() + }) + + mt.SetBackgroundColor(tcell.GetColor(cfg.Theme.BackgroundColor)) + + mt.SetTitle("Messages") + mt.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor)) + mt.SetTitleAlign(tview.AlignLeft) + + p := cfg.Theme.BorderPadding + mt.SetBorder(cfg.Theme.Border) + mt.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor)) + mt.SetBorderPadding(p[0], p[1], p[2], p[3]) + + return mt +} + +func (mt *MessagesText) drawMsgs(cID discord.ChannelID) { + ms, err := discordState.Messages(cID, uint(cfg.MessagesLimit)) + if err != nil { + log.Println(err) + return + } + + for i := len(ms) - 1; i >= 0; i-- { + mainFlex.messagesText.createMessage(ms[i]) + } +} + +func (mt *MessagesText) reset() { + mainFlex.messagesText.selectedMessage = -1 + + mt.SetTitle("") + mt.Clear() + mt.Highlight() +} + +func (mt *MessagesText) createMessage(m discord.Message) { + switch m.Type { + case discord.DefaultMessage, discord.InlinedReplyMessage: + // Region tags are square brackets that contain a region ID in double quotes + // https://pkg.go.dev/github.com/rivo/tview#hdr-Regions_and_Highlights + fmt.Fprintf(mt, `["%s"]`, m.ID) + + if m.ReferencedMessage != nil { + mt.createHeader(mt, *m.ReferencedMessage, true) + mt.createBody(mt, *m.ReferencedMessage, true) + + fmt.Fprint(mt, "[::-]\n") + } + + mt.createHeader(mt, m, false) + mt.createBody(mt, m, false) + mt.createFooter(mt, m) + + // Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region. + fmt.Fprint(mt, `[""]`) + fmt.Fprintln(mt) + } +} + +func (mt *MessagesText) createHeader(w io.Writer, m discord.Message, isReply bool) { + time := m.Timestamp.Time().In(time.Local).Format(cfg.TimestampsFormat) + + if cfg.Timestamps && cfg.TimestampsBeforeAuthor { + fmt.Fprintf(w, "[::d]%s[::-] ", time) + } + + if isReply { + fmt.Fprintf(mt, "[::d]%s", cfg.Theme.MessagesText.ReplyIndicator) + } + + fmt.Fprintf(w, "[%s]%s[-:-:-] ", cfg.Theme.MessagesText.AuthorColor, m.Author.Username) + + if cfg.Timestamps && !cfg.TimestampsBeforeAuthor { + fmt.Fprintf(w, "[::d]%s[::-] ", time) + } +} + +func parseIDsToUsernames(m discord.Message) string { + var toReplace []string + for _, mention := range m.Mentions { + toReplace = append(toReplace, + fmt.Sprintf("<@%s>", mention.User.ID.String()), + fmt.Sprintf("__**@%s**__", mention.User.Username), + ) + } + + return strings.NewReplacer(toReplace...).Replace(m.Content) +} + +func (mt *MessagesText) createBody(w io.Writer, m discord.Message, isReply bool) { + var body string + if len(m.Mentions) > 0 { + body = parseIDsToUsernames(m) + } else { + body = m.Content + } + + if isReply { + fmt.Fprint(w, "[::d]") + } + fmt.Fprint(w, markdown.Parse(tview.Escape(body))) + if isReply { + fmt.Fprint(w, "[::-]") + } +} + +func (mt *MessagesText) createFooter(w io.Writer, m discord.Message) { + for _, a := range m.Attachments { + fmt.Fprintln(w) + fmt.Fprintf(w, "[%s]: %s", a.Filename, a.URL) + } +} + +func (mt *MessagesText) getSelectedMessage() (*discord.Message, error) { + if mt.selectedMessage == -1 { + return nil, errors.New("no message is currently selected") + } + + ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID) + if err != nil { + return nil, err + } + + return &ms[mt.selectedMessage], nil +} + +func (mt *MessagesText) onInputCapture(event *tcell.EventKey) *tcell.EventKey { + switch event.Name() { + case cfg.Keys.SelectPrevious, cfg.Keys.SelectNext, cfg.Keys.SelectFirst, cfg.Keys.SelectLast, cfg.Keys.MessagesText.SelectReply: + mt._select(event.Name()) + return nil + case cfg.Keys.MessagesText.Yank: + mt.yank() + return nil + case cfg.Keys.MessagesText.Open: + mt.open() + return nil + case cfg.Keys.MessagesText.Reply: + mt.reply(false) + return nil + case cfg.Keys.MessagesText.ReplyMention: + mt.reply(true) + return nil + case cfg.Keys.MessagesText.Delete: + mt.delete() + return nil + } + + return nil +} + +func (mt *MessagesText) _select(name string) { + ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID) + if err != nil { + log.Println(err) + return + } + + switch name { + case cfg.Keys.SelectPrevious: + // If no message is currently selected, select the latest message. + if len(mt.GetHighlights()) == 0 { + mt.selectedMessage = 0 + } else { + if mt.selectedMessage < len(ms)-1 { + mt.selectedMessage++ + } else { + return + } + } + case cfg.Keys.SelectNext: + // If no message is currently selected, select the latest message. + if len(mt.GetHighlights()) == 0 { + mt.selectedMessage = 0 + } else { + if mt.selectedMessage > 0 { + mt.selectedMessage-- + } else { + return + } + } + case cfg.Keys.SelectFirst: + mt.selectedMessage = len(ms) - 1 + case cfg.Keys.SelectLast: + mt.selectedMessage = 0 + case cfg.Keys.MessagesText.SelectReply: + if mt.selectedMessage == -1 { + return + } + + if ref := ms[mt.selectedMessage].ReferencedMessage; ref != nil { + for i, m := range ms { + if ref.ID == m.ID { + mt.selectedMessage = i + } + } + } + } + + mt.Highlight(ms[mt.selectedMessage].ID.String()) + mt.ScrollToHighlight() +} + +func (mt *MessagesText) yank() { + msg, err := mt.getSelectedMessage() + if err != nil { + log.Println(err) + return + } + + err = clipboard.WriteAll(msg.Content) + if err != nil { + log.Println("failed to write to clipboard:", err) + return + } +} + +func (mt *MessagesText) open() { + msg, err := mt.getSelectedMessage() + if err != nil { + log.Println(err) + return + } + + attachments := msg.Attachments + if len(attachments) == 0 { + return + } + + for _, a := range attachments { + go func() { + if err := open.Start(a.URL); err != nil { + log.Println(err) + } + }() + } + +} + +func (mt *MessagesText) reply(mention bool) { + var title string + if mention { + title += "[@] Replying to " + } else { + title += "Replying to " + } + + msg, err := mt.getSelectedMessage() + if err != nil { + log.Println(err) + return + } + + title += msg.Author.Tag() + mainFlex.messageInput.SetTitle(title) + mainFlex.messageInput.replyMessageIdx = mt.selectedMessage + app.SetFocus(mainFlex.messageInput) +} + +func (mt *MessagesText) delete() { + + msg, err := mt.getSelectedMessage() + if err != nil { + log.Println(err) + return + } + + clientID := discordState.Ready().User.ID + if msg.GuildID.IsValid() { + ps, err := discordState.Permissions(mainFlex.guildsTree.selectedChannelID, discordState.Ready().User.ID) + if err != nil { + return + } + + if msg.Author.ID != clientID && !ps.Has(discord.PermissionManageMessages) { + return + } + } else { + if msg.Author.ID != clientID { + return + } + } + + if err := discordState.DeleteMessage(mainFlex.guildsTree.selectedChannelID, msg.ID, ""); err != nil { + log.Println(err) + return + } + + if err := discordState.MessageRemove(mainFlex.guildsTree.selectedChannelID, msg.ID); err != nil { + log.Println(err) + } + + ms, err := discordState.Cabinet.Messages(mainFlex.guildsTree.selectedChannelID) + if err != nil { + log.Println(err) + return + } + + mt.Clear() + + for i := len(ms) - 1; i >= 0; i-- { + mainFlex.messagesText.createMessage(ms[i]) + } + +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..556c369 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "log" + + "github.com/rivo/tview" + "github.com/sinjs/clicord/internal/config" + "github.com/sinjs/clicord/internal/logger" + "github.com/sinjs/clicord/internal/ui" +) + +var ( + discordState *State + + cfg *config.Config + app = tview.NewApplication() + mainFlex *MainFlex +) + +func init() { + var err error + cfg, err = config.Load() + if err != nil { + panic(err) + } +} + +func Run(token string) error { + if err := logger.Load(); err != nil { + return err + } + + if token == "" { + lf := ui.NewLoginForm(cfg) + + go func() { + // mainFlex must be initialized before opening a new state. + mainFlex = newMainFlex() + + token := <-lf.Token + if token.Error != nil { + app.Stop() + log.Fatal(token.Error) + } + + if err := openState(token.Value); err != nil { + app.Stop() + log.Fatal(err) + } + + app.QueueUpdateDraw(func() { + app.SetRoot(mainFlex, true) + }) + }() + + app.SetRoot(lf, true) + } else { + mainFlex = newMainFlex() + if err := openState(token); err != nil { + return err + } + + app.SetRoot(mainFlex, true) + } + + app.EnableMouse(cfg.Mouse) + return app.Run() +} diff --git a/cmd/state.go b/cmd/state.go new file mode 100644 index 0000000..8c5ae30 --- /dev/null +++ b/cmd/state.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "log" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/diamondburned/arikawa/v3/gateway" + "github.com/diamondburned/arikawa/v3/state" + "github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver" + "github.com/rivo/tview" +) + +func init() { + api.UserAgent = cfg.UserAgent + gateway.DefaultIdentity = gateway.IdentifyProperties{ + OS: cfg.OS, + Browser: cfg.Browser, + Device: cfg.Device, + } +} + +type State struct { + *state.State +} + +func openState(token string) error { + discordState = &State{ + State: state.New(token), + } + + // Handlers + discordState.AddHandler(discordState.onReady) + discordState.AddHandler(discordState.onMessageCreate) + discordState.AddHandler(discordState.onMessageDelete) + + discordState.OnRequest = append(discordState.Client.OnRequest, discordState.onRequest) + return discordState.Open(context.TODO()) +} + +func (s *State) onRequest(r httpdriver.Request) error { + req, ok := r.(*httpdriver.DefaultRequest) + if ok { + log.Printf("method = %s; url = %s\n", req.Method, req.URL) + } + + return nil +} + +func (s *State) onReady(r *gateway.ReadyEvent) { + root := mainFlex.guildsTree.GetRoot() + dmNode := tview.NewTreeNode("Direct Messages") + root.AddChild(dmNode) + + folders := r.UserSettings.GuildFolders + if len(folders) == 0 { + for _, g := range r.Guilds { + mainFlex.guildsTree.createGuildNode(root, g.Guild) + } + } else { + for _, folder := range folders { + // If the ID of the guild folder is zero, the guild folder only contains single guild. + if folder.ID == 0 { + gID := folder.GuildIDs[0] + g, err := discordState.Cabinet.Guild(gID) + if err != nil { + log.Printf("guild %v not found in state: %v\n", gID, err) + continue + } + + mainFlex.guildsTree.createGuildNode(root, *g) + } else { + mainFlex.guildsTree.createFolderNode(folder) + } + } + } + + mainFlex.guildsTree.SetCurrentNode(root) + app.SetFocus(mainFlex.guildsTree) +} + +func (s *State) onMessageCreate(m *gateway.MessageCreateEvent) { + if mainFlex.guildsTree.selectedChannelID.IsValid() && mainFlex.guildsTree.selectedChannelID == m.ChannelID { + mainFlex.messagesText.createMessage(m.Message) + } +} + +func (s *State) onMessageDelete(m *gateway.MessageDeleteEvent) { + if mainFlex.guildsTree.selectedChannelID == m.ChannelID { + mainFlex.messagesText.selectedMessage = -1 + mainFlex.messagesText.Highlight() + mainFlex.messagesText.Clear() + + mainFlex.messagesText.drawMsgs(m.ChannelID) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ceadd6f --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/sinjs/clicord + +go 1.22.4 + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/atotto/clipboard v0.1.4 + github.com/diamondburned/arikawa/v3 v3.3.6 + github.com/gdamore/tcell/v2 v2.7.4 + github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/zalando/go-keyring v0.2.5 +) + +require ( + github.com/alessio/shellescape v1.4.2 // indirect + github.com/danieljoos/wincred v1.2.1 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gorilla/schema v1.4.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8daf35e --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= +github.com/alessio/shellescape v1.4.2/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/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= +github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +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.3.6 h1:Vxyb+kuWEFseDS2+USRTWS0b5RUbV9PQ1fnVN5sJhwo= +github.com/diamondburned/arikawa/v3 v3.3.6/go.mod h1:0EAniaG6PMkhuIZEDR8BxXodasfWT7wekNqlNmb+JZI= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= +github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/schema v1.3.0/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 h1:iqvDlgyjmqleATtFbA7c14djmPh2n4mCYUv7JlD/ruA= +github.com/rivo/tview v0.0.0-20240524063012-037df494fb76/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3d4932b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" + "github.com/sinjs/clicord/internal/constants" +) + +type Config struct { + Mouse bool `toml:"mouse"` + + Timestamps bool `toml:"timestamps"` + TimestampsBeforeAuthor bool `toml:"timestamps_before_author"` + TimestampsFormat string `toml:"timestamps_format"` + + UserAgent string `toml:"user_agent"` + OS string `toml:"os"` + Browser string `toml:"browser"` + Device string `toml:"device"` + + MessagesLimit uint8 `toml:"messages_limit"` + + Editor string `toml:"editor"` + + Keys Keys `toml:"keys"` + Theme Theme `toml:"theme"` +} + +func DefaultConfig() Config { + return Config{ + Mouse: true, + + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.3", + OS: "Windows", + Browser: "Chrome", + Device: "", + + Timestamps: true, + TimestampsBeforeAuthor: true, + TimestampsFormat: time.Kitchen, + + MessagesLimit: 50, + Editor: "default", + + Keys: defaultKeys(), + Theme: defaultTheme(), + } +} + +// Reads the configuration file and parses it. +func Load() (*Config, error) { + path, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + cfg := DefaultConfig() + path = filepath.Join(path, constants.Name, "config.toml") + f, err := os.Open(path) + if os.IsNotExist(err) { + return &cfg, nil + } + + if err != nil { + return nil, err + } + defer f.Close() + + if _, err := toml.NewDecoder(f).Decode(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/config/keys.go b/internal/config/keys.go new file mode 100644 index 0000000..3669288 --- /dev/null +++ b/internal/config/keys.go @@ -0,0 +1,74 @@ +package config + +type ( + Keys struct { + FocusGuildsTree string `toml:"focus_guilds_tree"` + FocusMessagesText string `toml:"focus_messages_text"` + FocusMessageInput string `toml:"focus_message_input"` + ToggleGuildsTree string `toml:"toggle_guild_tree"` + + SelectPrevious string `toml:"select_previous"` + SelectNext string `toml:"select_next"` + SelectFirst string `toml:"select_first"` + SelectLast string `toml:"select_last"` + + GuildsTree GuildsTreeKeys `toml:"guilds_tree"` + MessagesText MessagesTextKeys `toml:"messages_text"` + MessageInput MessageInputKeys `toml:"message_input"` + } + + GuildsTreeKeys struct { + SelectCurrent string `toml:"select_current"` + } + + MessagesTextKeys struct { + SelectReply string `toml:"select_reply"` + Reply string `toml:"reply"` + ReplyMention string `toml:"reply_mention"` + + Delete string `toml:"delete"` + Yank string `toml:"yank"` + Open string `toml:"open"` + } + + MessageInputKeys struct { + Send string `toml:"send"` + Editor string `toml:"editor"` + Cancel string `toml:"cancel"` + } +) + +func defaultKeys() Keys { + return Keys{ + FocusGuildsTree: "Ctrl+G", + FocusMessagesText: "Ctrl+T", + FocusMessageInput: "Ctrl+P", + ToggleGuildsTree: "Ctrl+B", + + SelectPrevious: "Rune[k]", + SelectNext: "Rune[j]", + SelectFirst: "Rune[g]", + SelectLast: "Rune[G]", + + GuildsTree: GuildsTreeKeys{ + SelectCurrent: "Enter", + }, + + MessagesText: MessagesTextKeys{ + SelectReply: "Rune[s]", + + Reply: "Rune[r]", + ReplyMention: "Rune[R]", + + Delete: "Rune[d]", + Yank: "Rune[y]", + Open: "Rune[o]", + }, + + MessageInput: MessageInputKeys{ + Send: "Enter", + Editor: "Ctrl+E", + Cancel: "Esc", + }, + } +} diff --git a/internal/config/theme.go b/internal/config/theme.go new file mode 100644 index 0000000..13b6961 --- /dev/null +++ b/internal/config/theme.go @@ -0,0 +1,47 @@ +package config + +import "github.com/rivo/tview" + +type ( + Theme struct { + Border bool `toml:"border"` + BorderColor string `toml:"border_color"` + BorderPadding [4]int `toml:"border_padding"` + + TitleColor string `toml:"title_color"` + BackgroundColor string `toml:"background_color"` + + GuildsTree GuildsTreeTheme `toml:"guilds_tree"` + MessagesText MessagesTextTheme `toml:"messages_text"` + } + + GuildsTreeTheme struct { + AutoExpandFolders bool `toml:"auto_expand_folders"` + Graphics bool `toml:"graphics"` + } + + MessagesTextTheme struct { + AuthorColor string `toml:"author_color"` + ReplyIndicator string `toml:"reply_indicator"` + } +) + +func defaultTheme() Theme { + return Theme{ + Border: true, + BorderColor: "default", + BorderPadding: [...]int{0, 0, 1, 1}, + + BackgroundColor: "default", + TitleColor: "default", + + GuildsTree: GuildsTreeTheme{ + AutoExpandFolders: true, + Graphics: true, + }, + MessagesText: MessagesTextTheme{ + AuthorColor: "pink", + ReplyIndicator: string(tview.BoxDrawingsLightArcDownAndRight) + " ", + }, + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..5c8558b --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,5 @@ +package constants + +const Name = "clicord" + +const TmpFilePattern = Name + "_*.md" diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..137100f --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,41 @@ +package logger + +import ( + "log" + "os" + "path/filepath" + + "github.com/sinjs/clicord/internal/constants" +) + +// Recursively creates the log directory if it does not exist already and returns the path to the log file. +func initialize() (string, error) { + path, err := os.UserCacheDir() + if err != nil { + return "", err + } + + path = filepath.Join(path, constants.Name) + if err := os.MkdirAll(path, os.ModePerm); err != nil { + return "", err + } + + return filepath.Join(path, "logs.txt"), nil +} + +// Opens the log file and configures standard logger. +func Load() error { + path, err := initialize() + if err != nil { + return err + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm) + if err != nil { + return err + } + + log.SetOutput(f) + log.SetFlags(log.LstdFlags | log.Lshortfile) + return nil +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go new file mode 100644 index 0000000..063df40 --- /dev/null +++ b/internal/markdown/markdown.go @@ -0,0 +1,22 @@ +package markdown + +import ( + "regexp" +) + +var ( + boldRe = regexp.MustCompile(`(?ms)\*\*(.*?)\*\*`) + italicRe = regexp.MustCompile(`(?ms)\*(.*?)\*`) + underlineRe = regexp.MustCompile(`(?ms)__(.*?)__`) + strikethroughRe = regexp.MustCompile(`(?ms)~~(.*?)~~`) + codeblockRe = regexp.MustCompile("(?ms)`" + `([^` + "`" + `\n]+)` + "`") +) + +func Parse(input string) string { + input = boldRe.ReplaceAllString(input, "[::b]$1[::B]") + input = italicRe.ReplaceAllString(input, "[::i]$1[::I]") + input = underlineRe.ReplaceAllString(input, "[::u]$1[::U]") + input = strikethroughRe.ReplaceAllString(input, "[::s]$1[::S]") + input = codeblockRe.ReplaceAllString(input, "[::r]$1[::R]") + return input +} diff --git a/internal/ui/login_form.go b/internal/ui/login_form.go new file mode 100644 index 0000000..c002c18 --- /dev/null +++ b/internal/ui/login_form.go @@ -0,0 +1,95 @@ +package ui + +import ( + "errors" + "log" + + "github.com/diamondburned/arikawa/v3/api" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/sinjs/clicord/internal/config" + "github.com/sinjs/clicord/internal/constants" + "github.com/zalando/go-keyring" +) + +type Token struct { + Value string + Error error +} + +type LoginForm struct { + *tview.Form + Token chan Token +} + +func NewLoginForm(cfg *config.Config) *LoginForm { + lf := &LoginForm{ + Form: tview.NewForm(), + Token: make(chan Token, 1), + } + + lf.AddInputField("Email", "", 0, nil, nil) + lf.AddPasswordField("Password", "", 0, 0, nil) + lf.AddPasswordField("Code (optional)", "", 0, 0, nil) + lf.AddCheckbox("Remember Me", true, nil) + lf.AddButton("Login", lf.onLoginButtonSelected) + + lf.SetTitle("Login") + lf.SetTitleColor(tcell.GetColor(cfg.Theme.TitleColor)) + lf.SetTitleAlign(tview.AlignLeft) + + p := cfg.Theme.BorderPadding + lf.SetBorder(cfg.Theme.Border) + lf.SetBorderColor(tcell.GetColor(cfg.Theme.BorderColor)) + lf.SetBorderPadding(p[0], p[1], p[2], p[3]) + + return lf +} + +func (lf *LoginForm) onLoginButtonSelected() { + email := lf.GetFormItem(0).(*tview.InputField).GetText() + password := lf.GetFormItem(1).(*tview.InputField).GetText() + if email == "" || password == "" { + return + } + + // Create a new API client without an authentication token. + apiClient := api.NewClient("") + // Log in using the provided email and password. + lr, err := apiClient.Login(email, password) + if err != nil { + lf.Token <- Token{Error: err} + return + } + + // If the account has MFA-enabled, attempt to log in using the provided code. + if lr.MFA && lr.Token == "" { + code := lf.GetFormItem(2).(*tview.InputField).GetText() + if code == "" { + lf.Token <- Token{Error: errors.New("code required")} + return + } + + lr, err = apiClient.TOTP(code, lr.Ticket) + if err != nil { + lf.Token <- Token{Error: err} + return + } + } + + if lr.Token == "" { + lf.Token <- Token{Error: errors.New("missing token")} + return + } + + rememberMe := lf.GetFormItem(3).(*tview.Checkbox).IsChecked() + if rememberMe { + go func() { + if err := keyring.Set(constants.Name, "token", lr.Token); err != nil { + log.Println(err) + } + }() + } + + lf.Token <- Token{Value: lr.Token} +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ed7c44 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "flag" + "log" + + "github.com/sinjs/clicord/cmd" + "github.com/sinjs/clicord/internal/constants" + "github.com/zalando/go-keyring" +) + +func main() { + // Declare and parse all flags first + token := flag.String("token", "", "authentication token") + flag.Parse() + + // If no token was provided, look it up in the keyring + if *token == "" { + t, err := keyring.Get(constants.Name, "token") + if err != nil { + log.Println("failed to get token from keyring:", err) + } else { + *token = t + } + } + + if err := cmd.Run(*token); err != nil { + log.Fatal(err) + } +}