├── .gitignore ├── .gitlab-ci.yml ├── .gitmodules ├── 6cord.toml ├── LICENSE ├── README.md ├── _screenshots ├── clean.png ├── commands.png ├── guildview.png ├── highlight.png ├── img.png ├── latest.png ├── mentions.png └── reactions.png ├── _tests ├── feh.sh ├── main.go.bak ├── term_size │ └── main.go ├── tui-go │ └── main.go ├── typing │ └── main.go └── voice │ ├── main.go │ └── test.go ├── antitele └── antitele.go ├── center └── center.go ├── clipboard.go ├── command.go ├── commandblock.go ├── commandcopy.go ├── commanddebug.go ├── commanddelete.go ├── commanddm.go ├── commandedit.go ├── commandeditor.go ├── commandgoto.go ├── commandheated.go ├── commandmentions.go ├── commandmessages.go ├── commandnick.go ├── commandreact.go ├── commandsixel.go ├── commandstatus.go ├── commandtoken.go ├── commandupload.go ├── config.go ├── configfile.go ├── demojis ├── emojis.go └── fuzzy.go ├── docs ├── 6cord.png ├── CNAME └── index.html ├── editor.go ├── fmtemojis.go ├── fmtmessage.go ├── fuzzy.go ├── fuzzychannels.go ├── fuzzycommands.go ├── fuzzyemojis.go ├── fuzzymentions.go ├── fuzzymessages.go ├── fuzzyupload.go ├── go.mod ├── go.sum ├── guildevents.go ├── guildmembers.go ├── guildsettings.go ├── heated.go ├── humanize.go ├── image.go ├── image ├── backend.go ├── image.go ├── query.go ├── sixel.go ├── ueberzug.go ├── w3m.go ├── winch.go └── winch_test.go ├── imagecache.go ├── input.go ├── keyring ├── keyring.go └── keyring_null.go ├── lastauthor_state.go ├── loadchannel.go ├── login.go ├── main.go ├── math.go ├── md ├── chroma.go ├── highlighter.go ├── md.go └── md_test.go ├── mentions.go ├── message.go ├── messagecreate.go ├── messagedelete.go ├── messagerenderer.go ├── messagescroll.go ├── messageupdate.go ├── notify.go ├── onready.go ├── ontyping.go ├── parsementions.go ├── reactionevents.go ├── reactions.go ├── readstate.go ├── relationships.go ├── screenguild.go ├── screenmiddleware.go ├── scroll.go ├── shortener ├── port.go ├── shortener.go └── url.go ├── sixel.go ├── sortchannels.go ├── syscall.go ├── syscall_linux.go ├── syscall_unix.go ├── syscall_windows.go ├── treenodefns.go ├── typing.go ├── ui.go ├── user.go ├── user_store.go ├── voice.go ├── voiceaudio.go ├── w3m ├── locate.go └── w3m.go ├── warn.go └── wraplines.go /.gitignore: -------------------------------------------------------------------------------- 1 | 6cord 2 | 6cord.upx 3 | .vscode 4 | .history 5 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: golang:alpine 2 | 3 | variables: 4 | GO111MODULE: "on" 5 | CGO_ENABLED: 0 6 | 7 | before_script: 8 | - apk add git upx 9 | 10 | stages: 11 | - build 12 | 13 | linux: 14 | stage: build 15 | script: 16 | - time go get 17 | - export FLAGS="-ldflags -w -ldflags -s" 18 | # compiles 6cord without cgo so that it's statically linked 19 | # may affect performance - disabled for ueberzug + xorg 20 | - time go build $FLAGS -o $CI_PROJECT_DIR/6cord 21 | - upx -q --8086 -9 $CI_PROJECT_DIR/6cord 22 | - time go build $FLAGS -o $CI_PROJECT_DIR/6cord_nk -tags nokeyring 23 | - upx -q --8086 -9 $CI_PROJECT_DIR/6cord_nk 24 | artifacts: 25 | paths: 26 | - 6cord 27 | - 6cord_nk 28 | 29 | linux_arm64: 30 | stage: build 31 | script: 32 | - export GOOS=linux GOARCH=arm64 33 | - time go get 34 | - time go build -o $CI_PROJECT_DIR/6cord_arm64 35 | artifacts: 36 | paths: 37 | - 6cord_arm64 38 | 39 | windows: 40 | stage: build 41 | script: 42 | - export GOOS=windows 43 | - time go get 44 | - time go build -o $CI_PROJECT_DIR/6cord.exe 45 | artifacts: 46 | paths: 47 | - 6cord.exe 48 | 49 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "xterm-343"] 2 | path = xterm-343 3 | url = https://gitlab.com/diamondburned/xterm-343 4 | -------------------------------------------------------------------------------- /6cord.toml: -------------------------------------------------------------------------------- 1 | # Email and password authentication flow 2 | # NOT recommended, neither me nor Discord 3 | username = "" 4 | password = "" 5 | 6 | # Token authentication flow 7 | # RECOMMENDED 8 | # Uses the token stored in keyrings if empty, will attempt to 9 | # store the token if the first token works 10 | token = "" 11 | 12 | [ properties ] 13 | # Refer to ./6cord -h for details. 14 | # Defaults are rather defined in the code, which is why this 15 | # should only be used as a reference on how to configure. 16 | # Keys are optional. 17 | true-color = true 18 | default-name-color = "#CCCCCC" 19 | mention-color = "#0D4A91" 20 | mention-self-color = "#17AC86" 21 | compact-mode = false 22 | show-channels = true 23 | chat-padding = 2 24 | sidebar-ratio = 3 25 | sidebar-indent = 2 26 | hide-blocked = true 27 | trigger-typing = true 28 | foreground-color = 15 29 | background-color = -1 30 | author-format = "[#{color}::b]{name}" 31 | command-prefix = "[${GUILD}${CHANNEL}] " 32 | default-status = "Send a message or input a command" 33 | syntax-highlight-colorscheme = "emacs" 34 | show-emoji-urls = true 35 | obfuscate-words = false 36 | chat-max-width = 0 37 | image-fetch-timeout = 1 38 | image-width = 400 39 | image-height = 400 40 | shorten-url = true 41 | 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Deprecated

2 | 3 |

4 | 6cord has been deprecated in favor of [gtkcord3](https://github.com/diamondburned/gtkcord3). Important bugs may not be 5 | fixed, but PRs are still welcomed. 6 |

7 | 8 |

6cord

9 |

10 | 11 | Donate using Liberapay 12 | 13 | 14 | 15 |

16 | 17 | ## Installation 18 | 19 | ### Method 1. (recommended, precompiled binaries) 20 | 21 | **[Linux](https://gitlab.com/diamondburned/6cord/builds/artifacts/master/file/6cord?job=linux)** 22 | **[Linux (no dbus)](https://gitlab.com/diamondburned/6cord/builds/artifacts/master/file/6cord_nk?job=linux)** 23 | **[Linux (arm64)](https://gitlab.com/diamondburned/6cord/builds/artifacts/master/file/6cord_arm64?job=linux_arm64)** 24 | **[Windows](https://gitlab.com/diamondburned/6cord/builds/artifacts/master/file/6cord.exe?job=windows)** 25 | 26 | Only do this if the CI passed (a green tick in the commit bar) 27 | 28 | ### Method 2. (building from source) 29 | 30 | ```sh 31 | git clone https://gitlab.com/diamondburned/6cord 32 | cd 6cord && go build 33 | ./6cord 34 | 35 | # Optional 36 | mkdir -p ~/bin/ 37 | mv ./6cord ~/bin/ 38 | echo PATH="$HOME/bin:$PATH" >> ~/.bashrc && . ~/.bashrc # or any shellrc 39 | ``` 40 | 41 | ### Method 3. (package manager) 42 | 43 | ```sh 44 | # Arch Linux, using your favourite AUR helper: 45 | yay install 6cord 46 | # Alternatively you can install '6cord-git' 47 | # which is the latest development version. 48 | 49 | # FreeBSD: 50 | pkg install 6cord 51 | 52 | # Void Linux: 53 | xbps-install 6cord 54 | 55 | # Alpine Linux (in the testing repo): 56 | apk add 6cord 57 | ``` 58 | 59 | ## Getting the token 60 | 61 | This is possible from both the web client and the Electron client. 62 | 63 | 1. Hit Ctrl+Shift+I 64 | 2. Switch to the `Network` tab 65 | 3. Find Discord API requests. This is usually called `messages`, `ack`, `typing`, etc 66 | 4. Search for the `Authorization` header. This is the token. 67 | 68 | ## Running 6cord with the token 69 | 70 | `./6cord -t "TOKEN_HERE"` 71 | 72 | - If you have Gnome Keyring (usually the case on most DEs), the token would automatically be stored securely. This could be tested by running `./6cord` without any arguments. 73 | - To reset the token, override it with a new one using `-t` 74 | - It is also possible to move the `6cord.toml` file from the root of this Git repository to `~/.config/6cord/`, then run without any arguments. 75 | 76 | ## Additional things 77 | 78 | ### Quirks 79 | 80 | - The ~ key could be used to both preview images and select a message ID 81 | - `/mentions` is useless at the moment. This is planned to change in the future. 82 | - There is currently no global emoji support. This is also planned to change, along with emoji previews. 83 | 84 | ### Additional keybinds 85 | 86 | - Refer to the Quick Start section displayed when starting 6cord 87 | - Tab to show/hide the server list 88 | - Input field history is cycled with Alt + Up/Down 89 | - PgUp and PgDn can be used to jump between servers in the list 90 | - There are some Vim binds available ie ^n and ^p to move between fuzzy listed items 91 | 92 | ### `command-prefix` 93 | 94 | - The following variables are available: `CHANNEL`, `GUILD`, `USERNAME` and `DISCRIM` 95 | - This follows `tview`'s rich text format: 96 | - Coloring text with `[#424242]` 97 | - Bold text with `[::b]` 98 | - Both can be done with `[#424242::b]` 99 | - Reset with `[-]`, `[::-]` or `[-::-]` 100 | - You need to manually escape square brackets by adding an opening (`[`) bracket before a closing (`]`) bracket 101 | - Example: `[${guild}]` to `[${guild}[]` 102 | 103 | ### Color support 104 | 105 | 6cord runs in 256 color mode most of the time. To force true color, run: 106 | 107 | ```sh 108 | TERM=xterm-truecolor ./6cord` 109 | ``` 110 | 111 | (`xterm-truecolor` is known to break a lot of applications including `htop`, only use it with `6cord`) 112 | 113 | To limit 6cord to strictly 16 colors, run: 114 | 115 | ```sh 116 | TERM=xterm-basic ./6cord 117 | ``` 118 | 119 | To run 6cord in monochrome mode: 120 | 121 | ```sh 122 | TERM=xterm ./6cord 123 | ``` 124 | 125 | ### Supported Image backends 126 | 127 | Currently, Xorg is the only supported image backend. SIXEL support proved itself to be challenging with how `tcell` and `tview` call redraws. There is no Kitty terminal implementation in Golang that is available as a library yet (`termui` has a PR with Kitty support). There are things in my priority list right now. That said, PRs are welcomed. 128 | 129 | ## Screenshots 130 | 131 |

132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 |

140 | 141 | ## Credits 142 | 143 | - XTerm from 144 | - https://invisible-island.net/xterm/ 145 | - https://gist.github.com/saitoha/7822989 146 | - Fishy ([RumbleFrog](https://github.com/rumblefrog)) for his 147 | - [discordgo fork](https://github.com/rumblefrog/discordgo) 148 | - [Channel sort lib ~~that he stole from my shittercord~~](https://gist.github.com/rumblefrog/c9ebd9fb84a8955495d4fb7983345530) 149 | - Some people on unixporn and nix nest (ym555, tdeo, ...) 150 | - [cordless](https://github.com/Bios-Marcel/cordless) [(author)](https://github.com/Bios-Marcel) for some of the functions 151 | 152 | -------------------------------------------------------------------------------- /_screenshots/clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/clean.png -------------------------------------------------------------------------------- /_screenshots/commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/commands.png -------------------------------------------------------------------------------- /_screenshots/guildview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/guildview.png -------------------------------------------------------------------------------- /_screenshots/highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/highlight.png -------------------------------------------------------------------------------- /_screenshots/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/img.png -------------------------------------------------------------------------------- /_screenshots/latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/latest.png -------------------------------------------------------------------------------- /_screenshots/mentions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/mentions.png -------------------------------------------------------------------------------- /_screenshots/reactions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/_screenshots/reactions.png -------------------------------------------------------------------------------- /_tests/feh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | [ -z $1 ] && exit 1 3 | 4 | case "$1" in 5 | *.jpg*|*.png*|*.PNG*|*.jpeg*|*.gif*) 6 | H="800" 7 | W="600" 8 | 9 | [[ "$1" = *"/emojis/"* ]] && { 10 | H="50" 11 | W="50" 12 | } 13 | 14 | feh -H $H -W $W -b trans --auto-zoom --xinerama-index 0 -B black -. -x "$1" 15 | ;; 16 | *) 17 | xdg-open "$1" 18 | ;; 19 | esac 20 | 21 | -------------------------------------------------------------------------------- /_tests/main.go.bak: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/bwmarrin/discordgo" 8 | "github.com/davecgh/go-spew/spew" 9 | ) 10 | 11 | func main() { 12 | d, _ := discordgo.New(os.Args[1]) 13 | 14 | d.AddHandler(func(s *discordgo.Session, ts *discordgo.TypingStart) { 15 | spew.Dump(ts) 16 | }) 17 | 18 | defer d.Close() 19 | 20 | e := d.Open() 21 | if e != nil { 22 | panic(e) 23 | } 24 | 25 | time.Sleep(time.Minute * 10) 26 | } 27 | -------------------------------------------------------------------------------- /_tests/term_size/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | 10 | scanner := bufio.NewScanner(os.Stdin) 11 | 12 | var text string 13 | for text != "t" { // break the loop if text == "q" 14 | scanner.Scan() 15 | print("\033[14t") 16 | text = scanner.Text() 17 | } 18 | 19 | println(text) 20 | } 21 | -------------------------------------------------------------------------------- /_tests/tui-go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "image" 5 | "os" 6 | "time" 7 | 8 | _ "image/png" 9 | 10 | tui "github.com/marcusolsson/tui-go" 11 | sixel "github.com/mattn/go-sixel" 12 | ) 13 | 14 | func main() { 15 | path := "/home/diamond/Pictures/peni_ava.png" 16 | 17 | file, err := os.OpenFile(path, os.O_RDONLY, os.ModePerm) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | defer file.Close() 23 | 24 | img, _, err := image.Decode(file) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | tty, err := os.Open("/dev/tty") 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | go func() { 35 | time.Sleep(time.Second * 1) 36 | if err := sixel.NewEncoder(tty).Encode(img); err != nil { 37 | panic(err) 38 | } 39 | }() 40 | 41 | text := tui.NewTextEdit() 42 | text.SetText("homo tard") 43 | 44 | box := tui.NewVBox(text) 45 | 46 | ui, err := tui.New(box) 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | ui.SetKeybinding("Esc", func() { ui.Quit() }) 52 | 53 | if err := ui.Run(); err != nil { 54 | panic(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /_tests/typing/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/bwmarrin/discordgo" 7 | "github.com/davecgh/go-spew/spew" 8 | ) 9 | 10 | func main() { 11 | d, _ := discordgo.New(os.Args[1]) 12 | if err := d.Open(); err != nil { 13 | panic(err) 14 | } 15 | 16 | defer d.Close() 17 | 18 | d.AddHandler(func(s *discordgo.Session, t *discordgo.TypingStart) { 19 | spew.Dump(t) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /_tests/voice/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "sync" 8 | 9 | "github.com/bwmarrin/dgvoice" 10 | "github.com/bwmarrin/discordgo" 11 | "layeh.com/gopus" 12 | ) 13 | 14 | func main() { 15 | d, _ := discordgo.New(os.Args[1]) 16 | if err := d.Open(); err != nil { 17 | panic(err) 18 | } 19 | 20 | defer d.Close() 21 | 22 | c, err := d.State.Channel(os.Args[2]) 23 | if err != nil { 24 | panic(err) 25 | } 26 | 27 | dgv, err := d.ChannelVoiceJoin( 28 | c.GuildID, c.ID, false, false, 29 | ) 30 | 31 | defer dgv.Close() 32 | 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | //dgv.AddHandler(func(vc *discordgo.VoiceConnection, vs *discordgo.VoiceSpeakingUpdate) { 38 | //spew.Dump(vc, vs) 39 | //}) 40 | 41 | recv := make(chan *discordgo.Packet, 2) 42 | go ReceivePCM(dgv, recv) 43 | 44 | send := make(chan []int16, 2) 45 | go dgvoice.SendPCM(dgv, send) 46 | 47 | dgv.Speaking(true) 48 | defer dgv.Speaking(false) 49 | 50 | for { 51 | p, ok := <-recv 52 | if !ok { 53 | return 54 | } 55 | 56 | send <- p.PCM 57 | } 58 | 59 | //portaudio.Initialize() 60 | //defer portaudio.Terminate() 61 | 62 | //out := make([]int16, 960) 63 | 64 | //stream, err := portaudio.OpenDefaultStream(0, 2, 48000, len(out), &out) 65 | //if err != nil { 66 | //panic(err) 67 | //} 68 | 69 | //defer stream.Close() 70 | 71 | //if err := stream.Start(); err != nil { 72 | //panic(err) 73 | //} 74 | 75 | //defer stream.Stop() 76 | 77 | //recv := make(chan *discordgo.Packet, 2) 78 | //go dgvoice.ReceivePCM(dgv, recv) 79 | 80 | //for { 81 | //p, ok := <-recv 82 | //if !ok { 83 | //return 84 | //} 85 | 86 | //log.Println(len(p.PCM)) 87 | 88 | //copy(out, p.PCM) 89 | 90 | //if err := stream.Write(); err != nil { 91 | //panic(err) 92 | //} 93 | //} 94 | } 95 | 96 | var ( 97 | speakers map[uint32]*gopus.Decoder 98 | opusEncoder *gopus.Encoder 99 | mu sync.Mutex 100 | ) 101 | 102 | func ReceivePCM(v *discordgo.VoiceConnection, c chan *discordgo.Packet) { 103 | if c == nil { 104 | return 105 | } 106 | 107 | var err error 108 | 109 | for { 110 | if v.Ready == false || v.OpusRecv == nil { 111 | OnError(fmt.Sprintf("Discordgo not to receive opus packets. %+v : %+v", v.Ready, v.OpusSend), nil) 112 | return 113 | } 114 | 115 | log.Println("I'm desperate") 116 | 117 | p, ok := <-v.OpusRecv 118 | if !ok { 119 | log.Println("Closed ch") 120 | return 121 | } 122 | 123 | log.Println("Received") 124 | 125 | if speakers == nil { 126 | speakers = make(map[uint32]*gopus.Decoder) 127 | } 128 | 129 | _, ok = speakers[p.SSRC] 130 | if !ok { 131 | speakers[p.SSRC], err = gopus.NewDecoder(48000, 2) 132 | if err != nil { 133 | OnError("error creating opus decoder", err) 134 | continue 135 | } 136 | } 137 | 138 | p.PCM, err = speakers[p.SSRC].Decode(p.Opus, 960, false) 139 | if err != nil { 140 | OnError("Error decoding opus data", err) 141 | continue 142 | } 143 | 144 | c <- p 145 | } 146 | } 147 | 148 | func OnError(str string, err error) { 149 | println(str) 150 | panic(err) 151 | } 152 | -------------------------------------------------------------------------------- /_tests/voice/test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "github.com/bwmarrin/discordgo" 7 | "gopkg.in/hraban/opus.v2" 8 | "os" 9 | "path" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | type recordedUser struct { 15 | user *discordgo.User 16 | ssrc uint32 17 | decoder *opus.Decoder 18 | currentFilePath string 19 | fileOpened time.Time 20 | file *os.File 21 | lastTimestamp uint32 // raw timestamp - sample no. 22 | baseTimestamp timestamp 23 | } 24 | 25 | type timestamp struct { 26 | remoteTimestamp uint32 // first raw timestamp received 27 | localTimestamp int64 // time when we received first frame in milliseconds 28 | } 29 | 30 | type mixData struct { 31 | pcm []int16 32 | remotePacketTimestamp uint32 33 | baseTimestamp timestamp 34 | } 35 | 36 | type UserMap map[uint32]*recordedUser 37 | 38 | const frameSize = 2 * 20 * 48000 / 1000 39 | const timeoutFramesCount = (2 * 60 * 1000) / 20 40 | 41 | var silence = []byte{0xF8, 0xFF, 0xFE} 42 | 43 | func (bot *Bot) StartRecording() (chan bool, error) { 44 | channel, err := bot.Session.Channel(bot.Config.RecordedChannel) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | closeChan := make(chan bool) 50 | go recordChannel(channel, closeChan) 51 | 52 | return closeChan, err 53 | } 54 | 55 | /*func mixUsers(input chan mixData, close chan bool) { 56 | var startTime int64 = 0 57 | buffer := make([]int16, frameSize*100) // 2 seconds buffer 58 | 59 | mixFile, err := os.OpenFile("mix.pcm", os.O_RDWR|os.O_CREATE, 0755) 60 | if err != nil { 61 | return 62 | } 63 | 64 | for { 65 | select { 66 | case data := <-input: 67 | localPacketTimestamp := data.baseTimestamp.localTimestamp + int64(((data.remotePacketTimestamp - data.baseTimestamp.remoteTimestamp) / 960) * 20) 68 | 69 | if startTime == 0 { 70 | startTime = data.baseTimestamp.localTimestamp 71 | buffer = append(buffer, data.pcm...) 72 | 73 | startTime += 20 74 | } 75 | 76 | if localPacketTimestamp >= startTime 77 | case <-close: 78 | mixFile.Close() 79 | return 80 | } 81 | } 82 | }*/ 83 | 84 | func recordChannel(channel *discordgo.Channel, closeChan chan bool) { 85 | //mix := make(chan mixData) 86 | users := make(UserMap) 87 | 88 | /*log, err := os.OpenFile("packetlog2.csv", os.O_RDWR|os.O_CREATE, 0755) 89 | if err != nil { 90 | return 91 | } 92 | 93 | log.Write([]byte("Timestamp;SSRC;Sequence\n"))*/ 94 | 95 | pcm := make([]int16, frameSize) 96 | 97 | voice, err := bot.Session.ChannelVoiceJoin(channel.GuildID, channel.ID, true, false) 98 | if err != nil { 99 | return 100 | } 101 | 102 | voice.AddHandler(func(vc *discordgo.VoiceConnection, vs *discordgo.VoiceSpeakingUpdate) { 103 | user, err := bot.Session.User(vs.UserID) 104 | if err != nil { 105 | return 106 | } 107 | 108 | if rec, exists := users[uint32(vs.SSRC)]; !exists { 109 | rec, err := initUser(uint32(vs.SSRC), user) 110 | if err != nil { 111 | return 112 | } 113 | 114 | users[uint32(vs.SSRC)] = rec 115 | } else if rec.user == nil { 116 | err = rec.setUser(user) 117 | if err != nil { 118 | return 119 | } 120 | } 121 | }) 122 | 123 | for i := 0; i < 10; i++ { 124 | voice.OpusSend <- silence 125 | } 126 | 127 | for { 128 | select { 129 | case packet := <-voice.OpusRecv: 130 | //log.Write([]byte(fmt.Sprintf("%d;%d;%d\n", packet.Timestamp, packet.SSRC, packet.Sequence))) 131 | 132 | // throw away silence 133 | if bytes.Equal(packet.Opus, silence) { 134 | continue 135 | } 136 | 137 | if _, exists := users[packet.SSRC]; !exists { 138 | rec, err := initUser(packet.SSRC, nil) 139 | if err != nil { 140 | return 141 | } 142 | 143 | users[packet.SSRC] = rec 144 | } 145 | 146 | user := users[packet.SSRC] 147 | 148 | if user.lastTimestamp != 0 { 149 | silentFrames := (packet.Timestamp - user.lastTimestamp) / 960 150 | 151 | if silentFrames < timeoutFramesCount { 152 | for i := uint32(0); i < silentFrames-1; i++ { 153 | binary.Write(user.file, binary.LittleEndian, new([frameSize]byte)) 154 | } 155 | } else { 156 | user.openFile() 157 | } 158 | } else { 159 | user.baseTimestamp.remoteTimestamp = packet.Timestamp 160 | user.baseTimestamp.localTimestamp = time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)) 161 | } 162 | 163 | user.lastTimestamp = packet.Timestamp 164 | 165 | _, err := user.decoder.Decode(packet.Opus, pcm) 166 | if err != nil { 167 | return 168 | } 169 | 170 | //mix <- mixData{pcm: pcm, baseTimestamp: user.baseTimestamp, remotePacketTimestamp: packet.Timestamp} 171 | 172 | if time.Now().UTC().After(user.fileOpened.Add(1 * time.Hour)) { 173 | user.openFile() 174 | } 175 | 176 | err = binary.Write(user.file, binary.LittleEndian, pcm) 177 | if err != nil { 178 | return 179 | } 180 | 181 | case <-closeChan: 182 | voice.Close() 183 | 184 | //log.Close() 185 | 186 | for _, user := range users { 187 | err := user.file.Close() 188 | if err != nil { 189 | return 190 | } 191 | } 192 | 193 | return 194 | } 195 | } 196 | } 197 | 198 | func initUser(ssrc uint32, user *discordgo.User) (*recordedUser, error) { 199 | var newUser = recordedUser{ssrc: ssrc, user: user} 200 | 201 | err = newUser.openFile() 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | newUser.decoder, err = opus.NewDecoder(48000, 2) 207 | if err != nil { 208 | return nil, err 209 | } 210 | 211 | return &newUser, nil 212 | } 213 | 214 | func (ru *recordedUser) openFile() error { 215 | if ru.file != nil { 216 | err = ru.file.Close() 217 | if err != nil { 218 | return err 219 | } 220 | } 221 | 222 | var identifier string 223 | 224 | if ru.user == nil { 225 | identifier = strconv.Itoa(int(ru.ssrc)) 226 | } else { 227 | identifier = ru.user.Username 228 | } 229 | 230 | ru.currentFilePath = path.Clean(path.Join(bot.Config.OutputPath, identifier+"_"+time.Now().UTC().Format("2006-01-02_15-04-05")) + ".pcm") 231 | ru.file, err = os.OpenFile(ru.currentFilePath, os.O_RDWR|os.O_CREATE, 0755) 232 | ru.fileOpened = time.Now().UTC() 233 | 234 | return err 235 | } 236 | 237 | func (ru *recordedUser) setUser(user *discordgo.User) error { 238 | ru.user = user 239 | 240 | err = ru.file.Close() 241 | if err != nil { 242 | return err 243 | } 244 | 245 | newPath := path.Clean(path.Join(bot.Config.OutputPath, user.Username+"_"+time.Now().UTC().Format("2006-01-02_15-04-05")) + ".pcm") 246 | 247 | err = os.Rename(ru.currentFilePath, newPath) 248 | if err != nil { 249 | return err 250 | } 251 | 252 | ru.currentFilePath = newPath 253 | ru.file, err = os.OpenFile(ru.currentFilePath, os.O_RDWR|os.O_CREATE, 0755) 254 | 255 | return err 256 | } 257 | -------------------------------------------------------------------------------- /antitele/antitele.go: -------------------------------------------------------------------------------- 1 | // Package antitele attempts to insert invisible runes into messages, so that 2 | // collected telemetric data aren't usable for selling to companies, protecting 3 | // privacy 4 | package antitele 5 | 6 | import ( 7 | "math/rand" 8 | "strings" 9 | "time" 10 | "unicode" 11 | ) 12 | 13 | // Probability modifies the probability of an invisible character appearing. 14 | // The lower this is, the less visible characters you can send. 15 | // Rules: [0, n) 0 > n >= +Inf 16 | var Probability = 5 17 | 18 | // ZeroWidthRunes is the array containing all invisible runes. 19 | // U+200B is used for obfuscating. 20 | var ZeroWidthRunes = []rune{ 21 | '\u200b', '\u200c', '\u200d', '\ufeff', 22 | } 23 | 24 | func init() { 25 | rand.Seed(time.Now().UnixNano()) 26 | } 27 | 28 | // Insert works its magic 29 | func Insert(s string) string { 30 | var words = strings.Fields(s) 31 | 32 | var amount = max(0, 2048-len(s)) 33 | var needle int 34 | 35 | for i, w := range words { 36 | needle++ 37 | if amount < needle { 38 | break 39 | } 40 | 41 | // Skip over links for them to be clickable 42 | if strings.HasPrefix(w, "http") { 43 | continue 44 | } 45 | 46 | // Skip if it's not a word 47 | if strings.IndexFunc(w, func(c rune) bool { 48 | return !unicode.IsLetter(c) 49 | }) != -1 { 50 | continue 51 | } 52 | 53 | // Words too short probably doens't need 54 | // to be obfuscated 55 | if len(w) < 3 { 56 | continue 57 | } 58 | 59 | words[i] = obf(w) 60 | } 61 | 62 | return strings.Join(words, " ") 63 | } 64 | 65 | func obf(s string) string { 66 | var r = []rune(s) 67 | var i = rand.Intn(len(r)) 68 | 69 | return string(r[:i]) + "\u200b" + string(r[i:]) 70 | } 71 | 72 | func max(i, j int) int { 73 | if i > j { 74 | return i 75 | } 76 | 77 | return j 78 | } 79 | 80 | func containsRunes(s string, trs ...rune) bool { 81 | var i int 82 | for _, r := range []rune(s) { 83 | for _, tr := range trs { 84 | if tr == r { 85 | i++ 86 | } 87 | } 88 | } 89 | 90 | return i == len(trs) 91 | } 92 | -------------------------------------------------------------------------------- /center/center.go: -------------------------------------------------------------------------------- 1 | package center 2 | 3 | import "github.com/diamondburned/tview/v2" 4 | 5 | type Center struct { 6 | MaxWidth int 7 | MaxHeight int 8 | 9 | x, y int 10 | w, h int 11 | 12 | tview.Primitive 13 | } 14 | 15 | var _ tview.Primitive = (*Center)(nil) 16 | 17 | func New(p tview.Primitive) *Center { 18 | return &Center{ 19 | Primitive: p, 20 | } 21 | } 22 | 23 | // GetRect overrides the embedded Primitive's GetRect method. 24 | func (c *Center) GetRect() (int, int, int, int) { 25 | return c.x, c.y, c.w, c.h 26 | } 27 | 28 | // SetRect overrides the embedded Primitive's SetRect method. 29 | func (c *Center) SetRect(x, y, w, h int) { 30 | c.x, c.y = x, y 31 | c.w, c.h = w, h 32 | 33 | // Get the default primitive positions and sizes 34 | var ( 35 | pW, pH = w, h 36 | pX, pY = x, y 37 | ) 38 | 39 | // If the height is bigger than the max height 40 | if c.MaxHeight > 0 && h > c.MaxHeight { 41 | pH = c.MaxHeight // min 42 | pY = y + (h-c.MaxHeight)/2 // also center the primitive 43 | } 44 | 45 | // If the width is bigger than the max width 46 | if c.MaxWidth > 0 && w > c.MaxWidth { 47 | pW = c.MaxWidth // min 48 | pX = x + (w-c.MaxWidth)/2 // center 49 | } 50 | 51 | c.Primitive.SetRect(pX, pY, pW, pH) 52 | } 53 | -------------------------------------------------------------------------------- /clipboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // IsFile checks if f clipboard is plain text 8 | func IsFile(b []byte) bool { 9 | return http.DetectContentType(b) != "text/plain; charset=utf-8" 10 | } 11 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | senderRegex = strings.NewReplacer() 10 | cmdHistory = make([]string, 0, 256) 11 | ) 12 | 13 | // Commands contains multiple commands 14 | type Commands []Command 15 | 16 | // Command contains a command's info 17 | type Command struct { 18 | Command string 19 | Function func([]string) 20 | Description string 21 | } 22 | 23 | var commands = Commands{ 24 | Command{ 25 | Command: "/goto", 26 | Function: gotoChannel, 27 | Description: "[channel name] - jumps to a channel", 28 | }, 29 | Command{ 30 | Command: "/editor", 31 | Function: commandEditor, 32 | Description: "Pop up $EDITOR to send a message ", 33 | }, 34 | Command{ 35 | Command: "/mentions", 36 | Function: commandMentions, 37 | Description: "shows the last mentions", 38 | }, 39 | Command{ 40 | Command: "/nick", 41 | Function: changeSelfNick, 42 | Description: "[nickname] - changes nickname for the current guild", 43 | }, 44 | Command{ 45 | Command: "/status", 46 | Function: setStatus, 47 | Description: "[online|busy|away|invisible] - sets your status", 48 | }, 49 | Command{ 50 | Command: "/edit", 51 | Function: editMessage, 52 | Description: "[n:int optional] - edits the latest n message of yours", 53 | }, 54 | Command{ 55 | Command: "/delete", 56 | Function: deleteMessage, 57 | Description: "[messageID:int] - deletes the message", 58 | }, 59 | Command{ 60 | Command: "/presence", 61 | Function: setGame, 62 | Description: "[string] - sets your \"Playing\" or \"Listening to\" presence, empty to reset", 63 | }, 64 | Command{ 65 | Command: "/react", 66 | Function: reactMessage, 67 | Description: "[messageID:int] [emoji:string] - toggle reaction on a message", 68 | }, 69 | Command{ 70 | Command: "/upload", 71 | Function: uploadFile, 72 | Description: "[file path] - uploads file", 73 | }, 74 | Command{ 75 | Command: "/heated", 76 | Function: cmdHeated, 77 | Description: "warns you when a message is sent, regardless of settings", 78 | }, 79 | Command{ 80 | Command: "/copy", 81 | Function: matchCopyMessage, 82 | Description: "[n:int] - copies the entire last n message", 83 | }, 84 | Command{ 85 | Command: "/highlight", 86 | Function: highlightMessage, 87 | Description: "[ID:int64] - highlights the message ID if possible", 88 | }, 89 | Command{ 90 | Command: "/dm", 91 | Function: makeDirectMessage, 92 | Description: "[@mention] - starts a new direct message", 93 | }, 94 | Command{ 95 | Command: "/block", 96 | Function: blockUser, 97 | Description: "[@mention] - blocks someone", 98 | }, 99 | Command{ 100 | Command: "/unblock", 101 | Function: unblockUser, 102 | Description: "[@mention] - unblocks someone", 103 | }, 104 | Command{ 105 | Command: "/copytoken", 106 | Function: commandCopyToken, 107 | Description: "prints your token", 108 | }, 109 | Command{ 110 | Command: "/debug", 111 | Function: commandDebug, 112 | Description: "prints extra debug info", 113 | }, 114 | Command{ 115 | Command: "/quit", 116 | Function: commandExit, 117 | Description: "quits ", 118 | }, 119 | } 120 | 121 | func commandExit(text []string) { 122 | app.Stop() 123 | } 124 | 125 | // CommandHandler . 126 | func CommandHandler() { 127 | text := input.GetText() 128 | if text == "" { 129 | return 130 | } 131 | 132 | defer input.SetText("") 133 | 134 | if len(cmdHistory) >= 256 { 135 | cmdHistory = cmdHistory[255:] 136 | } 137 | 138 | cmdHistory = append( 139 | cmdHistory, text, 140 | ) 141 | 142 | switch { 143 | case strings.HasPrefix(text, "s/"): 144 | go editMessageRegex(text) 145 | 146 | case strings.HasPrefix(text, "/"): 147 | f := strings.Fields(text) 148 | if len(f) < 0 { 149 | return 150 | } 151 | 152 | for _, cmd := range commands { 153 | if f[0] == cmd.Command && cmd.Function != nil { 154 | go func() { 155 | defer func() { 156 | if r := recover(); r != nil { 157 | Warn(fmt.Sprintf("%v", r)) 158 | } 159 | }() 160 | 161 | cmd.Function(f) 162 | }() 163 | 164 | return 165 | } 166 | } 167 | 168 | fallthrough 169 | default: 170 | // Trim literal backslash, in case "\/actual message" 171 | text = strings.TrimPrefix(text, `\`) 172 | 173 | if Channel == nil { 174 | Message("You're not in a channel!") 175 | return 176 | } 177 | 178 | go func(text string) { 179 | _, err := d.ChannelMessageSend(Channel.ID, processString(text)) 180 | if err != nil { 181 | Warn("Failed to send message:\n" + text + "\nError: " + err.Error()) 182 | } 183 | 184 | messagesView.ScrollToEnd() 185 | app.Draw() 186 | }(text) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /commandblock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | ) 6 | 7 | func parseUserID(text []string) int64 { 8 | mention := text[1] 9 | 10 | mention = mention[2:] 11 | mention = mention[:len(mention)-1] 12 | 13 | id, err := strconv.ParseInt(mention, 10, 64) 14 | if err != nil { 15 | Message(err.Error()) 16 | return 0 17 | } 18 | 19 | return id 20 | } 21 | 22 | func blockUser(text []string) { 23 | id := parseUserID(text) 24 | if id == 0 { 25 | return 26 | } 27 | 28 | if err := d.RelationshipUserBlock(id); err != nil { 29 | Warn(err.Error()) 30 | return 31 | } 32 | } 33 | 34 | func unblockUser(text []string) { 35 | id := parseUserID(text) 36 | if id == 0 { 37 | return 38 | } 39 | 40 | if err := d.RelationshipDelete(id); err != nil { 41 | Warn(err.Error()) 42 | return 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /commandcopy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/atotto/clipboard" 7 | "github.com/diamondburned/discordgo" 8 | ) 9 | 10 | func matchCopyMessage(text []string) { 11 | if len(text) != 2 { 12 | Message("Invalid args! Refer to description.") 13 | return 14 | } 15 | 16 | residue, err := strconv.Atoi(text[1]) 17 | if err != nil { 18 | Message(err.Error()) 19 | return 20 | } 21 | 22 | if Channel == nil { 23 | Message("You're not in a channel!") 24 | return 25 | } 26 | 27 | var message *discordgo.Message 28 | 29 | m, err := d.State.Message(Channel.ID, int64(residue)) 30 | if err == nil { 31 | message = m 32 | } else { 33 | for i := len(messageStore) - 1; i >= 0; i-- { 34 | if ID := getIDfromindex(i); ID != 0 { 35 | m, err := d.State.Message(Channel.ID, ID) 36 | if err != nil { 37 | continue 38 | } 39 | 40 | if residue == 0 { 41 | message = m 42 | break 43 | } 44 | 45 | residue-- 46 | } 47 | } 48 | } 49 | 50 | if message == nil { 51 | Message("Can't find any message to copy.") 52 | return 53 | } 54 | 55 | if err := clipboard.WriteAll(message.Content); err != nil { 56 | Warn(err.Error()) 57 | } else { 58 | Message("Copied message from " + message.Author.Username) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /commanddebug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "runtime/debug" 7 | "strings" 8 | "text/tabwriter" 9 | "time" 10 | ) 11 | 12 | func commandDebug(text []string) { 13 | s := strings.Builder{} 14 | w := tabwriter.NewWriter(&s, 0, 0, 1, ' ', 0) 15 | 16 | fmt.Fprintf(w, "Channel ID:\t%d\n", Channel.ID) 17 | fmt.Fprintf(w, "Channel Icon:\t%s\n", Channel.Icon) 18 | fmt.Fprintf(w, "Guild ID:\t%d\n", Channel.GuildID) 19 | 20 | if g, _ := d.State.Guild(Channel.GuildID); g != nil { 21 | fmt.Fprintf(w, 22 | "Guild Icon:\thttps://cdn.discordapp.com/icons/%d/%s.png\n", 23 | g.ID, g.Icon, 24 | ) 25 | } 26 | 27 | fmt.Fprintf(w, "Number of goroutines:\t%d\n", runtime.NumGoroutine()) 28 | fmt.Fprintf(w, "GOMAXPROCS:\t%d\n", runtime.GOMAXPROCS(-1)) 29 | fmt.Fprintf(w, "GOOS:\t%s\n", runtime.GOOS) 30 | fmt.Fprintf(w, "GOARCH:\t%s\n", runtime.GOARCH) 31 | fmt.Fprintf(w, "Go version:\t%s", runtime.Version()) 32 | 33 | var gc = &debug.GCStats{} 34 | debug.ReadGCStats(gc) 35 | 36 | if gc != nil { 37 | fmt.Fprintf(w, "\nLast garbage collection:\t%s\n", gc.LastGC.Format(time.Kitchen)) 38 | fmt.Fprintf(w, "Total garbage collection:\t%d\n", gc.NumGC) 39 | fmt.Fprintf(w, "Total pause for GC:\t%d", gc.PauseTotal) 40 | } 41 | 42 | var mem = &runtime.MemStats{} 43 | runtime.ReadMemStats(mem) 44 | 45 | if mem != nil { 46 | fmt.Fprintf(w, 47 | "\nTotal RAM usage:\t%.2f MB\n", 48 | float64(mem.Alloc)/1000000, 49 | ) 50 | fmt.Fprintf(w, 51 | "Total heap allocated:\t%.2f MB\n", 52 | float64(mem.HeapAlloc)/1000000, 53 | ) 54 | } 55 | 56 | if err := w.Flush(); err != nil { 57 | Warn(err.Error()) 58 | return 59 | } 60 | 61 | Message(s.String()) 62 | 63 | runtime.GC() 64 | } 65 | -------------------------------------------------------------------------------- /commanddelete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | func deleteMessage(text []string) { 9 | toDelete := make([]int64, 0, len(text)) 10 | 11 | if len(text) == 1 { 12 | lastMsg := matchMyMessage(0) 13 | if lastMsg == nil { 14 | Message("Can't find your last message :(") 15 | return 16 | } 17 | 18 | toDelete = append(toDelete, lastMsg.ID) 19 | } else { 20 | for i, a := range text[1:] { 21 | m, err := strconv.Atoi(a) 22 | if err != nil { 23 | Message(fmt.Sprintf("Failed to parse argument %d", i-1)) 24 | return 25 | } 26 | 27 | lastMsg := matchMyMessage(m) 28 | if lastMsg == nil { 29 | Message("Can't find your last message :(") 30 | return 31 | } 32 | 33 | toDelete = append(toDelete, lastMsg.ID) 34 | } 35 | } 36 | 37 | for _, m := range toDelete { 38 | if err := d.ChannelMessageDelete(Channel.ID, m); err != nil { 39 | Warn(err.Error()) 40 | return 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /commanddm.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func parseUserMention(m string) int64 { 9 | i := 0 10 | trim := strings.TrimFunc(m, func(r rune) bool { 11 | switch { 12 | case r == '<', r == '>', r == '@': 13 | i++ 14 | return true 15 | default: 16 | return false 17 | } 18 | }) 19 | 20 | id, _ := strconv.ParseInt(trim, 10, 64) 21 | if i != 3 { 22 | return 0 23 | } 24 | 25 | return id 26 | } 27 | 28 | func makeDirectMessage(text []string) { 29 | if len(text) != 2 { 30 | Message("No channels given!") 31 | return 32 | } 33 | 34 | id := parseUserMention(text[1]) 35 | if id == 0 { 36 | Message("Invalid user mention!") 37 | return 38 | } 39 | 40 | ch, err := d.UserChannelCreate(id) 41 | if err != nil { 42 | Message(err.Error()) 43 | } 44 | 45 | loadChannel(ch.ID) 46 | } 47 | -------------------------------------------------------------------------------- /commandedit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/diamondburned/discordgo" 11 | "github.com/diamondburned/tcell" 12 | ) 13 | 14 | const ( 15 | // EditMessageLabel is used to detect which function to use 16 | EditMessageLabel = "Edit " 17 | ) 18 | 19 | var toEditMessage int64 20 | 21 | func editMessage(text []string) { 22 | if Channel == nil { 23 | Message("You're not in a channel!") 24 | return 25 | } 26 | 27 | var messageN int 28 | 29 | if len(text) == 2 { 30 | i, err := strconv.Atoi(text[1]) 31 | if err != nil { 32 | Message(err.Error()) 33 | return 34 | } 35 | 36 | messageN = i 37 | } 38 | 39 | lastMsg := matchMyMessage(messageN) 40 | if lastMsg == nil { 41 | Message("Can't find your last message :(") 42 | return 43 | } 44 | 45 | toEditMessage = lastMsg.ID 46 | 47 | input.SetBackgroundColor(tcell.ColorBlue) 48 | input.SetFieldBackgroundColor(tcell.ColorBlue) 49 | input.SetPlaceholderTextColor(tcell.ColorWhite) 50 | input.SetLabel("Edit ") 51 | input.SetPlaceholder("Send this empty message to delete it") 52 | input.SetText(lastMsg.Content) 53 | 54 | messagesView.Highlight(strconv.FormatInt(lastMsg.ID, 10)) 55 | } 56 | 57 | func editHandler() { 58 | var ( 59 | edit = toEditMessage 60 | i = input.GetText() 61 | err error 62 | ) 63 | 64 | resetInputBehavior() 65 | 66 | if i != "" { 67 | _, err = d.ChannelMessageEdit( 68 | Channel.ID, edit, processString(i), 69 | ) 70 | } else { 71 | err = d.ChannelMessageDelete( 72 | Channel.ID, edit, 73 | ) 74 | } 75 | 76 | if err != nil { 77 | Warn(err.Error()) 78 | } 79 | } 80 | 81 | func editMessageRegex(text string) { 82 | if Channel == nil { 83 | Message("You're not in a channel!") 84 | } 85 | 86 | input := csv.NewReader(strings.NewReader(text)) 87 | input.Comma = '/' // delimiter 88 | args, err := input.Read() 89 | if err != nil { 90 | Warn(err.Error()) 91 | return 92 | } 93 | 94 | if len(args) != 3 && len(args) != 4 { 95 | Message(fmt.Sprintf("Invalid arguments! %d", len(args))) 96 | return 97 | } 98 | 99 | var ( 100 | regexArg = args[1] 101 | withArg = args[2] 102 | messageN int 103 | ) 104 | 105 | if len(args) == 4 { 106 | order := args[3] 107 | 108 | if order != "" && order != "g" { 109 | messageN, _ = strconv.Atoi(order) 110 | } 111 | } 112 | 113 | regex, err := regexp.Compile(regexArg) 114 | if err != nil { 115 | Message(err.Error()) 116 | return 117 | } 118 | 119 | lastMsg := matchMyMessage(messageN) 120 | if lastMsg == nil { 121 | Message("Can't find your last message :(") 122 | return 123 | } 124 | 125 | repl := regex.ReplaceAllString(lastMsg.Content, withArg) 126 | 127 | _, err = d.ChannelMessageEdit( 128 | lastMsg.ChannelID, 129 | lastMsg.ID, 130 | repl, 131 | ) 132 | 133 | if err != nil { 134 | Warn(err.Error()) 135 | } 136 | } 137 | 138 | func matchMyMessage(residue int) *discordgo.Message { 139 | m, err := d.State.Message(Channel.ID, int64(residue)) 140 | if err == nil { 141 | return m 142 | } 143 | 144 | for i := len(messageStore) - 1; i >= 0; i-- { 145 | if ID := getIDfromindex(i); ID != 0 { 146 | m, err := d.State.Message(Channel.ID, ID) 147 | if err != nil { 148 | continue 149 | } 150 | 151 | if m.Author.ID == d.State.User.ID { 152 | if residue == 0 { 153 | return m 154 | } 155 | 156 | residue-- 157 | } 158 | } 159 | } 160 | 161 | return nil 162 | } 163 | -------------------------------------------------------------------------------- /commandeditor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func commandEditor(text []string) { 4 | if Channel == nil { 5 | Message("You're not in a channel!") 6 | return 7 | } 8 | 9 | b, err := summonEditor() 10 | if err != nil { 11 | Warn(err.Error()) 12 | return 13 | } 14 | 15 | if _, err := d.ChannelMessageSend(Channel.ID, string(b)); err != nil { 16 | Warn(err.Error()) 17 | return 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /commandgoto.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/diamondburned/discordgo" 8 | "github.com/diamondburned/tview/v2" 9 | ) 10 | 11 | func parseChannelID(input string) int64 { 12 | chID := strings.TrimSpace(input) 13 | 14 | chID = chID[2:] 15 | chID = chID[:len(chID)-1] 16 | 17 | id, err := strconv.ParseInt(chID, 10, 64) 18 | if err != nil { 19 | Message(err.Error()) 20 | return 0 21 | } 22 | 23 | return id 24 | } 25 | 26 | func gotoChannel(text []string) { 27 | if len(text) != 2 { 28 | Message("No channels given!") 29 | return 30 | } 31 | 32 | var id int64 33 | 34 | switch { 35 | case strings.HasPrefix(text[1], "<#"): 36 | id = parseChannelID(text[1]) 37 | case strings.HasPrefix(text[1], "<@"): 38 | ch, err := d.UserChannelCreate(parseUserMention(text[1])) 39 | if err != nil { 40 | Warn(err.Error()) 41 | return 42 | } 43 | 44 | id = ch.ID 45 | } 46 | 47 | if id == 0 { 48 | Message("No channels given!") 49 | return 50 | } 51 | 52 | go func() { 53 | root := guildView.GetRoot() 54 | if root == nil { 55 | return 56 | } 57 | 58 | root.Walk(func(node, parent *tview.TreeNode) bool { 59 | if parent == nil { 60 | CollapseAll(node) 61 | return true 62 | } 63 | 64 | refr, ok := node.GetReference().(*discordgo.Channel) 65 | if !ok { 66 | return true 67 | } 68 | 69 | if id != refr.ID { 70 | return false 71 | } 72 | 73 | node.Expand() 74 | parent.Expand() 75 | guildView.SetCurrentNode(node) 76 | 77 | return false 78 | }) 79 | }() 80 | 81 | resetInputBehavior() 82 | 83 | loadChannel(id) 84 | } 85 | -------------------------------------------------------------------------------- /commandheated.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func cmdHeated(text []string) { 4 | if Channel == nil { 5 | Message("You're not in a channel!") 6 | return 7 | } 8 | 9 | if heatedChannelsToggle(Channel.ID) { 10 | Message("Added this channel. We'll warn you when there's a message.") 11 | } else { 12 | Message("Removed this channel.") 13 | } 14 | 15 | // Heated servers are checked in notify.go 16 | // Check function is in heated.go 17 | } 18 | -------------------------------------------------------------------------------- /commandmentions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/diamondburned/tview/v2" 8 | ) 9 | 10 | func commandMentions(text []string) { 11 | input.SetPlaceholder("Loading mentions...") 12 | defer input.SetPlaceholder(cfg.Prop.DefaultStatus) 13 | 14 | mentions, err := getMentions() 15 | if err != nil { 16 | Warn(err.Error()) 17 | return 18 | } 19 | 20 | Channel = nil 21 | messageStore = []string{} 22 | messagesView.Clear() 23 | 24 | for i := len(mentions) - 1; i >= 0; i-- { 25 | m := mentions[i] 26 | 27 | username, color := us.DiscordThis(m) 28 | 29 | sentTime, err := m.Timestamp.Parse() 30 | if err != nil { 31 | sentTime = time.Now() 32 | } 33 | 34 | messagesView.Write([]byte( 35 | authorTmpl.ExecuteString(map[string]interface{}{ 36 | "color": fmtHex(color), 37 | "name": tview.Escape(username), 38 | "time": sentTime.Format(time.Stamp), 39 | }), 40 | )) 41 | 42 | messagesView.Write([]byte( 43 | messageTmpl.ExecuteString(map[string]interface{}{ 44 | "ID": strconv.FormatInt(m.ID, 10), 45 | "content": fmtMessage(m), 46 | }), 47 | )) 48 | } 49 | 50 | wrapFrame.SetTitle("[Mentions[]") 51 | input.SetPlaceholder("Done.") 52 | app.Draw() 53 | 54 | time.Sleep(time.Second * 5) 55 | } 56 | -------------------------------------------------------------------------------- /commandmessages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func highlightMessage(text []string) { 4 | if len(text) != 2 { 5 | messagesView.Highlight() 6 | return 7 | } 8 | 9 | messagesView.Highlight(text[1]) 10 | messagesView.ScrollToHighlight() 11 | } 12 | -------------------------------------------------------------------------------- /commandnick.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | func changeSelfNick(text []string) { 6 | if Channel == nil { 7 | Message("You're not in a channel!") 8 | return 9 | } 10 | 11 | if len(text) < 2 { 12 | Message("Missing nickname argument!") 13 | return 14 | } 15 | 16 | nickname := strings.Join(text[1:], " ") 17 | 18 | ch, err := d.State.Channel(Channel.ID) 19 | if err != nil { 20 | Warn(err.Error()) 21 | return 22 | } 23 | 24 | if ch.GuildID == 0 { 25 | Message("You can't set a nickname in a DM") 26 | return 27 | } 28 | 29 | err = d.GuildMemberNicknameMe( 30 | ch.GuildID, 31 | nickname, 32 | ) 33 | 34 | if err != nil { 35 | Message(err.Error()) 36 | return 37 | } 38 | 39 | Message("Changed successfully") 40 | 41 | go func() { 42 | i, u := us.GetUser(ch.GuildID, d.State.User.ID) 43 | if u != nil { 44 | us.Guilds[ch.GuildID][i].Nick = nickname 45 | } 46 | }() 47 | } 48 | -------------------------------------------------------------------------------- /commandreact.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | func reactMessage(text []string) { 9 | if Channel == nil { 10 | Message("You're not in a channel!") 11 | return 12 | } 13 | 14 | if len(text) < 3 { 15 | Message("Invalid arguments! Refer to description.") 16 | return 17 | } 18 | 19 | messageID, err := strconv.ParseInt(text[1], 10, 64) 20 | if err != nil { 21 | Message("Failed to find the message.") 22 | return 23 | } 24 | 25 | message, err := d.State.Message(Channel.ID, messageID) 26 | if err != nil { 27 | Message("Failed to find the message.") 28 | return 29 | } 30 | 31 | var ( 32 | emoji = make([]string, len(text)-2) 33 | reacted = make([]bool, len(text)-2) 34 | ) 35 | 36 | for i := 0; i < len(text)-2; i++ { 37 | regres := EmojiRegex.FindAllStringSubmatch(text[i+2], -1) 38 | if len(regres) > 0 && len(regres[0]) == 4 { 39 | emoji[i] = regres[0][2] + ":" + regres[0][3] 40 | 41 | for _, r := range message.Reactions { 42 | if r.Emoji == nil { 43 | continue 44 | } 45 | 46 | if strconv.FormatInt(r.Emoji.ID, 10) == regres[0][3] { 47 | reacted[i] = r.Me 48 | break 49 | } 50 | } 51 | } else { 52 | emoji[i] = strings.TrimSpace(text[i+2]) 53 | 54 | for _, r := range message.Reactions { 55 | if r.Emoji == nil { 56 | continue 57 | } 58 | 59 | if r.Emoji.Name == text[i+2] { 60 | reacted[i] = r.Me 61 | break 62 | } 63 | } 64 | } 65 | } 66 | 67 | for i := 0; i < len(emoji); i++ { 68 | if reacted[i] { 69 | err = d.MessageReactionRemoveMe( 70 | Channel.ID, 71 | message.ID, 72 | emoji[i], 73 | ) 74 | } else { 75 | err = d.MessageReactionAdd( 76 | Channel.ID, 77 | message.ID, 78 | emoji[i], 79 | ) 80 | } 81 | 82 | if err != nil { 83 | Warn(err.Error()) 84 | return 85 | } 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /commandsixel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "net/http" 8 | "os" 9 | 10 | _ "image/gif" 11 | _ "image/jpeg" 12 | _ "image/png" 13 | 14 | "github.com/disintegration/imaging" 15 | "github.com/mattn/go-sixel" 16 | ) 17 | 18 | var redrawDisabled bool 19 | 20 | func commandSixelTest(text []string) { 21 | r, err := http.Get(text[1]) 22 | if err != nil { 23 | Warn(err.Error()) 24 | return 25 | } 26 | 27 | defer r.Body.Close() 28 | 29 | img, _, err := image.Decode(r.Body) 30 | if err != nil { 31 | Warn(err.Error()) 32 | return 33 | } 34 | 35 | if img.Bounds().Dx() > 200 { 36 | img = imaging.Resize(img, 200, 0, imaging.Linear) 37 | } 38 | 39 | var b bytes.Buffer 40 | 41 | enc := sixel.NewEncoder(&b) 42 | enc.Dither = false 43 | 44 | if err := enc.Encode(img); err != nil { 45 | Warn(err.Error()) 46 | return 47 | } 48 | 49 | redrawDisabled = true 50 | fmt.Fprint(os.Stdout, string(b.Bytes())) 51 | } 52 | -------------------------------------------------------------------------------- /commandstatus.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/diamondburned/discordgo" 7 | ) 8 | 9 | func setStatus(input []string) { 10 | if d.State.Settings == nil { 11 | Message("Settings are uninitialized") 12 | return 13 | } 14 | 15 | s := d.State.Settings.Status 16 | 17 | if len(input) < 2 { 18 | switch s { 19 | case discordgo.StatusOnline: 20 | Message("Status: Online") 21 | case discordgo.StatusIdle: 22 | Message("Status: Idle") 23 | case discordgo.StatusDoNotDisturb: 24 | Message("Status: Do not disturb") 25 | case discordgo.StatusInvisible: 26 | Message("Status: Invisible") 27 | default: 28 | Message(string(s)) 29 | } 30 | 31 | return 32 | } 33 | 34 | switch strings.Join(input[1:], " ") { 35 | case string(discordgo.StatusOnline), "Online": 36 | s = discordgo.StatusOnline 37 | 38 | case string(discordgo.StatusIdle), "Idle", 39 | "Away", "away": 40 | s = discordgo.StatusIdle 41 | 42 | case string(discordgo.StatusDoNotDisturb), 43 | "do not disturb", "Do not disturb", "Do Not Disturb", 44 | "Busy", "busy": 45 | s = discordgo.StatusDoNotDisturb 46 | 47 | case string(discordgo.StatusInvisible), "invis", "Invisible": 48 | s = discordgo.StatusInvisible 49 | 50 | default: 51 | Message("Unknown status to set, check description") 52 | return 53 | } 54 | 55 | if _, err := d.UserUpdateStatus(s); err != nil { 56 | Warn(err.Error()) 57 | return 58 | } 59 | 60 | Message("Set status to " + string(s)) 61 | } 62 | 63 | func setListen(text []string) { 64 | if len(text) < 2 { 65 | Message("Missing string!") 66 | return 67 | } 68 | 69 | s := strings.Join(text[1:], " ") 70 | 71 | if err := d.UpdateListeningStatus(s); err != nil { 72 | Message(err.Error()) 73 | } else { 74 | Message("Set listening status to " + s) 75 | } 76 | } 77 | 78 | func setGame(text []string) { 79 | var s string 80 | if len(text) > 1 { 81 | s = strings.Join(text[1:], " ") 82 | } 83 | 84 | var ( 85 | msg string 86 | gametype = discordgo.GameTypeGame 87 | ) 88 | 89 | switch { 90 | case strings.HasPrefix(strings.ToLower(s), "listening to "): 91 | s = s[13:] 92 | gametype = discordgo.GameTypeListening 93 | msg = "Set listening to " 94 | case strings.HasPrefix(strings.ToLower(s), "watching "): 95 | s = s[9:] 96 | gametype = discordgo.GameTypeWatching 97 | msg = "Set watching " 98 | default: 99 | msg = "Set game to " 100 | } 101 | 102 | usd := discordgo.UpdateStatusData{ 103 | Status: string(d.State.Settings.Status), 104 | Game: &discordgo.Game{ 105 | Name: s, 106 | Type: gametype, 107 | }, 108 | } 109 | 110 | if err := d.UpdateStatusComplex(usd); err != nil { 111 | Message(err.Error()) 112 | return 113 | } 114 | 115 | if s != "" { 116 | Message(msg + s + ".") 117 | } else { 118 | Message("Reset presence successfully.") 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /commandtoken.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/atotto/clipboard" 4 | 5 | func commandCopyToken(text []string) { 6 | if err := clipboard.WriteAll(d.Token); err != nil { 7 | Warn(err.Error()) 8 | return 9 | } 10 | 11 | Message("Token copied to clipboard.") 12 | } 13 | -------------------------------------------------------------------------------- /commandupload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | func uploadFile(args []string) { 9 | if Channel == nil { 10 | Message("You're not in a channel!") 11 | return 12 | } 13 | 14 | if len(args) < 2 { 15 | Message("Missing file path!") 16 | return 17 | } 18 | 19 | file, err := os.Open(strings.Join(args[1:], " ")) 20 | if err != nil { 21 | Warn(err.Error()) 22 | return 23 | } 24 | 25 | fileparts := strings.Split(file.Name(), "/") 26 | 27 | _, err = d.ChannelFileSend( 28 | Channel.ID, 29 | fileparts[len(fileparts)-1], 30 | file, 31 | ) 32 | 33 | if err != nil { 34 | Warn(err.Error()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // CustomCommands is for user-made commands 4 | var CustomCommands = []Command{ 5 | Command{ 6 | Command: "/shrug", 7 | Function: cmdShrug, 8 | Description: `¯\_(ツ)_/¯`, 9 | }, 10 | } 11 | 12 | // `text` is the chat argument, split into arrays. 13 | // This is done with strings.Fields(messageContent). 14 | // For shell-like argument splitting, join the array and run it through 15 | // a CSV reader, delimiter ' '. 16 | func cmdShrug(text []string) { 17 | if Channel == nil { 18 | // Error handling in case nil crashes the entire app 19 | Message("You're not in a channel!") 20 | } 21 | 22 | // Channel is a global variable indicating the current channel. 23 | // Writing to this variable will screw _everthing_ up. 24 | if _, err := d.ChannelMessageSend(Channel.ID, `¯\_(ツ)_/¯`); err != nil { 25 | Warn(err.Error()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /configfile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/stevenroose/gonfig" 11 | fast "github.com/valyala/fasttemplate" 12 | "gitlab.com/diamondburned/6cord/md" 13 | ) 14 | 15 | var cfg Config 16 | 17 | // Properties .. 18 | type Properties struct { 19 | CompactMode bool `id:"compact-mode" default:"true" desc:"Compact Mode"` 20 | TrueColor bool `id:"true-color" default:"true" desc:"Enable True color mode instead of 256 color mode"` 21 | DefaultNameColor string `id:"default-name-color" default:"#CCCCCC" desc:"Sets the default name color, format: #XXXXXX"` 22 | MentionColor string `id:"mention-color" default:"#0D4A91" desc:"The default mention background color"` 23 | MentionSelfColor string `id:"mention-self-color" default:"#17AC86" desc:"The default mention background color, when the target is you"` 24 | ShowChannelsOnStartup bool `id:"show-channels" default:"true" desc:"Show the left channel bar on startup."` 25 | ChatPadding int `id:"chat-padding" default:"2" desc:"Determine the default indentation of messages from the left side."` 26 | SidebarRatio int `id:"sidebar-ratio" default:"3" desc:"The sidebar width in ratio of 1:N, whereas N is the ratio for the message box. The higher the number is, the narrower the sidebar is."` 27 | SidebarIndent int `id:"sidebar-indent" default:"2" desc:"Width in spaces each indentation level on the sidebar adds."` 28 | HideBlocked bool `id:"hide-blocked" default:"true" desc:"Ignore all blocked users."` 29 | TriggerTyping bool `id:"trigger-typing" default:"true" desc:"Send a TypingStart event periodically to the Discord server, default behavior of clients."` 30 | ForegroundColor int `id:"foreground-color" default:"15" desc:"Default foreground color, 0-255, 0 is black, 15 is white."` 31 | BackgroundColor int `id:"background-color" default:"-1" desc:"Acceptable values: tcell.Color*, -1, 0-255 (terminal colors)."` 32 | AuthorFormat string `id:"author-format" default:"[#{color}::b]{name}" desc:"The formatting of message authors"` 33 | CommandPrefix string `id:"command-prefix" default:"[${GUILD}${CHANNEL}] " desc:"The prefix of the input box"` 34 | DefaultStatus string `id:"default-status" default:"Send a message or input a command" desc:"The message in the status bar."` 35 | SyntaxHighlightColorscheme string `id:"syntax-highlight-colorscheme" default:"emacs" desc:"The color scheme for syntax highlighting, refer to https://xyproto.github.io/splash/docs/all.html."` 36 | ShowEmojiURLs bool `id:"show-emoji-urls" default:"true" desc:"Converts emojis into clickable URLs."` 37 | ObfuscateWords bool `id:"obfuscate-words" default:"false" desc:"Insert a zero-width space to obfuscate word-logging telemetry."` 38 | ChatMaxWidth int `id:"chat-max-width" default:"0" desc:"The maximum width of the chat box, if smaller, will be centered."` 39 | ImageFetchTimeout int `id:"image-fetch-timeout" default:"1" desc:"The timeout to fetch images, in seconds."` 40 | ImageWidth int `id:"image-width" default:"400" desc:"The maximum width for an image."` 41 | ImageHeight int `id:"image-height" default:"400" desc:"The maximum height for an image."` 42 | ShortenURL bool `id:"shorten-url" default:"true" desc:"Opens a webserver to redirect URLs"` 43 | RPCServer bool `id:"rpc-server" default:"true" desc:"Start a Rich Presence server for applications to use. Experimental. Source: https://gitlab.com/diamondburned/drpc-server"` 44 | } 45 | 46 | type Config struct { 47 | Username string `id:"username" short:"u" default:"" desc:"Used when token is empty, avoid if 2FA"` 48 | Password string `id:"password" short:"p" default:"" desc:"Used when token is empty"` 49 | Token string `id:"token" short:"t" default:"" desc:"Authentication Token, recommended way of using"` 50 | 51 | Login bool `id:"login" short:"l" default:"false" desc:"Force pop up a login prompt"` 52 | 53 | Prop Properties `id:"properties"` 54 | 55 | Debug bool `id:"debug" short:"d" default:"false" desc:"Enables debug mode"` 56 | 57 | Config string `short:"c"` 58 | } 59 | 60 | var ( 61 | authorRawFormat string 62 | authorPrefix string 63 | messageRawFormat string 64 | 65 | // color, name, time 66 | authorTmpl *fast.Template 67 | 68 | // ID, content 69 | messageTmpl *fast.Template 70 | 71 | chatPadding string 72 | 73 | defaultNameColor int 74 | ) 75 | 76 | func loadCfg() error { 77 | // Get the XDG paths 78 | var xdg = os.Getenv("XDG_CONFIG_HOME") 79 | if xdg == "" { 80 | if h, err := os.UserHomeDir(); err == nil { 81 | xdg = filepath.Join(h, ".config") 82 | } 83 | } 84 | 85 | err := gonfig.Load(&cfg, gonfig.Conf{ 86 | ConfigFileVariable: "config", 87 | FileDefaultFilename: filepath.Join(xdg, "6cord", "6cord.toml"), 88 | FileDecoder: gonfig.DecoderTOML, 89 | EnvPrefix: "sixcord_", 90 | }) 91 | 92 | if err != nil { 93 | return err 94 | } 95 | 96 | if cfg.Config != "" { 97 | return gonfig.Load(&cfg, gonfig.Conf{ 98 | ConfigFileVariable: "config", 99 | FileDefaultFilename: cfg.Config, 100 | FileDecoder: gonfig.DecoderTOML, 101 | EnvPrefix: "sixcord_", 102 | }) 103 | } 104 | 105 | if cfg.Prop.TrueColor { 106 | term := os.Getenv("TERM") 107 | if strings.Contains(term, "-") { 108 | os.Setenv("TERM", strings.Split(term, "-")[0]+"-truecolor") 109 | } 110 | } 111 | 112 | hex := cfg.Prop.DefaultNameColor 113 | if len(hex) < 6 { 114 | return errors.New("Invalid format for name color, refer to help") 115 | } 116 | 117 | if hex[0] == '#' { 118 | hex = hex[1:] 119 | } 120 | 121 | hex64, err := strconv.ParseInt(hex, 16, 64) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | defaultNameColor = int(hex64) 127 | messageRawFormat = `["{ID}"][-]{content}[-::-]["ENDMESSAGE"]` 128 | 129 | if cfg.Prop.CompactMode { 130 | messageRawFormat = " " + messageRawFormat 131 | authorPrefix = "\n[\"author\"]" 132 | authorRawFormat = authorPrefix + cfg.Prop.AuthorFormat + `[-:-:-][""]` 133 | } else { 134 | messageRawFormat = "\n" + messageRawFormat 135 | authorPrefix = "\n\n[\"author\"]" 136 | authorRawFormat = authorPrefix + cfg.Prop.AuthorFormat + ` [-:-:-][::d]{time}[::-][""]` 137 | } 138 | 139 | authorTmpl = fast.New(authorRawFormat, "{", "}") 140 | messageTmpl = fast.New(messageRawFormat, "{", "}") 141 | 142 | chatPadding = strings.Repeat(" ", cfg.Prop.ChatPadding) 143 | 144 | showChannels = cfg.Prop.ShowChannelsOnStartup 145 | md.HighlightStyle = cfg.Prop.SyntaxHighlightColorscheme 146 | 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /demojis/fuzzy.go: -------------------------------------------------------------------------------- 1 | package demojis 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | "github.com/sahilm/fuzzy" 6 | ) 7 | 8 | // Emojis contains a list of emojis 9 | // DiscordEmojis generate Discordgo emojis 10 | // with ID always being -2 11 | var Emojis, DiscordEmojis = makeArray() 12 | 13 | func makeArray() (a []string, d []*discordgo.Emoji) { 14 | a = make([]string, len(emojiCodeMap)) 15 | d = make([]*discordgo.Emoji, len(emojiCodeMap)) 16 | 17 | i := 0 18 | for e := range emojiCodeMap { 19 | a[i] = e 20 | d[i] = &discordgo.Emoji{ 21 | ID: -2, 22 | Name: e, 23 | } 24 | 25 | i++ 26 | } 27 | 28 | return 29 | } 30 | 31 | // FuzzyEmojis fuzzy searches the emojis 32 | // Argument: p == pattern 33 | func FuzzyEmojis(p string) []fuzzy.Match { 34 | return fuzzy.Find(p, Emojis) 35 | } 36 | 37 | // MatchEmoji matches a fuzzy search with the emoji 38 | func MatchEmoji(m fuzzy.Match) string { 39 | vl, ok := emojiCodeMap[m.Str] 40 | if !ok { 41 | // should never happen 42 | return "" 43 | } 44 | 45 | return vl 46 | } 47 | 48 | // GetEmojiFromKey returns "", false if emoji isn't found 49 | func GetEmojiFromKey(k string) (string, bool) { 50 | v, ok := emojiCodeMap[k] 51 | return v, ok 52 | } 53 | -------------------------------------------------------------------------------- /docs/6cord.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diamondburned/6cord/979d7a6ace67070cc2ffa13cf35b22048da70754/docs/6cord.png -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | 6cord.diamondb.xyz 2 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 57 | 58 | 59 |
60 |

6cord

61 |

A terminal front-end for the Discord chat service

62 |
63 |
64 | 65 |
66 | Windows 67 | Linux 68 | Source Code 69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /editor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func summonEditor() (b []byte, err error) { 11 | editor := os.Getenv("EDITOR") 12 | if editor == "" { 13 | editor = "nano" 14 | } 15 | 16 | f, err := ioutil.TempFile("", "6cord-editor-*.md") 17 | if err != nil { 18 | return 19 | } 20 | 21 | defer os.Remove(f.Name()) 22 | 23 | for { 24 | cmd := exec.Command(editor, f.Name()) 25 | cmd.Stdout = os.Stdout 26 | cmd.Stdin = os.Stdin 27 | 28 | app.Suspend(func() error { 29 | fmt.Println("Opening", editor+"...") 30 | err = cmd.Run() 31 | 32 | return nil 33 | }) 34 | 35 | if err != nil { 36 | return 37 | } 38 | 39 | b, err = ioutil.ReadAll(f) 40 | if err != nil { 41 | return 42 | } 43 | 44 | if len(b) > 2000 { 45 | Warn("Content too long! The limit is 2000 characters!") 46 | } 47 | 48 | break 49 | } 50 | 51 | return 52 | } 53 | -------------------------------------------------------------------------------- /fmtemojis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/diamondburned/tview/v2" 9 | ) 10 | 11 | var ( 12 | // EmojiRegex to get emoji IDs 13 | // thanks ym 14 | EmojiRegex = regexp.MustCompile(`<(a?):(.+?):(\d+)>`) 15 | ) 16 | 17 | // returns map[ID][]{name, url} 18 | func parseEmojis(content string) (fmtted string, emojiMap map[string][]string) { 19 | emojiMap = make(map[string][]string) 20 | fmtted = content 21 | 22 | emojiIDs := EmojiRegex.FindAllStringSubmatch(content, -1) 23 | for _, nameandID := range emojiIDs { 24 | if len(nameandID) < 4 { 25 | continue 26 | } 27 | 28 | if _, ok := emojiMap[nameandID[3]]; !ok { 29 | var format = "png" 30 | if nameandID[1] != "" { 31 | format = "gif" 32 | } 33 | 34 | fmtted = strings.Replace( 35 | fmtted, 36 | strings.TrimSpace(nameandID[0]), 37 | ":"+nameandID[2]+":", 38 | -1, 39 | ) 40 | 41 | if cfg.Prop.ShowEmojiURLs { 42 | emojiMap[nameandID[3]] = []string{ 43 | tview.Escape(nameandID[2]), 44 | fmt.Sprintf( 45 | `https://cdn.discordapp.com/emojis/%s.%s`, 46 | nameandID[3], format, 47 | ), 48 | } 49 | } 50 | } 51 | } 52 | 53 | return 54 | } 55 | -------------------------------------------------------------------------------- /fmtmessage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/diamondburned/discordgo" 9 | "github.com/diamondburned/tview/v2" 10 | "gitlab.com/diamondburned/6cord/antitele" 11 | "gitlab.com/diamondburned/6cord/shortener" 12 | ) 13 | 14 | const zeroes = "000000" 15 | 16 | func fmtHex(hex int) string { 17 | h := zeroes + strconv.FormatInt(int64(hex), 16) 18 | return h[len(h)-len(zeroes):] 19 | } 20 | 21 | func fmtMessage(m *discordgo.Message) string { 22 | ct, emojiMap := parseMessageContent(m) 23 | ct = strings.Map(func(r rune) rune { 24 | for _, z := range antitele.ZeroWidthRunes { 25 | if z == r { 26 | return -1 27 | } 28 | } 29 | 30 | return r 31 | }, ct) 32 | 33 | if m.EditedTimestamp != "" { 34 | ct += " " + readChannelColorPrefix + "(edited)[-::-]" 35 | 36 | // Prevent cases where the message is empty 37 | // " (edited)" 38 | ct = strings.TrimPrefix(ct, " ") 39 | } 40 | 41 | var ( 42 | c strings.Builder 43 | l = strings.Split(ct, "\n") 44 | 45 | attachments = m.Attachments 46 | reactions, reactMap = formatReactions(m.Reactions) 47 | ) 48 | 49 | if ct != "" { 50 | for i := 0; i < len(l); i++ { 51 | if !cfg.Prop.CompactMode || i != 0 { 52 | c.WriteString(chatPadding) 53 | } 54 | 55 | c.WriteString(l[i]) 56 | 57 | if i != len(l)-1 { 58 | c.WriteByte('\n') 59 | } 60 | } 61 | } 62 | 63 | for k, v := range reactMap { 64 | emojiMap[k] = v 65 | } 66 | 67 | for _, arr := range emojiMap { 68 | attachments = append( 69 | attachments, 70 | &discordgo.MessageAttachment{ 71 | Filename: arr[0], 72 | URL: arr[1], 73 | }, 74 | ) 75 | } 76 | 77 | for _, e := range m.Embeds { 78 | var embed = make([]string, 0, 5) 79 | 80 | if e.URL != "" { 81 | attachments = append( 82 | attachments, 83 | &discordgo.MessageAttachment{ 84 | Filename: "EmbedURL", 85 | URL: e.URL, 86 | }, 87 | ) 88 | } 89 | 90 | if e.Author != nil { 91 | embed = append( 92 | embed, 93 | "[::du]"+e.Author.Name+"[::-]", 94 | ) 95 | 96 | if e.Author.IconURL != "" { 97 | attachments = append( 98 | m.Attachments, 99 | &discordgo.MessageAttachment{ 100 | Filename: "AuthorIcon", 101 | URL: e.Author.IconURL, 102 | }, 103 | ) 104 | } 105 | 106 | if e.Author.URL != "" { 107 | attachments = append( 108 | m.Attachments, 109 | &discordgo.MessageAttachment{ 110 | Filename: "AuthorURL", 111 | URL: e.Author.URL, 112 | }, 113 | ) 114 | } 115 | } 116 | 117 | if e.Title != "" { 118 | embed = append( 119 | embed, 120 | splitEmbedLine(e.Title, "[::b]", "[#0096cf]")..., 121 | ) 122 | } 123 | 124 | if e.Description != "" { 125 | var desc, emojis = parseEmojis(e.Description) 126 | 127 | embed = append(embed, splitEmbedLine(desc)...) 128 | 129 | for _, arr := range emojis { 130 | attachments = append( 131 | m.Attachments, 132 | &discordgo.MessageAttachment{ 133 | Filename: arr[0], 134 | URL: arr[1], 135 | }, 136 | ) 137 | } 138 | } 139 | 140 | if len(e.Fields) > 0 { 141 | embed = append(embed, "") 142 | 143 | for _, f := range e.Fields { 144 | embed = append(embed, 145 | splitEmbedLine(f.Name, " [::b]")...) 146 | embed = append(embed, 147 | splitEmbedLine(f.Value, " [::d]")...) 148 | embed = append(embed, "") 149 | } 150 | } 151 | 152 | var footer []string 153 | if e.Footer != nil { 154 | footer = append( 155 | footer, 156 | "[::d]"+tview.Escape(e.Footer.Text)+"[::-]", 157 | ) 158 | 159 | if e.Footer.IconURL != "" { 160 | attachments = append( 161 | m.Attachments, 162 | &discordgo.MessageAttachment{ 163 | Filename: "FooterIcon", 164 | URL: e.Footer.IconURL, 165 | }, 166 | ) 167 | } 168 | } 169 | 170 | if e.Timestamp != "" { 171 | footer = append( 172 | footer, 173 | "[::d]"+e.Timestamp+"[::-]", 174 | ) 175 | } 176 | 177 | if len(footer) > 0 { 178 | embed = append( 179 | embed, 180 | strings.Join(footer, " - "), 181 | ) 182 | } 183 | 184 | //if e.Thumbnail != nil { 185 | //attachments = append( 186 | //m.Attachments, 187 | //&discordgo.MessageAttachment{ 188 | //Filename: "Thumbnail", 189 | //URL: e.Thumbnail.URL, 190 | //}, 191 | //) 192 | //} 193 | 194 | if e.Image != nil { 195 | attachments = append( 196 | m.Attachments, 197 | &discordgo.MessageAttachment{ 198 | Filename: "Image", 199 | URL: e.Image.URL, 200 | }, 201 | ) 202 | } 203 | 204 | if e.Video != nil { 205 | attachments = append( 206 | m.Attachments, 207 | &discordgo.MessageAttachment{ 208 | Filename: "Video", 209 | URL: e.Video.URL, 210 | }, 211 | ) 212 | } 213 | 214 | var embedPadding = chatPadding 215 | if len(embedPadding) > 2 { 216 | embedPadding = chatPadding[:len(chatPadding)-2] 217 | } 218 | 219 | c.WriteByte('\n') 220 | 221 | for i, l := range embed { 222 | c.WriteString(embedPadding + fmt.Sprintf("[#%06X]", e.Color) + "┃[-::] " + l) 223 | 224 | if i != len(embed)-1 { 225 | c.WriteByte('\n') 226 | } 227 | } 228 | } 229 | 230 | if len(m.Reactions) > 0 { // Reactions 231 | c.WriteString("\n" + chatPadding + chatPadding + reactions) 232 | } 233 | 234 | if len(attachments) > 0 { 235 | for _, a := range attachments { 236 | c.WriteString(fmt.Sprintf( 237 | "\n%s[::d][%s[]: %s[::-]", 238 | chatPadding, 239 | tview.Escape(a.Filename), 240 | shortener.ShortenURL(a.URL), 241 | )) 242 | } 243 | } 244 | 245 | if cfg.Prop.CompactMode { 246 | // If the message begins with a code block, 247 | // we don't want the first line of the code 248 | // block to warp in the first line. 249 | if len(m.Content) > 3 && m.Content[:3] == "```" { 250 | return "\n" + chatPadding + c.String() 251 | } 252 | } 253 | 254 | return c.String() 255 | } 256 | -------------------------------------------------------------------------------- /fuzzy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | "github.com/diamondburned/tcell" 6 | "github.com/diamondburned/tview/v2" 7 | "github.com/sahilm/fuzzy" 8 | ) 9 | 10 | func stateResetter() { 11 | channelFuzzyCache = allChannels([]fuzzyReadState{}) 12 | discordEmojis = DiscordEmojis([]*discordgo.Emoji{}) 13 | allMessages = make([]*tview.ListItem, 0, len(messageStore)) 14 | autocomp.SetChangedFunc(nil) 15 | messagesView.Highlight() 16 | 17 | current, total := getLineStatus() 18 | 19 | // If the scroll offset is < 20 20 | if total-current < 20 { 21 | scrollChat() 22 | } 23 | 24 | if imageRendererPipeline != nil { 25 | imageRendererPipeline.clean() 26 | } 27 | } 28 | 29 | func clearList() { 30 | rightflex.ResizeItem(autocomp, 1, 1) 31 | 32 | if autocomp.GetItemCount() != 0 { 33 | autocomp.Clear() 34 | } 35 | } 36 | 37 | func formatNeedle(m fuzzy.Match) (f string) { 38 | isHL := false 39 | 40 | for i := 0; i < len(m.Str); i++ { 41 | if fuzzyHasNeedle(i, m.MatchedIndexes) { 42 | f += "[::u]" + string(m.Str[i]) 43 | isHL = true 44 | } else { 45 | if isHL { 46 | f += "[::-]" 47 | } 48 | 49 | f += string(m.Str[i]) 50 | } 51 | } 52 | 53 | return 54 | } 55 | 56 | func fuzzyHasNeedle(needle int, haystack []int) bool { 57 | for _, i := range haystack { 58 | if needle == i { 59 | return true 60 | } 61 | } 62 | return false 63 | } 64 | 65 | func autocompHandler(ev *tcell.EventKey) *tcell.EventKey { 66 | i := autocomp.GetCurrentItem() 67 | 68 | switch ev.Key() { 69 | case tcell.KeyDown, tcell.KeyCtrlN: 70 | if i+1 == autocomp.GetItemCount() { 71 | app.SetFocus(input) 72 | return nil 73 | } 74 | 75 | return ev 76 | 77 | case tcell.KeyUp, tcell.KeyCtrlP: 78 | if i == 0 { 79 | app.SetFocus(input) 80 | return nil 81 | } 82 | 83 | return ev 84 | 85 | case tcell.KeyLeft: 86 | imageRendererPipeline.prev() 87 | return nil 88 | 89 | case tcell.KeyRight: 90 | imageRendererPipeline.next() 91 | return nil 92 | 93 | case tcell.KeyEnter: 94 | return ev 95 | } 96 | 97 | if ev.Rune() >= 0x31 && ev.Rune() <= 0x122 { 98 | return ev 99 | } 100 | 101 | app.SetFocus(input) 102 | return nil 103 | } 104 | 105 | var autofillfunc func(i int) 106 | var onhoverfn func(i int) 107 | -------------------------------------------------------------------------------- /fuzzychannels.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/diamondburned/discordgo" 9 | "github.com/diamondburned/tview/v2" 10 | "github.com/sahilm/fuzzy" 11 | ) 12 | 13 | type allChannels []fuzzyReadState 14 | 15 | type fuzzyReadState struct { 16 | *discordgo.Channel 17 | Unread bool 18 | Format string 19 | } 20 | 21 | var channelFuzzyCache = allChannels([]fuzzyReadState{}) 22 | 23 | // String returns the fuzzy search part of the struct 24 | func (ac allChannels) String(i int) string { 25 | if ac[i].Unread { 26 | return "[::b]#" + ac[i].Format + "[::-]" 27 | } 28 | 29 | return "[::d]#" + ac[i].Format + "[::-]" 30 | } 31 | 32 | // Len returns the length 33 | func (ac allChannels) Len() int { 34 | return len(ac) 35 | } 36 | 37 | func fuzzyChannels(last string) { 38 | var fuzzied []fuzzy.Match 39 | 40 | if len(last) > 0 { 41 | if len(channelFuzzyCache) == 0 { 42 | for _, c := range d.State.PrivateChannels { 43 | var name = c.Name 44 | 45 | if c.Name == "" { 46 | recips := make([]string, len(c.Recipients)) 47 | for i, r := range c.Recipients { 48 | recips[i] = r.Username 49 | } 50 | 51 | name = HumanizeStrings(recips) 52 | } 53 | 54 | channelFuzzyCache = append( 55 | channelFuzzyCache, 56 | fuzzyReadState{c, isUnread(c), name}, 57 | ) 58 | } 59 | 60 | for _, g := range d.State.Guilds { 61 | for _, c := range g.Channels { 62 | if !isSendCh(c.Type) { 63 | continue 64 | } 65 | 66 | channelFuzzyCache = append( 67 | channelFuzzyCache, 68 | fuzzyReadState{ 69 | c, 70 | isUnread(c), 71 | c.Name + " (" + g.Name + ")", 72 | }, 73 | ) 74 | } 75 | } 76 | } 77 | 78 | fuzzied = fuzzy.FindFrom( 79 | strings.TrimPrefix(last, "#"), 80 | channelFuzzyCache, 81 | ) 82 | 83 | if Channel != nil { 84 | c, err := d.State.Channel(Channel.ID) 85 | if err == nil { 86 | guildID := c.GuildID 87 | sort.SliceStable(fuzzied, func(i, j int) bool { 88 | return channelFuzzyCache[fuzzied[i].Index].GuildID == guildID 89 | }) 90 | } 91 | } 92 | } 93 | 94 | clearList() 95 | 96 | if len(fuzzied) > 0 { 97 | for i, fz := range fuzzied { 98 | autocomp.InsertItem(i, &tview.ListItem{fz.Str, "", 0, nil}) 99 | 100 | if i == 25 { 101 | break 102 | } 103 | } 104 | 105 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 106 | 107 | autofillfunc = func(i int) { 108 | defer stateResetter() 109 | 110 | words := strings.Fields(input.GetText()) 111 | 112 | withoutlast := words[:len(words)-1] 113 | withoutlast = append(withoutlast, fmt.Sprintf( 114 | "<#%d> ", channelFuzzyCache[fuzzied[i].Index].ID, 115 | )) 116 | 117 | switch { 118 | case strings.HasPrefix(input.GetText(), "/goto "): 119 | input.SetText("") 120 | gotoChannel(withoutlast) 121 | return 122 | 123 | default: 124 | input.SetText(strings.Join(withoutlast, " ")) 125 | } 126 | 127 | clearList() 128 | 129 | app.SetFocus(input) 130 | } 131 | 132 | } else { 133 | rightflex.ResizeItem(autocomp, 1, 1) 134 | } 135 | 136 | app.Draw() 137 | } 138 | -------------------------------------------------------------------------------- /fuzzycommands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/diamondburned/tview/v2" 8 | "github.com/sahilm/fuzzy" 9 | ) 10 | 11 | // String returns the fuzzy search part of the struct 12 | func (cmds Commands) String(i int) string { 13 | return cmds[i].Command 14 | } 15 | 16 | // Len returns the length of the Emojis slice 17 | func (cmds Commands) Len() int { 18 | return len(cmds) 19 | } 20 | 21 | func fuzzyCommands(last string) { 22 | var fuzzied Commands 23 | 24 | if len(last) > 1 { 25 | results := fuzzy.FindFrom( 26 | strings.TrimPrefix(last, "/"), 27 | commands, 28 | ) 29 | 30 | for i, r := range results { 31 | if i == 10 { 32 | break 33 | } 34 | 35 | fuzzied = append( 36 | fuzzied, 37 | commands[r.Index], 38 | ) 39 | } 40 | 41 | sort.Slice(fuzzied, func(i, j int) bool { 42 | return len(fuzzied[i].Command) < len(fuzzied[j].Command) 43 | }) 44 | } else { 45 | fuzzied = append(fuzzied, commands...) 46 | } 47 | 48 | clearList() 49 | 50 | if len(fuzzied) > 0 { 51 | for i, u := range fuzzied { 52 | autocomp.InsertItem(i, &tview.ListItem{ 53 | "[::b]" + u.Command + "[::-] - " + tview.Escape(u.Description), 54 | "", 0, nil, 55 | }) 56 | } 57 | 58 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 59 | 60 | autofillfunc = func(i int) { 61 | input.SetText(fuzzied[i].Command + " ") 62 | clearList() 63 | app.SetFocus(input) 64 | } 65 | 66 | } else { 67 | rightflex.ResizeItem(autocomp, 1, 1) 68 | } 69 | 70 | app.Draw() 71 | } 72 | -------------------------------------------------------------------------------- /fuzzyemojis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/diamondburned/discordgo" 8 | "github.com/diamondburned/tview/v2" 9 | "github.com/sahilm/fuzzy" 10 | "gitlab.com/diamondburned/6cord/demojis" 11 | ) 12 | 13 | // DiscordEmojis .. 14 | type DiscordEmojis []*discordgo.Emoji 15 | 16 | func (de DiscordEmojis) String(i int) string { 17 | return de[i].Name 18 | } 19 | 20 | func (de DiscordEmojis) Len() int { 21 | return len(de) 22 | } 23 | 24 | var discordEmojis = DiscordEmojis([]*discordgo.Emoji{}) 25 | 26 | func fuzzyEmojis(last string) { 27 | var fuzzied []fuzzy.Match 28 | 29 | if len(last) > 0 && Channel != nil { 30 | if len(discordEmojis) < 1 { 31 | c, err := d.State.Channel(Channel.ID) 32 | if err != nil { 33 | return 34 | } 35 | 36 | emojis := DiscordEmojis{} 37 | 38 | g, err := d.State.Guild(c.GuildID) 39 | if err == nil { 40 | emojis = g.Emojis 41 | emojis = append(emojis, demojis.DiscordEmojis...) 42 | } else { 43 | emojis = demojis.DiscordEmojis 44 | } 45 | 46 | discordEmojis = emojis 47 | } 48 | 49 | fuzzied = fuzzy.FindFrom( 50 | strings.TrimPrefix(last, ":"), 51 | discordEmojis, 52 | ) 53 | } 54 | 55 | clearList() 56 | 57 | if len(fuzzied) > 0 { 58 | for i, m := range fuzzied { 59 | autocomp.InsertItem(i, &tview.ListItem{ 60 | ":" + m.Str + ":", "", 0, nil, 61 | }) 62 | 63 | if i == 25 { 64 | break 65 | } 66 | } 67 | 68 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 69 | 70 | autofillfunc = func(i int) { 71 | defer stateResetter() 72 | 73 | var ( 74 | words = strings.Fields(input.GetText()) 75 | emoji = discordEmojis[fuzzied[i].Index] 76 | insert string 77 | ) 78 | 79 | if emoji.ID == -2 { 80 | e, ok := demojis.GetEmojiFromKey(emoji.Name) 81 | if ok { 82 | insert = e 83 | } 84 | } else { 85 | var a string 86 | if emoji.Animated { 87 | a = "a" 88 | } 89 | 90 | insert = fmt.Sprintf( 91 | "<%s:%s:%d>", 92 | a, emoji.Name, emoji.ID, 93 | ) 94 | } 95 | 96 | withoutlast := words[:len(words)-1] 97 | withoutlast = append( 98 | withoutlast, 99 | insert+" ", 100 | ) 101 | 102 | input.SetText(strings.Join(withoutlast, " ")) 103 | 104 | clearList() 105 | 106 | app.SetFocus(input) 107 | } 108 | 109 | } else { 110 | rightflex.ResizeItem(autocomp, 1, 1) 111 | } 112 | 113 | app.Draw() 114 | } 115 | -------------------------------------------------------------------------------- /fuzzymentions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/diamondburned/tview/v2" 8 | "github.com/sahilm/fuzzy" 9 | ) 10 | 11 | // String returns the fuzzy search part of the struct 12 | func (gm UserStoreArray) String(i int) string { 13 | var s = gm[i].Name 14 | 15 | if gm[i].Nick != "" { 16 | s += " " + gm[i].Nick 17 | } 18 | 19 | return s 20 | } 21 | 22 | // Len returns the length 23 | func (gm UserStoreArray) Len() int { 24 | return len(gm) 25 | } 26 | 27 | // FuzzyMembers fuzzy searches and returns the slice of results 28 | func FuzzyMembers(pattern string, s *UserStore, guildID int64) (fzr UserStoreArray) { 29 | s.RLock() 30 | defer s.RUnlock() 31 | 32 | this, ok := s.Guilds[guildID] 33 | if !ok { 34 | return 35 | } 36 | 37 | results := fuzzy.FindFrom(pattern, this) 38 | for i := 0; i < len(results) && i < 10; i++ { 39 | fzr = append(fzr, this[results[i].Index]) 40 | } 41 | 42 | return 43 | } 44 | 45 | func fuzzyMentions(last string) { 46 | var fuzzied UserStoreArray 47 | 48 | if len(last) > 0 && Channel != nil { 49 | fuzzied = FuzzyMembers( 50 | strings.TrimPrefix(last, "@"), us, Channel.GuildID, 51 | ) 52 | } 53 | 54 | clearList() 55 | 56 | if len(fuzzied) > 0 { 57 | g, _ := d.State.Guild(Channel.GuildID) 58 | 59 | for i, u := range fuzzied { 60 | var username = u.Name + "[::d]#" + u.Discrim + "[::-]" 61 | if u.Nick != "" { 62 | username += " [::d](" + u.Nick + ")[::-]" 63 | } 64 | 65 | if g != nil { 66 | for _, p := range g.Presences { 67 | if p.User.ID == fuzzied[i].ID { 68 | username = fmt.Sprintf( 69 | "[%s]%s[-]", 70 | ReflectStatusColor(p.Status), 71 | username, 72 | ) 73 | } 74 | } 75 | } 76 | 77 | autocomp.InsertItem(i, &tview.ListItem{"@" + username, "", 0, nil}) 78 | } 79 | 80 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 81 | 82 | autofillfunc = func(i int) { 83 | words := strings.Fields(input.GetText()) 84 | 85 | withoutlast := words[:len(words)-1] 86 | withoutlast = append(withoutlast, fmt.Sprintf( 87 | "<@%d> ", fuzzied[i].ID, 88 | )) 89 | 90 | input.SetText(strings.Join(withoutlast, " ")) 91 | 92 | clearList() 93 | 94 | app.SetFocus(input) 95 | } 96 | 97 | } else { 98 | rightflex.ResizeItem(autocomp, 1, 1) 99 | } 100 | 101 | app.Draw() 102 | } 103 | -------------------------------------------------------------------------------- /fuzzymessages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/diamondburned/tview/v2" 10 | ) 11 | 12 | // [0]:format [1]:ID 13 | var allMessages []*tview.ListItem 14 | 15 | func fuzzyMessages(text string) { 16 | var fuzzied []*tview.ListItem 17 | 18 | if len(allMessages) == 0 && Channel != nil { 19 | for i := len(messageStore) - 1; i >= 0; i-- { 20 | ID := getIDfromindex(i) 21 | if ID == 0 { 22 | continue 23 | } 24 | 25 | allMessages = append(allMessages, makeMessageItem(ID)) 26 | } 27 | } 28 | 29 | if len(text) > 1 { 30 | text := strings.TrimPrefix(text, "~") 31 | fuzzied = make([]*tview.ListItem, 0, len(allMessages)) 32 | 33 | for _, m := range allMessages { 34 | if strings.Contains(m.MainText, text) { 35 | fuzzied = append(fuzzied) 36 | } 37 | } 38 | } else { 39 | fuzzied = allMessages 40 | } 41 | 42 | clearList() 43 | 44 | if len(fuzzied) > 0 { 45 | for i, j := 0, len(fuzzied)-1; i < j; i, j = i+1, j-1 { 46 | fuzzied[i], fuzzied[j] = fuzzied[j], fuzzied[i] 47 | } 48 | 49 | autocomp.SetItems(fuzzied) 50 | autocomp.SetCurrentItem(-1) 51 | 52 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 53 | 54 | autofillfunc = func(i int) { 55 | words := strings.Fields(input.GetText()) 56 | 57 | withoutlast := words[:len(words)-1] 58 | withoutlast = append(withoutlast, fuzzied[i].SecondaryText) 59 | 60 | input.SetText(strings.Join(withoutlast, " ") + " ") 61 | 62 | clearList() 63 | app.SetFocus(input) 64 | } 65 | 66 | } else { 67 | rightflex.ResizeItem(autocomp, 1, 1) 68 | } 69 | 70 | app.Draw() 71 | 72 | autocomp.SetChangedFunc(func(i int, t string, st string, s rune) { 73 | if i >= len(fuzzied) { 74 | return 75 | } 76 | 77 | ID := fuzzied[i].SecondaryText 78 | 79 | if Channel != nil { 80 | id, _ := strconv.ParseInt(ID, 10, 64) 81 | if id != 0 { 82 | m, err := d.State.Message(Channel.ID, id) 83 | if err == nil { 84 | // Update the list entry 85 | item := makeMessageItem(id) 86 | if item.MainText != t { 87 | fuzzied[i].MainText = item.MainText 88 | } 89 | 90 | imageRendererPipeline.add(m) 91 | } 92 | } 93 | } 94 | 95 | messagesView.Highlight(ID) 96 | messagesView.ScrollToHighlight() 97 | }) 98 | } 99 | 100 | func makeMessageItem(ID int64) *tview.ListItem { 101 | id := strconv.FormatInt(ID, 10) 102 | 103 | m, err := d.State.Message(Channel.ID, ID) 104 | if err != nil { 105 | return &tview.ListItem{ 106 | MainText: id + " - ???", 107 | SecondaryText: id, 108 | Shortcut: 0, 109 | Selected: nil, 110 | } 111 | } 112 | 113 | username, color := us.DiscordThis(m) 114 | 115 | sentTime, err := m.Timestamp.Parse() 116 | if err != nil { 117 | sentTime = time.Now() 118 | } 119 | 120 | var fetchedColor = readChannelColorPrefix 121 | if s := imageRendererPipeline.cache.get(m.ID); s != nil { 122 | fetchedColor = string(s.state) 123 | } 124 | 125 | return &tview.ListItem{ 126 | MainText: fmt.Sprintf( 127 | "%s%s[-] - [#%06X]%s[-] [::d]- %s[::-]", 128 | fetchedColor, id, color, username, 129 | sentTime.Local().Format(time.Stamp), 130 | ), 131 | SecondaryText: id, 132 | Shortcut: 0, 133 | Selected: nil, 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /fuzzyupload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/diamondburned/tview/v2" 8 | "github.com/sahilm/fuzzy" 9 | ) 10 | 11 | func joinFolder(fs []string) (p string) { 12 | for _, f := range fs { 13 | p += f + "/" 14 | } 15 | 16 | return 17 | } 18 | 19 | func fuzzyUpload(text string) { 20 | fuzzied := [][]string{} 21 | 22 | if len(text) > 8 { 23 | text = strings.TrimPrefix(text, "/upload ") 24 | 25 | var ( 26 | pathparts = strings.Split(text, "/") 27 | inputfile = pathparts[len(pathparts)-1] 28 | 29 | path = joinFolder(pathparts[:len(pathparts)-1]) 30 | ) 31 | 32 | f, err := os.Open(path) 33 | if err != nil { 34 | return 35 | } 36 | 37 | defer f.Close() 38 | 39 | files, err := f.Readdir(-1) 40 | if err != nil { 41 | return 42 | } 43 | 44 | filenames := make([]string, len(files)) 45 | 46 | for i, f := range files { 47 | filename := f.Name() 48 | if f.IsDir() { 49 | filename += "/" 50 | } 51 | 52 | filenames[i] = filename 53 | } 54 | 55 | results := fuzzy.Find(inputfile, filenames) 56 | fuzzied = make([][]string, len(results)) 57 | 58 | for i, r := range results { 59 | fuzzied[i] = []string{ 60 | "[::u]" + path + "[::-]\u200B" + formatNeedle(r), 61 | path + filenames[r.Index], 62 | } 63 | } 64 | } 65 | 66 | clearList() 67 | 68 | if len(fuzzied) > 0 { 69 | for i, filename := range fuzzied { 70 | autocomp.InsertItem( 71 | i, &tview.ListItem{filename[0], "", 0, nil}, 72 | ) 73 | } 74 | 75 | rightflex.ResizeItem(autocomp, min(len(fuzzied), 10), 1) 76 | 77 | autofillfunc = func(i int) { 78 | input.SetText("/upload " + fuzzied[i][1]) 79 | clearList() 80 | app.SetFocus(input) 81 | } 82 | 83 | } else { 84 | rightflex.ResizeItem(autocomp, 1, 1) 85 | } 86 | 87 | app.Draw() 88 | } 89 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gitlab.com/diamondburned/6cord 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect 7 | github.com/alecthomas/chroma v0.6.7 8 | github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 // indirect 9 | github.com/atotto/clipboard v0.1.2 10 | github.com/davecgh/go-spew v1.1.1 11 | github.com/diamondburned/discordgo v1.2.1 12 | github.com/diamondburned/tcell v1.1.8 13 | github.com/diamondburned/tview/v2 v2.4.0 14 | github.com/disintegration/imaging v1.6.1 15 | github.com/dlclark/regexp2 v1.2.0 // indirect 16 | github.com/francoispqt/gojay v1.2.13 // indirect 17 | github.com/gen2brain/beeep v0.0.0-20190719094215-ece0cb67ca77 18 | github.com/go-test/deep v1.0.3 19 | github.com/gobwas/ws v1.0.2 // indirect 20 | github.com/gopherjs/gopherjs v0.0.0-20190915194858-d3ddacdb130f // indirect 21 | github.com/gopherjs/gopherwasm v1.1.0 // indirect 22 | github.com/hashicorp/go-retryablehttp v0.6.2 // indirect 23 | github.com/jonas747/gojay v0.0.0-20190906102056-b3bd5c8fcd50 24 | github.com/kylelemons/godebug v1.1.0 // indirect 25 | github.com/mattn/go-isatty v0.0.10 // indirect 26 | github.com/mattn/go-sixel v0.0.1 27 | github.com/mattn/go-tty v0.0.0-20190424173100-523744f04859 28 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 29 | github.com/pelletier/go-toml v1.5.0 // indirect 30 | github.com/sahilm/fuzzy v0.1.0 31 | github.com/soniakeys/quant v1.0.0 // indirect 32 | github.com/stevenroose/gonfig v0.1.4 33 | github.com/stretchr/objx v0.2.0 // indirect 34 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 35 | github.com/valyala/fasttemplate v1.1.0 36 | github.com/zalando/go-keyring v0.0.0-20190913082157-62750a1ff80d 37 | gitlab.com/diamondburned/go-w3m v0.0.0-20190608163716-1b390b8a3d1f 38 | gitlab.com/diamondburned/ueberzug-go v0.0.0-20190521043425-7c15a5f63b06 39 | gitlab.com/shihoya-inc/ws v0.0.0-20190921174751-da4704c313d7 // indirect 40 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect 41 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect 42 | golang.org/x/sys v0.0.0-20191010194322-b09406accb47 // indirect 43 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 44 | gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 // indirect 45 | ) 46 | -------------------------------------------------------------------------------- /guildevents.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/diamondburned/discordgo" 4 | 5 | func guildCreate(s *discordgo.Session, gc *discordgo.GuildCreate) { 6 | onReady(s, &d.State.Ready) 7 | } 8 | 9 | func guildDelete(s *discordgo.Session, gc *discordgo.GuildCreate) { 10 | onReady(s, &d.State.Ready) 11 | } 12 | -------------------------------------------------------------------------------- /guildmembers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | ) 6 | 7 | func guildMemberAdd(s *discordgo.Session, gma *discordgo.GuildMemberAdd) { 8 | guildMemberDoSomething(gma.Member) 9 | } 10 | 11 | func guildMemberRemove(s *discordgo.Session, gmr *discordgo.GuildMemberRemove) { 12 | if gmr.User == nil { 13 | return 14 | } 15 | 16 | us.RemoveUser(gmr.GuildID, gmr.User.ID) 17 | } 18 | 19 | func guildMemberUpdate(s *discordgo.Session, gma *discordgo.GuildMemberUpdate) { 20 | guildMemberDoSomething(gma.Member) 21 | } 22 | 23 | func guildMemberDoSomething(gm *discordgo.Member) { 24 | if gm.User == nil { 25 | return 26 | } 27 | 28 | us.UpdateUser( 29 | gm.GuildID, 30 | gm.User.ID, 31 | gm.User.Username, 32 | gm.Nick, 33 | gm.User.Discriminator, 34 | getUserColor(gm.GuildID, gm.Roles), 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /guildsettings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | ) 6 | 7 | func messagePingable(m *discordgo.Message, gID int64) bool { 8 | c, err := d.State.Channel(m.ChannelID) 9 | if err != nil { 10 | return false 11 | } 12 | 13 | if c.GuildID == 0 { 14 | if m.Author.ID != d.State.User.ID { 15 | return true 16 | } 17 | 18 | return false 19 | } 20 | 21 | s := getGuildFromSettings(c.GuildID) 22 | 23 | if m.MentionEveryone { 24 | //log.Println(2) 25 | return settingGuildAllowEveryone(s) 26 | } 27 | 28 | for _, mention := range m.Mentions { 29 | if mention.ID == d.State.User.ID { 30 | //log.Println(4) 31 | return true 32 | } 33 | } 34 | 35 | /* if s == nil { 36 | switch g.DefaultMessageNotifications { 37 | case 0: 38 | log.Println(3) 39 | return true 40 | case 2: 41 | default: 42 | } 43 | } else { 44 | var notify = s.MessageNotifications 45 | if c := getChannelFromGuildSettings(m.ChannelID, s); c != nil { 46 | notify = c.MessageNotifications 47 | } 48 | 49 | switch notify { 50 | case 0: // all messages 51 | log.Println(3) 52 | return true 53 | case 2: 54 | return false 55 | default: // case 1 - mentions only 56 | } 57 | 58 | for _, mention := range m.Mentions { 59 | if mention.ID == d.State.User.ID { 60 | log.Println(4) 61 | return true 62 | } 63 | } 64 | } 65 | */ 66 | 67 | return false 68 | } 69 | 70 | func getGuildFromSettings(guildID int64) *discordgo.UserGuildSettings { 71 | for _, ugs := range d.State.UserGuildSettings { 72 | if ugs.GuildID == guildID { 73 | return ugs 74 | } 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func settingGuildIsMuted(s *discordgo.UserGuildSettings) bool { 81 | if s == nil { 82 | return false 83 | } 84 | 85 | return s.Muted 86 | } 87 | 88 | func settingGuildAllowEveryone(s *discordgo.UserGuildSettings) bool { 89 | if s == nil { 90 | return true 91 | } 92 | 93 | return !s.SupressEveryone 94 | } 95 | 96 | func getChannelFromGuildSettings(chID int64, s *discordgo.UserGuildSettings, 97 | ) *discordgo.UserGuildSettingsChannelOverride { 98 | 99 | if s == nil { 100 | return nil 101 | } 102 | 103 | for _, c := range s.ChannelOverrides { 104 | if c.ChannelID == chID { 105 | return c 106 | } 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func settingChannelIsMuted( 113 | cho *discordgo.UserGuildSettingsChannelOverride, 114 | s *discordgo.UserGuildSettings) (m bool) { 115 | 116 | if cho == nil { 117 | return false 118 | } 119 | 120 | m = cho.Muted 121 | 122 | c, err := d.State.Channel(cho.ChannelID) 123 | if err != nil { 124 | return 125 | } 126 | 127 | if c.ParentID != 0 { 128 | if cs := getChannelFromGuildSettings(c.ParentID, s); cs != nil { 129 | return cs.Muted 130 | } 131 | } 132 | 133 | return 134 | } 135 | -------------------------------------------------------------------------------- /heated.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sync" 4 | 5 | var ( 6 | heatedChannels = struct { 7 | sync.Mutex 8 | buffer map[int64]struct{} 9 | }{ 10 | buffer: map[int64]struct{}{}, 11 | } 12 | ) 13 | 14 | // true == added, false == removed 15 | func heatedChannelsToggle(channelID int64) bool { 16 | heatedChannels.Lock() 17 | defer heatedChannels.Unlock() 18 | 19 | if _, ok := heatedChannels.buffer[channelID]; ok { 20 | delete(heatedChannels.buffer, channelID) 21 | return false 22 | } 23 | 24 | heatedChannels.buffer[channelID] = struct{}{} 25 | return true 26 | } 27 | 28 | func heatedChannelsExists(channelID int64) bool { 29 | heatedChannels.Lock() 30 | defer heatedChannels.Unlock() 31 | 32 | if _, ok := heatedChannels.buffer[channelID]; ok { 33 | delete(heatedChannels.buffer, channelID) 34 | return true 35 | } 36 | 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /humanize.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // HumanizeStrings converts arrays to a friendly string 4 | func HumanizeStrings(a []string) (s string) { 5 | switch len(a) { 6 | case 0: 7 | case 1: 8 | s = a[0] 9 | case 2: 10 | s = a[0] + " and " + a[1] 11 | default: 12 | for i := 0; i < len(a)-2; i++ { 13 | s += a[i] + ", " 14 | } 15 | 16 | s += a[len(a)-2] + " and " + a[len(a)-1] 17 | } 18 | 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | _ "image/gif" 8 | _ "image/jpeg" 9 | _ "image/png" 10 | 11 | "github.com/diamondburned/discordgo" 12 | img "gitlab.com/diamondburned/6cord/image" 13 | ) 14 | 15 | type imageCtx struct { 16 | state img.Backend 17 | index int 18 | } 19 | 20 | type imageRendererPipelineStruct struct { 21 | event chan interface{} 22 | state img.Backend 23 | message int64 24 | index int 25 | 26 | cache *imageCacheStruct 27 | assets []*imageCacheAsset 28 | } 29 | 30 | const ( 31 | imagePipelineNextEvent int = iota 32 | imagePipelinePrevEvent 33 | ) 34 | 35 | var imageRendererPipeline *imageRendererPipelineStruct 36 | 37 | func startImageRendererPipeline() *imageRendererPipelineStruct { 38 | p := &imageRendererPipelineStruct{ 39 | event: make(chan interface{}, prefetchMessageCount), 40 | cache: &imageCacheStruct{ 41 | Age: 5 * time.Minute, 42 | client: &http.Client{ 43 | Timeout: 5 * time.Second, 44 | }, 45 | store: map[int64]*imageCacheStore{}, 46 | }, 47 | } 48 | 49 | go func() { 50 | for i := range p.event { 51 | Switch: 52 | switch i := i.(type) { 53 | case *discordgo.Message: 54 | p.message = i.ID 55 | 56 | a := p.cache.get(i.ID) 57 | if a == nil || a.state != imageFetched { 58 | p.clean() 59 | break 60 | } 61 | 62 | p.assets = a.assets 63 | p.clean() 64 | 65 | if p.assets == nil { 66 | break Switch 67 | } 68 | 69 | p.show() 70 | 71 | case int: 72 | if p.assets == nil { 73 | break Switch 74 | } 75 | 76 | switch i { 77 | case imagePipelineNextEvent: 78 | p.index++ 79 | if p.index >= len(p.assets) { 80 | p.index = 0 81 | } 82 | case imagePipelinePrevEvent: 83 | p.index-- 84 | if p.index < 0 { 85 | p.index = len(p.assets) - 1 86 | } 87 | default: 88 | break Switch 89 | } 90 | 91 | p.show() 92 | 93 | default: 94 | break Switch 95 | } 96 | } 97 | }() 98 | 99 | return p 100 | } 101 | 102 | func (p *imageRendererPipelineStruct) add(m *discordgo.Message) { 103 | p.event <- m 104 | } 105 | 106 | func (p *imageRendererPipelineStruct) next() { 107 | p.event <- imagePipelineNextEvent 108 | } 109 | 110 | func (p *imageRendererPipelineStruct) prev() { 111 | p.event <- imagePipelinePrevEvent 112 | } 113 | 114 | func (p *imageRendererPipelineStruct) clean() { 115 | if p != nil { 116 | if p.state != nil { 117 | p.state.Delete() 118 | } 119 | 120 | p.cache.gc() 121 | p.index = 0 122 | } 123 | } 124 | 125 | func (p *imageRendererPipelineStruct) show() (err error) { 126 | p.clean() 127 | 128 | if p.assets == nil { 129 | return nil 130 | } 131 | 132 | if p.assets[p.index].i == nil { 133 | return nil 134 | } 135 | 136 | p.state, err = img.New(p.assets[p.index].i) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /image/backend.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/mattn/go-tty" 5 | "gitlab.com/diamondburned/ueberzug-go" 6 | ) 7 | 8 | func ttySize(t *tty.TTY) (int, int) { 9 | _, _, w, h, _ := t.SizePixel() 10 | return w, h 11 | } 12 | 13 | func escapeSize(t *tty.TTY) (int, int) { 14 | return getTermSize(t) 15 | } 16 | 17 | func xSize(t *tty.TTY) (int, int) { 18 | w, h, err := ueberzug.GetParentSize() 19 | if err != nil { 20 | return 0, 0 21 | } 22 | 23 | return w, h 24 | } 25 | -------------------------------------------------------------------------------- /image/image.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | 7 | "gitlab.com/diamondburned/ueberzug-go" 8 | ) 9 | 10 | // Backend is the interface for various image 11 | // backends 12 | type Backend interface { 13 | // Available should return nil if an image 14 | // backend successfully runs. This could be 15 | // used for intializing backends. 16 | Available() error 17 | 18 | Spawn(image.Image, int, int) error 19 | 20 | Delete() error 21 | } 22 | 23 | // PossibleBackends is a list of possible image 24 | // backends 25 | var PossibleBackends = []Backend{ 26 | &Ueberzug{}, 27 | &W3M{}, 28 | } 29 | 30 | var ( 31 | // ErrNoBackend is returned if no backends 32 | // are available 33 | ErrNoBackend = errors.New("no backend") 34 | ) 35 | 36 | // New finds a backend and spawns the image 37 | func New(i image.Image) (backend Backend, err error) { 38 | if t == nil { 39 | if err := Listen(); err != nil { 40 | return nil, err 41 | } 42 | } 43 | 44 | if PixelW < 1 || PixelH < 1 { 45 | return nil, ErrNoBackend 46 | } 47 | 48 | for _, b := range PossibleBackends { 49 | if b.Available() == nil { 50 | backend = b 51 | break 52 | } 53 | } 54 | 55 | if backend == nil { 56 | return nil, ErrNoBackend 57 | } 58 | 59 | w := i.Bounds().Dx() 60 | 61 | if err := backend.Spawn(i, PixelW/2-w/2, 0); err != nil { 62 | return nil, err 63 | } 64 | 65 | return backend, nil 66 | } 67 | 68 | // Close has to be ran on exit! 69 | func Close() { 70 | if t != nil { 71 | t.Close() 72 | t = nil 73 | } 74 | 75 | ueberzug.Close() 76 | } 77 | -------------------------------------------------------------------------------- /image/query.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/mattn/go-tty" 8 | ) 9 | 10 | // All these functions are taken from 11 | // https://github.com/gizak/termui/pull/233/files#diff-61ca5d3d7b39f5b633e6774d6a31aea5R91 12 | // and is modified promptly for this library. 13 | // 14 | // All credits reserved. 15 | 16 | func queryTerm(qs string, t *tty.TTY) (ret [][]rune) { 17 | var b []rune 18 | 19 | ch := make(chan bool, 1) 20 | 21 | go func() { 22 | Main: 23 | for { 24 | r, err := t.ReadRune() 25 | if err != nil { 26 | return 27 | } 28 | // handle key event 29 | switch r { 30 | case 'c', 't': 31 | ret = append(ret, b) 32 | break Main 33 | case '?', ';': 34 | ret = append(ret, b) 35 | b = []rune{} 36 | default: 37 | b = append(b, r) 38 | } 39 | } 40 | 41 | ch <- true 42 | }() 43 | 44 | timer := time.NewTimer(100 * time.Microsecond) 45 | defer timer.Stop() 46 | 47 | select { 48 | case <-ch: 49 | defer close(ch) 50 | case <-timer.C: 51 | } 52 | 53 | return 54 | } 55 | 56 | func getTermSize(t *tty.TTY) (w, h int) { 57 | q := queryTerm("\033[14t", t) 58 | if len(q) != 3 { 59 | return 60 | } 61 | 62 | if yy, err := strconv.Atoi(string(q[1])); err == nil { 63 | if xx, err := strconv.Atoi(string(q[2])); err == nil { 64 | w = xx 65 | h = yy 66 | } 67 | } 68 | 69 | return 70 | } 71 | -------------------------------------------------------------------------------- /image/sixel.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | /* 4 | // Ueberzug is the backend for ueberzug-go: 5 | // https://gitlab.com/diamondburned/ueberzug-go 6 | type Ueberzug struct { 7 | i *ueberzug.Image 8 | } 9 | 10 | func (u *Ueberzug) Available() error { 11 | return ueberzug.Initialize() 12 | } 13 | 14 | func (u *Ueberzug) Spawn(i image.Image, x, y int) error { 15 | ui, err := ueberzug.NewImage(i, x, y) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | u.i = ui 21 | return nil 22 | } 23 | 24 | func (u *Ueberzug) Delete() error { 25 | u.i.Clear() 26 | u.i.Destroy() 27 | return nil 28 | } 29 | */ 30 | -------------------------------------------------------------------------------- /image/ueberzug.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "image" 5 | 6 | ueberzug "gitlab.com/diamondburned/ueberzug-go" 7 | ) 8 | 9 | // Ueberzug is the backend for ueberzug-go: 10 | // https://gitlab.com/diamondburned/ueberzug-go 11 | type Ueberzug struct { 12 | i *ueberzug.Image 13 | } 14 | 15 | func (u *Ueberzug) Available() error { 16 | return ueberzug.Initialize() 17 | } 18 | 19 | func (u *Ueberzug) Spawn(i image.Image, x, y int) error { 20 | ui, err := ueberzug.NewImage(i, x, y) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | u.i = ui 26 | return nil 27 | } 28 | 29 | func (u *Ueberzug) Delete() error { 30 | u.i.Clear() 31 | u.i.Destroy() 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /image/w3m.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | "image/png" 7 | "io/ioutil" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "gitlab.com/diamondburned/go-w3m" 13 | ) 14 | 15 | type W3M struct { 16 | a *w3m.Arguments 17 | f *os.File 18 | } 19 | 20 | var Errw3mUnavailable = errors.New("w3m unavailable") 21 | 22 | func (w *W3M) Available() error { 23 | if w3m.GetExecPath() == "" { 24 | return Errw3mUnavailable 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func (w *W3M) Spawn(i image.Image, x, y int) error { 31 | bounds := i.Bounds() 32 | 33 | w.a = &w3m.Arguments{ 34 | Width: bounds.Dx(), 35 | Height: bounds.Dy(), 36 | Xoffset: x, 37 | Yoffset: y, 38 | } 39 | 40 | t := strconv.FormatInt(time.Now().UnixNano(), 10) 41 | 42 | f, err := ioutil.TempFile(os.TempDir(), t+".png") 43 | if err != nil { 44 | return err 45 | } 46 | 47 | defer f.Close() 48 | 49 | if err := png.Encode(f, i); err != nil { 50 | return err 51 | } 52 | 53 | w.f = f 54 | 55 | return w3m.Spawn(w.a, f.Name()) 56 | } 57 | 58 | func (w *W3M) Delete() error { 59 | os.Remove(w.f.Name()) 60 | return w3m.Clear(w.a) 61 | } 62 | -------------------------------------------------------------------------------- /image/winch.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/mattn/go-tty" 7 | ) 8 | 9 | var t *tty.TTY 10 | 11 | var ( 12 | PixelW int 13 | PixelH int 14 | ) 15 | 16 | func Listen() (err error) { 17 | t, err = tty.Open() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | var ( 23 | fn func(*tty.TTY) (int, int) 24 | w int 25 | h int 26 | ) 27 | 28 | if w, h = ttySize(t); w > 0 && h > 0 { 29 | fn = ttySize 30 | } else if w, h = xSize(t); w > 0 && h > 0 { 31 | fn = xSize 32 | } else if w, h = escapeSize(t); w > 0 && h > 0 { 33 | fn = escapeSize 34 | } 35 | 36 | if fn == nil { 37 | return errors.New("No method of getting terminal size avilable") 38 | } 39 | 40 | PixelW = w 41 | PixelH = h 42 | 43 | go func() { 44 | winch := t.SIGWINCH() 45 | for range winch { 46 | PixelW, PixelH = fn(t) 47 | } 48 | }() 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /image/winch_test.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestWinch(t *testing.T) { 9 | if err := Listen(); err != nil { 10 | t.Fatal(t) 11 | } 12 | 13 | defer Close() 14 | 15 | if PixelH < 1 || PixelW < 1 { 16 | t.Fatal("failed") 17 | } 18 | 19 | fmt.Println(PixelH, PixelW) 20 | } 21 | -------------------------------------------------------------------------------- /imagecache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "log" 7 | "net/http" 8 | "path/filepath" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | _ "image/jpeg" 14 | _ "image/png" 15 | 16 | "github.com/diamondburned/discordgo" 17 | ) 18 | 19 | type imageCacheStruct struct { 20 | sync.RWMutex 21 | Age time.Duration 22 | 23 | client *http.Client 24 | store map[int64]*imageCacheStore 25 | lastCh int64 26 | } 27 | 28 | type imageCacheStore struct { 29 | assets []*imageCacheAsset 30 | time time.Time 31 | state imageFetchState 32 | } 33 | 34 | type imageCacheAsset struct { 35 | url string 36 | w, h int 37 | sizedURL string 38 | 39 | i image.Image 40 | } 41 | 42 | type imageFetchState string 43 | 44 | const ( 45 | imageNotFetched imageFetchState = "[#424242]" 46 | imageFetching imageFetchState = "[green]" 47 | imageFetched imageFetchState = "[lime]" 48 | ) 49 | 50 | func (c *imageCacheStruct) get(m int64) *imageCacheStore { 51 | c.RLock() 52 | defer c.RUnlock() 53 | 54 | if a, ok := c.store[m]; ok { 55 | return a 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (c *imageCacheStruct) calcURL(a *imageCacheAsset) { 62 | var ( 63 | resizeW int 64 | resizeH int 65 | ) 66 | 67 | if a.w < a.h { 68 | resizeH = cfg.Prop.ImageHeight 69 | resizeW = cfg.Prop.ImageHeight * a.w / a.h 70 | } else { 71 | resizeW = cfg.Prop.ImageWidth 72 | resizeH = cfg.Prop.ImageWidth * a.h / a.w 73 | } 74 | 75 | if a.sizedURL == "" { 76 | a.sizedURL = strings.Split(a.url, "?")[0] + fmt.Sprintf( 77 | "?width=%d&height=%d", 78 | resizeW, resizeH, 79 | ) 80 | } 81 | } 82 | 83 | func (c *imageCacheStruct) markUnfetch(m *discordgo.Message) *imageCacheStore { 84 | s := &imageCacheStore{ 85 | assets: make( 86 | []*imageCacheAsset, 87 | 0, len(m.Attachments)+len(m.Embeds), 88 | ), 89 | time: time.Now(), 90 | state: imageNotFetched, 91 | } 92 | 93 | for _, a := range m.Attachments { 94 | if a.Width < 1 || a.Height < 1 { 95 | continue 96 | } 97 | 98 | if !imageFormatIsSupported(a.Filename) { 99 | continue 100 | } 101 | 102 | a := &imageCacheAsset{ 103 | url: a.ProxyURL, 104 | w: a.Width, 105 | h: a.Height, 106 | } 107 | 108 | c.calcURL(a) 109 | s.assets = append(s.assets, a) 110 | } 111 | 112 | for _, e := range m.Embeds { 113 | if t := e.Thumbnail; t != nil { 114 | if t.Width < 1 || t.Height < 1 { 115 | continue 116 | } 117 | 118 | if !imageFormatIsSupported(t.ProxyURL) { 119 | continue 120 | } 121 | 122 | a := &imageCacheAsset{ 123 | url: t.ProxyURL, 124 | w: t.Width, 125 | h: t.Height, 126 | } 127 | 128 | c.calcURL(a) 129 | s.assets = append(s.assets, a) 130 | } 131 | } 132 | 133 | if len(s.assets) == 0 { 134 | return nil 135 | } 136 | 137 | c.Lock() 138 | defer c.Unlock() 139 | 140 | c.store[m.ID] = s 141 | 142 | return s 143 | } 144 | 145 | // set checks cache as well 146 | func (c *imageCacheStruct) upd(m *discordgo.Message) (*imageCacheStore, error) { 147 | s := c.get(m.ID) 148 | // If already fetched 149 | if s != nil && s.state == imageFetched { 150 | return s, nil 151 | } 152 | 153 | // If not fetched, but there's something 154 | if s == nil { 155 | s = c.markUnfetch(m) 156 | } 157 | 158 | // If there's nothing 159 | if s == nil { 160 | return nil, nil 161 | } 162 | 163 | c.Lock() 164 | defer c.Unlock() 165 | 166 | s.state = imageFetching 167 | 168 | for _, a := range s.assets { 169 | r, err := c.client.Get(a.sizedURL) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | i, _, err := image.Decode(r.Body) 175 | if err == nil { 176 | a.i = i 177 | } else { 178 | // Error is ignored, as skipping a non-supported 179 | // image is fine 180 | log.Println("Error on", a.sizedURL, "\n"+err.Error()) 181 | } 182 | 183 | r.Body.Close() 184 | } 185 | 186 | s.state = imageFetched 187 | c.store[m.ID] = s 188 | 189 | go app.Draw() 190 | 191 | return s, nil 192 | } 193 | 194 | func (c *imageCacheStruct) reset() { 195 | c.Lock() 196 | defer c.Unlock() 197 | 198 | c.store = map[int64]*imageCacheStore{} 199 | } 200 | 201 | func (c *imageCacheStruct) gc() { 202 | c.Lock() 203 | defer c.Unlock() 204 | 205 | for k, store := range c.store { 206 | if Channel != nil && Channel.ID == k { 207 | continue 208 | } 209 | 210 | if time.Now().Sub(store.time) > c.Age { 211 | delete(c.store, k) 212 | } 213 | } 214 | } 215 | 216 | func imageFormatIsSupported(filename string) bool { 217 | fileExt := filepath.Ext(filename) 218 | for _, ext := range []string{".png", ".jpg", ".jpeg"} { 219 | if fileExt == ext { 220 | return true 221 | } 222 | } 223 | 224 | return false 225 | } 226 | -------------------------------------------------------------------------------- /input.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "strings" 7 | 8 | "github.com/atotto/clipboard" 9 | "github.com/diamondburned/tcell" 10 | "github.com/diamondburned/tview/v2" 11 | "gitlab.com/diamondburned/6cord/antitele" 12 | ) 13 | 14 | var toReplaceRuneMap = map[byte]string{ 15 | 'n': "\n", 16 | 't': " ", 17 | } 18 | 19 | var ( 20 | currentHistoryItem = -1 21 | currentMessage string 22 | ) 23 | 24 | func resetInputBehavior() { 25 | input.SetLabel(templatePrefix()) 26 | input.SetLabelColor(tcell.Color(cfg.Prop.BackgroundColor)) 27 | input.SetBackgroundColor(tcell.Color(cfg.Prop.BackgroundColor)) 28 | input.SetFieldBackgroundColor(tcell.Color(cfg.Prop.BackgroundColor)) 29 | input.SetPlaceholderTextColor(tcell.ColorDarkCyan) 30 | input.SetText("") 31 | clearList() 32 | 33 | stateResetter() 34 | toEditMessage = 0 35 | } 36 | 37 | func templatePrefix() string { 38 | var ( 39 | channelTpl = "#nil" 40 | guildTpl = "nil" 41 | selfnameTpl = "nil" 42 | discrimTpl = "0000" 43 | ) 44 | 45 | if Channel != nil { 46 | switch { 47 | case Channel.Name == "": 48 | channelTpl = "#dm" 49 | default: 50 | channelTpl = "#" + Channel.Name 51 | } 52 | 53 | g, _ := d.State.Guild(Channel.GuildID) 54 | if g != nil { 55 | guildTpl = g.Name 56 | } 57 | } 58 | 59 | if d != nil && d.State.User != nil { 60 | selfnameTpl = d.State.User.Username 61 | discrimTpl = d.State.User.Discriminator 62 | } 63 | 64 | return prefixTpl.ExecuteString(map[string]interface{}{ 65 | "CHANNEL": channelTpl, 66 | "GUILD": guildTpl, 67 | "USERNAME": selfnameTpl, 68 | "DISCRIM": discrimTpl, 69 | }) 70 | } 71 | 72 | func processString(input string) string { 73 | var output = strings.Builder{} 74 | 75 | RuneWalk: 76 | for i := 0; i < len(input); i++ { 77 | for match, with := range toReplaceRuneMap { 78 | if input[i] == '\\' && (i < len(input)-1 && input[i+1] == match) { 79 | if i == 0 || input[i-1] != '\\' { 80 | output.WriteString(with) 81 | if i < len(input)-2 && input[i+2] == ' ' { 82 | i++ 83 | } 84 | 85 | i++ 86 | continue RuneWalk 87 | } else { 88 | i++ 89 | } 90 | } 91 | } 92 | 93 | output.WriteByte(input[i]) 94 | } 95 | 96 | if cfg.Prop.ObfuscateWords { 97 | return antitele.Insert(output.String()) 98 | } 99 | 100 | return output.String() 101 | } 102 | 103 | var store bool 104 | 105 | func handleHistoryItem() { 106 | if currentHistoryItem < 0 { 107 | input.SetText(currentMessage) 108 | store = true 109 | } else { 110 | if store { 111 | currentMessage = input.GetText() 112 | store = false 113 | } 114 | 115 | input.SetText(cmdHistory[currentHistoryItem]) 116 | } 117 | } 118 | 119 | func inputKeyHandler(ev *tcell.EventKey) *tcell.EventKey { 120 | if ev.Modifiers() == tcell.ModAlt { 121 | switch { 122 | case ev.Key() == tcell.KeyEnter: 123 | input.SetText(input.GetText() + "\n") 124 | return nil 125 | 126 | case ev.Key() == tcell.KeyDown || ev.Rune() == 'j': 127 | currentHistoryItem++ 128 | if currentHistoryItem > len(cmdHistory)-1 { 129 | currentHistoryItem = len(cmdHistory) - 1 130 | } 131 | 132 | handleHistoryItem() 133 | return nil 134 | 135 | case ev.Key() == tcell.KeyUp || ev.Rune() == 'k': 136 | currentHistoryItem-- 137 | if currentHistoryItem < -1 { 138 | currentHistoryItem = -1 139 | } 140 | 141 | handleHistoryItem() 142 | return nil 143 | } 144 | } else { 145 | switch ev.Key() { 146 | case tcell.KeyEscape: 147 | resetInputBehavior() 148 | if showChannels { 149 | toggleChannels() 150 | } 151 | 152 | app.SetFocus(guildView) 153 | return nil 154 | 155 | case tcell.KeyCtrlV: 156 | cb, err := clipboard.ReadAll() 157 | if err != nil { 158 | log.Println("Couldn't get clipboard:", err) 159 | return nil 160 | } 161 | 162 | b := []byte(cb) 163 | 164 | if IsFile(b) { 165 | modal := tview.NewModal() 166 | modal.AddButtons([]string{"Cancel", "Yes"}) 167 | modal.SetText("Upload file in clipboard?") 168 | modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { 169 | switch buttonLabel { 170 | case "Yes": 171 | go func() { 172 | if Channel == nil { 173 | Warn("Not in a channel.") 174 | } 175 | 176 | input.SetPlaceholder("Uploading file...") 177 | 178 | br := bytes.NewReader(b) 179 | _, err = d.ChannelFileSend( 180 | Channel.ID, 181 | "clipboard.png", 182 | br, 183 | ) 184 | 185 | input.SetPlaceholder(cfg.Prop.DefaultStatus) 186 | 187 | if err != nil { 188 | Warn(err.Error()) 189 | } 190 | }() 191 | } 192 | 193 | app.SetRoot(appflex, true) 194 | app.SetFocus(input) 195 | }) 196 | 197 | app.SetRoot(modal, false) 198 | app.SetFocus(modal) 199 | 200 | } else { 201 | input.SetText(input.GetText() + cb) 202 | } 203 | case tcell.KeyLeft, tcell.KeyCtrlH: 204 | if input.GetText() != "" { 205 | return ev 206 | } 207 | 208 | app.SetFocus(guildView) 209 | return nil 210 | 211 | case tcell.KeyUp, tcell.KeyCtrlP: 212 | if autocomp.GetItemCount() < 1 { 213 | // Todo: replace with /edit call 214 | app.SetFocus(messagesView) 215 | } else { 216 | acItem := autocomp.GetCurrentItem() 217 | if acItem == 0 { 218 | newitem := autocomp.GetItemCount() - 1 219 | autocomp.SetCurrentItem(newitem) 220 | } 221 | 222 | app.SetFocus(autocomp) 223 | } 224 | 225 | case tcell.KeyDown, tcell.KeyCtrlN: 226 | acItem := autocomp.GetCurrentItem() 227 | var newitem = acItem + 1 228 | 229 | switch { 230 | case autocomp.GetItemCount() == 0: 231 | return ev 232 | case newitem > autocomp.GetItemCount()-1: 233 | newitem = 0 234 | } 235 | 236 | autocomp.SetCurrentItem(newitem) 237 | app.SetFocus(autocomp) 238 | 239 | case tcell.KeyPgUp, tcell.KeyPgDn: 240 | // Handle scrolling with PgUp/Dn in the input box 241 | app.SetFocus(messagesView) 242 | messagesView.InputHandler()(ev, nil) 243 | handleScroll() 244 | return nil 245 | 246 | case tcell.KeyTab: 247 | if autocomp.GetItemCount() > 0 { 248 | acItem := autocomp.GetCurrentItem() 249 | autofillfunc(acItem) 250 | return nil 251 | } 252 | 253 | return ev 254 | 255 | case tcell.KeyEnter: 256 | if autocomp.GetItemCount() > 0 { 257 | acItem := autocomp.GetCurrentItem() 258 | autofillfunc(acItem) 259 | return nil 260 | } 261 | 262 | // log.Println(ev.Name()) 263 | 264 | // if ev.Name() == "Shift+Enter" { 265 | // input.SetText(input.GetText() + "\\n") 266 | // return nil 267 | // } 268 | 269 | switch input.GetLabel() { 270 | case EditMessageLabel: 271 | go editHandler() 272 | 273 | default: 274 | CommandHandler() 275 | } 276 | 277 | return nil 278 | } 279 | 280 | if ev.Name() == "'" && ev.Modifiers() == tcell.ModCtrl { 281 | commandEditor(nil) 282 | return nil 283 | } 284 | } 285 | 286 | return ev 287 | } 288 | -------------------------------------------------------------------------------- /keyring/keyring.go: -------------------------------------------------------------------------------- 1 | package keyring 2 | 3 | import ( 4 | "log" 5 | 6 | keyring "github.com/zalando/go-keyring" 7 | ) 8 | 9 | const ( 10 | // AppName used for keyrings 11 | AppName = "6cord" 12 | ) 13 | 14 | func Get() string { return Store.Get() } 15 | func Set(token string) { Store.Set(token) } 16 | 17 | var Store Storer = defaultKeyring{} 18 | 19 | type Storer interface { 20 | Get() string 21 | Set(token string) 22 | Delete() 23 | } 24 | 25 | type defaultKeyring struct{} 26 | 27 | func (defaultKeyring) Get() string { 28 | k, err := keyring.Get(AppName, "token") 29 | if err != nil { 30 | log.Println("Warning: Could not get token:", err) 31 | } 32 | 33 | return k 34 | } 35 | 36 | func (defaultKeyring) Set(token string) { 37 | if err := keyring.Set(AppName, "token", token); err != nil { 38 | log.Println("Warning: Token is not stored:", err) 39 | } 40 | } 41 | 42 | func (defaultKeyring) Delete() { 43 | if err := keyring.Delete(AppName, "token"); err != nil { 44 | log.Println("Warning: Token is not deleted:", err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /keyring/keyring_null.go: -------------------------------------------------------------------------------- 1 | // +build nokeyring 2 | 3 | package keyring 4 | 5 | import ( 6 | "log" 7 | ) 8 | 9 | func init() { 10 | Store = nullKeyring{} 11 | } 12 | 13 | type nullKeyring struct{} 14 | 15 | func (nullKeyring) Get() string { 16 | log.Println("Warning: 6cord compiled without keyring") 17 | return "" 18 | } 19 | 20 | func (nullKeyring) Set(token string) { 21 | log.Println("Warning: 6cord compiled without keyring") 22 | } 23 | 24 | func (nullKeyring) Delete() { 25 | log.Println("Warning: 6cord compiled without keyring") 26 | } 27 | -------------------------------------------------------------------------------- /lastauthor_state.go: -------------------------------------------------------------------------------- 1 | package main 2 | -------------------------------------------------------------------------------- /loadchannel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/diamondburned/discordgo" 11 | "github.com/diamondburned/tview/v2" 12 | ) 13 | 14 | const prefetchMessageCount = 35 15 | 16 | var fetchedMembersGuild = map[int64]struct{}{} 17 | var fetchedMembersGuildMu sync.Mutex 18 | 19 | var _loadingChannel bool 20 | 21 | func loadingChannel() func() { 22 | _loadingChannel = true 23 | return func() { 24 | _loadingChannel = false 25 | } 26 | } 27 | 28 | func isFetched(guildID int64) bool { 29 | fetchedMembersGuildMu.Lock() 30 | defer fetchedMembersGuildMu.Unlock() 31 | 32 | if _, ok := fetchedMembersGuild[guildID]; ok { 33 | return true 34 | } 35 | 36 | fetchedMembersGuild[guildID] = struct{}{} 37 | return false 38 | } 39 | 40 | func loadChannel(channelID int64) { 41 | if _loadingChannel { 42 | return 43 | } 44 | 45 | wrapFrame.SetTitle("[Loading...[]") 46 | app.Draw() 47 | 48 | go actualLoadChannel(channelID) 49 | } 50 | 51 | func actualLoadChannel(channelID int64) { 52 | defer loadingChannel()() 53 | 54 | ch, err := d.State.Channel(channelID) 55 | if err != nil { 56 | ch, err = d.Channel(channelID) 57 | if err != nil { 58 | Warn(err.Error()) 59 | return 60 | } 61 | } 62 | 63 | log.Println("Booted stage 1") 64 | // time.Sleep(1 * time.Second) 65 | 66 | switch ch.Type { 67 | case discordgo.ChannelTypeGuildVoice: 68 | Message("Voice is currently not working D:") 69 | return 70 | case discordgo.ChannelTypeGuildCategory: 71 | return 72 | } 73 | 74 | Channel = ch 75 | 76 | resetInputBehavior() 77 | typing.Reset() 78 | app.SetFocus(input) 79 | 80 | if len(ch.Messages) > 0 { 81 | drawMsgs(ch.Messages) 82 | } 83 | 84 | msgs, err := d.ChannelMessages( 85 | Channel.ID, prefetchMessageCount, 86 | 0, 0, 0, 87 | ) 88 | 89 | if err != nil { 90 | Warn(err.Error()) 91 | return 92 | } 93 | 94 | log.Println("Booted stage 2") 95 | // time.Sleep(1 * time.Second) 96 | 97 | ch.Messages = msgs 98 | 99 | if len(msgs) > 0 { 100 | go func(c *discordgo.Channel, msgs []*discordgo.Message) { 101 | log.Println("Prepare to ack...") 102 | time.Sleep(3 * time.Second) 103 | log.Println("Acking...") 104 | ackMe(c.ID, msgs[len(msgs)-1].ID) 105 | }(ch, msgs) 106 | } 107 | 108 | drawMsgs(msgs) 109 | 110 | if len(msgs) == 0 { 111 | Message("There's nothing here!") 112 | } 113 | 114 | wrapFrame.SetTitle(generateTitle(ch)) 115 | app.Draw() 116 | 117 | go func() { 118 | if ch.GuildID == 0 { 119 | return 120 | } 121 | 122 | // If the huge members list is already fetched, return 123 | if isFetched(ch.GuildID) { 124 | return 125 | } 126 | 127 | log.Println("Booted stage 3") 128 | time.Sleep(2 * time.Second) 129 | 130 | d.GatewayManager.SubscribeGuild( 131 | Channel.GuildID, true, true, 132 | ) 133 | 134 | members := &([]*discordgo.Member{}) 135 | 136 | guild, err := d.State.Guild(ch.GuildID) 137 | if err != nil { 138 | if guild, err = d.Guild(ch.GuildID); err != nil { 139 | Warn(err.Error()) 140 | return 141 | } 142 | } 143 | 144 | log.Println("Halting stage 4...") 145 | return 146 | 147 | recurseMembers(members, ch.GuildID, 0) 148 | 149 | log.Println("Booted stage 4") 150 | time.Sleep(2 * time.Second) 151 | 152 | guild.Members = *members 153 | 154 | roles := guild.Roles 155 | sort.Slice(roles, func(i, j int) bool { 156 | return roles[i].Position > roles[j].Position 157 | }) 158 | 159 | for _, m := range *members { 160 | color := defaultNameColor 161 | 162 | RoleLoop: 163 | for _, role := range roles { 164 | for _, roleID := range m.Roles { 165 | if role.ID == roleID && role.Color != 0 { 166 | color = role.Color 167 | break RoleLoop 168 | } 169 | } 170 | } 171 | 172 | us.UpdateUser( 173 | guild.ID, 174 | m.User.ID, 175 | m.User.Username, 176 | m.Nick, 177 | m.User.Discriminator, 178 | color, 179 | ) 180 | } 181 | }() 182 | } 183 | 184 | func drawMsgs(msgs []*discordgo.Message) { 185 | sort.Slice(msgs, func(i, j int) bool { 186 | return msgs[i].ID < msgs[j].ID 187 | }) 188 | 189 | // Clears the buffer 190 | messageRender <- nil 191 | 192 | for i := 0; i < len(msgs); i++ { 193 | imageRendererPipeline.cache.markUnfetch(msgs[i]) 194 | } 195 | 196 | for i := 0; i < len(msgs); i++ { 197 | m := msgs[i] 198 | 199 | if rstore.Check(m.Author, RelationshipBlocked) { 200 | continue 201 | } 202 | 203 | messageRender <- m 204 | } 205 | } 206 | 207 | func generateTitle(ch *discordgo.Channel, custom ...string) (frameTitle string) { 208 | if ch == nil { 209 | return "[#nil[]" 210 | } 211 | 212 | var Custom = strings.Join(custom, " ") 213 | 214 | if ch.Name != "" { 215 | frameTitle = "[#" + ch.Name + "]" 216 | 217 | if Custom != "" { 218 | frameTitle += " - " + Custom 219 | } else if ch.Topic != "" { 220 | topic, _ := parseEmojis(ch.Topic) 221 | frameTitle += " - [" + tview.Escape(topic) + "]" 222 | } 223 | } else { 224 | if len(ch.Recipients) == 1 { 225 | frameTitle = "[" + ch.Recipients[0].String() + "]" 226 | } else if Custom != "" { 227 | frameTitle += " - " + Custom 228 | } else { 229 | var names = make([]string, len(ch.Recipients)) 230 | for i, r := range ch.Recipients { 231 | names[i] = r.Username 232 | } 233 | 234 | frameTitle = "[" + HumanizeStrings(names) + "]" 235 | } 236 | } 237 | 238 | return 239 | } 240 | 241 | func messageisOld(m, l *discordgo.Message) bool { 242 | if m == nil || l == nil { 243 | return true 244 | } 245 | 246 | mt, err := m.Timestamp.Parse() 247 | if err != nil { 248 | return true 249 | } 250 | 251 | lt, err := l.Timestamp.Parse() 252 | if err != nil { 253 | return true 254 | } 255 | 256 | return lt.Add(time.Minute).Before(mt) 257 | } 258 | 259 | func recurseMembers(memstore *[]*discordgo.Member, guildID, after int64) { 260 | return 261 | 262 | /* 263 | members, err := d.GuildMembers(guildID, after, 1000) 264 | if err != nil { 265 | log.Println(err) 266 | return 267 | } 268 | 269 | if len(members) == 1000 { 270 | recurseMembers( 271 | memstore, 272 | guildID, 273 | members[999].User.ID, 274 | ) 275 | } 276 | 277 | *memstore = append(*memstore, members...) 278 | 279 | return 280 | */ 281 | } 282 | -------------------------------------------------------------------------------- /login.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diamondburned/discordgo" 7 | "github.com/diamondburned/tcell" 8 | "github.com/diamondburned/tview/v2" 9 | "gitlab.com/diamondburned/6cord/center" 10 | ) 11 | 12 | func promptLogin(l *discordgo.Login, text string, mfa bool) (ok bool) { 13 | flex := tview.NewFlex() 14 | flex.SetDirection(tview.FlexRow) 15 | flex.AddItem(tview.NewTextView().SetText(text), 1, 1, false) 16 | flex.SetBackgroundColor(-1) 17 | 18 | f := tview.NewForm() 19 | f.SetBackgroundColor(tcell.Color237) 20 | f.SetButtonBackgroundColor(tcell.Color255) 21 | f.SetButtonTextColor(tcell.Color237) 22 | 23 | // Field 24 | f.SetFieldBackgroundColor(tcell.Color248) 25 | f.SetFieldTextColor(tcell.Color237) 26 | 27 | f.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { 28 | switch ev.Key() { 29 | case tcell.KeyCtrlC: 30 | app.Stop() 31 | } 32 | 33 | return nil 34 | }) 35 | 36 | if !mfa { 37 | f.AddInputField( 38 | "Email ", l.Email, 59, nil, 39 | func(s string) { l.Email = s }, 40 | ) 41 | 42 | f.AddPasswordField( 43 | "Password", l.Password, 59, '*', 44 | func(s string) { l.Password = s }, 45 | ) 46 | } else { 47 | f.AddInputField( 48 | "MFA ", l.MFA, 59, nil, 49 | func(s string) { l.MFA = s }, 50 | ) 51 | } 52 | 53 | f.AddButton("Login", func() { 54 | ok = true 55 | app.Stop() 56 | }) 57 | 58 | f.SetCancelFunc(func() { 59 | app.Stop() 60 | }) 61 | 62 | flex.AddItem(f, 0, 1, true) 63 | 64 | center := center.New(flex) 65 | center.MaxWidth = 70 66 | center.MaxHeight = 10 67 | 68 | app.SetRoot(center, true) 69 | if err := app.Run(); err != nil { 70 | fmt.Println(err.Error()) 71 | panic(err) 72 | } 73 | 74 | return 75 | } 76 | -------------------------------------------------------------------------------- /math.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func max(x, y int) int { 4 | if x > y { 5 | return x 6 | } 7 | return y 8 | } 9 | 10 | func min(x, y int) int { 11 | if x < y { 12 | return x 13 | } 14 | return y 15 | } 16 | -------------------------------------------------------------------------------- /md/highlighter.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/alecthomas/chroma" 7 | "github.com/alecthomas/chroma/formatters" 8 | "github.com/alecthomas/chroma/lexers" 9 | "github.com/alecthomas/chroma/styles" 10 | ) 11 | 12 | // HighlightStyle determines the syntax highlighting colorstyle: 13 | // https://xyproto.github.io/splash/docs/all.html 14 | var HighlightStyle = "vs" 15 | 16 | var ( 17 | style *chroma.Style 18 | fmtter chroma.Formatter 19 | ) 20 | 21 | // RenderCodeBlock renders the node to a syntax 22 | // highlighted code 23 | func RenderCodeBlock(lang, content string) string { 24 | // Get the highlight style 25 | if style == nil { 26 | // Try and find it 27 | if s := styles.Get(HighlightStyle); s != nil { 28 | style = s 29 | } else { 30 | // Can't find, use fallback 31 | style = styles.Fallback 32 | } 33 | } 34 | 35 | if fmtter == nil { 36 | fmtter = formatters.Get("tview-256bit") 37 | } 38 | 39 | var lexer = lexers.Fallback 40 | if lang := string(lang); lang != "" { 41 | if l := lexers.Get(lang); l != nil { 42 | lexer = l 43 | } else { 44 | content = lang + "\n" + content 45 | } 46 | } 47 | 48 | iterator, err := lexer.Tokenise(nil, content) 49 | if err != nil { 50 | return wrapBlock(content) 51 | } 52 | 53 | var code strings.Builder 54 | 55 | if err := fmtter.Format(&code, style, iterator); err != nil { 56 | return wrapBlock(content) 57 | } 58 | 59 | return wrapBlock(code.String()) 60 | } 61 | 62 | func wrapBlock(content string) string { 63 | var s strings.Builder 64 | 65 | // wrapped := tview.WordWrap(code.String(), 80) 66 | wrapped := strings.Split(content, "\n") 67 | 68 | for i := 0; i < len(wrapped); i++ { 69 | if wrapped[i] != "[-]" { 70 | s.WriteString("\n[grey]┃[-] " + wrapped[i]) 71 | } 72 | } 73 | 74 | return s.String()[1:] + "\n" 75 | } 76 | -------------------------------------------------------------------------------- /md/md.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/diamondburned/tview/v2" 8 | "gitlab.com/diamondburned/6cord/shortener" 9 | ) 10 | 11 | const mdRegex = `(?m)(?:^\x60\x60\x60 *(\w*)\n([\s\S]*?)\n\x60\x60\x60$)|((?:(?:^|\n)>\s+.*)+)|(?:(?:^|\n)(?:[>*+-]|\d+\.)\s+.*)+|(?:\x60([^\x60].*?)\x60)|(__|\*\*\*|\*\*|[_*]|~~|\|\|)|(https?:\/\S+(?:\.|:)\S+)` 12 | 13 | var r1 = regexp.MustCompile(mdRegex) 14 | 15 | type match struct { 16 | from, to int 17 | str string 18 | } 19 | 20 | type mdState struct { 21 | strings.Builder 22 | matches [][]match 23 | last int 24 | chunk string 25 | prev string 26 | context []string 27 | } 28 | 29 | func (s *mdState) tag(token string) string { 30 | var tags [2]string 31 | 32 | switch token { 33 | case "*": 34 | tags[0] = "[::i]" 35 | tags[1] = "[::-]" 36 | case "_": 37 | tags[0] = "[::i]" 38 | tags[1] = "[::-]" 39 | case "**": 40 | tags[0] = "[::b]" 41 | tags[1] = "[::-]" 42 | case "__": 43 | tags[0] = "[::u]" 44 | tags[1] = "[::-]" 45 | case "***": 46 | tags[0] = "[::ib]" 47 | tags[1] = "[::-]" 48 | case "~~": 49 | tags[0] = "[::s]" 50 | tags[1] = "[::-]" 51 | case "||": 52 | tags[0] = "[#777777]" 53 | tags[1] = "[-]" 54 | default: 55 | return token 56 | } 57 | 58 | var index = -1 59 | for i, t := range s.context { 60 | if t == token { 61 | index = i 62 | break 63 | } 64 | } 65 | 66 | if index >= 0 { // len(context) > 0 always 67 | s.context = append(s.context[:index], s.context[index+1:]...) 68 | return tags[1] 69 | } else { 70 | s.context = append(s.context, token) 71 | return tags[0] 72 | } 73 | } 74 | 75 | func (s mdState) getLastIndex(currentIndex int) int { 76 | if currentIndex >= len(s.matches) { 77 | return 0 78 | } 79 | 80 | return s.matches[currentIndex][0].to 81 | } 82 | 83 | func Parse(md string) string { 84 | var s mdState 85 | s.matches = submatch(r1, md) 86 | 87 | for i := 0; i < len(s.matches); i++ { 88 | s.prev = md[s.last:s.matches[i][0].from] 89 | s.last = s.getLastIndex(i) 90 | s.chunk = "" // reset chunk 91 | 92 | switch { 93 | case strings.Count(s.prev, "\\")%2 != 0: 94 | // escaped, print raw 95 | s.chunk = tview.Escape(s.matches[i][0].str) 96 | case s.matches[i][2].str != "": 97 | // codeblock 98 | s.chunk = RenderCodeBlock( 99 | tview.Escape(s.matches[i][1].str), 100 | tview.Escape(s.matches[i][2].str), 101 | ) 102 | case s.matches[i][3].str != "": 103 | // blockquotes, greentext 104 | s.chunk = "\n[#789922]" + 105 | tview.Escape(strings.TrimPrefix(s.matches[i][3].str, "\n")) + 106 | "[-]" 107 | case s.matches[i][4].str != "": 108 | // inline code 109 | s.chunk = "[:#4f4f4f:]" + tview.Escape(s.matches[i][4].str) + "[:-:]" 110 | case s.matches[i][5].str != "": 111 | // inline stuff 112 | s.chunk = s.tag(s.matches[i][5].str) 113 | case s.matches[i][6].str != "": 114 | s.chunk = shortener.ShortenURL(s.matches[i][6].str) 115 | default: 116 | s.chunk = tview.Escape(s.matches[i][0].str) 117 | } 118 | 119 | s.WriteString(tview.Escape(s.prev)) 120 | s.WriteString(s.chunk) 121 | } 122 | 123 | s.WriteString(md[s.last:]) 124 | 125 | for len(s.context) > 0 { 126 | s.WriteString(s.tag(s.context[len(s.context)-1])) 127 | } 128 | 129 | return strings.TrimSpace(s.String()) 130 | } 131 | 132 | func submatch(r *regexp.Regexp, s string) [][]match { 133 | found := r.FindAllStringSubmatchIndex(s, -1) 134 | indices := make([][]match, len(found)) 135 | 136 | var m = match{-1, -1, ""} 137 | 138 | for i := range found { 139 | indices[i] = make([]match, len(found[i])/2) 140 | 141 | for a, b := range found[i] { 142 | if a%2 == 0 { // first pair 143 | m.from = b 144 | } else { 145 | m.to = b 146 | 147 | if m.from >= 0 && m.to >= 0 { 148 | m.str = s[m.from:m.to] 149 | } else { 150 | m.str = "" 151 | } 152 | 153 | indices[i][a/2] = m 154 | } 155 | } 156 | } 157 | 158 | return indices 159 | } 160 | -------------------------------------------------------------------------------- /md/md_test.go: -------------------------------------------------------------------------------- 1 | package md 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-test/deep" 7 | ) 8 | 9 | const s = `**test** 10 | 11 | __bro__, that is ***cringe!*** 12 | 13 | ****test**** 14 | 15 | ` + "```" + `go 16 | println('a') 17 | ` + "```" + ` 18 | 19 | > cringe lmao 20 | > kek 21 | 22 | >lol 23 | > cringe 24 | 25 | __**test**__ 26 | 27 | \*lol\*` 28 | 29 | const results = `[::b]test[::-] 30 | 31 | [::u]bro[::-], that is [::ib]cringe![::-] 32 | 33 | [::ib][::i]test[::-][::-] 34 | 35 | [grey]┃[-] println([#af0000]'a'[-]) 36 | 37 | [#789922] 38 | > cringe lmao 39 | > kek[-] 40 | 41 | >lol[#789922] 42 | > cringe[-] 43 | 44 | [::u][::b]test[::-][::-] 45 | 46 | \*lol\*` 47 | 48 | func TestParse(t *testing.T) { 49 | html := Parse(s) 50 | if diff := deep.Equal(html, results); diff != nil { 51 | t.Fatal(diff) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /mentions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diamondburned/discordgo" 7 | "github.com/jonas747/gojay" 8 | ) 9 | 10 | const mentionsEndpoint = "https://discordapp.com/api/v6/users/@me/mentions?limit=%d&roles=true&everyone=true" 11 | 12 | func getMentions() (ms []*discordgo.Message, err error) { 13 | resp, err := d.Request( 14 | "GET", 15 | fmt.Sprintf(mentionsEndpoint, 25), 16 | nil, 17 | ) 18 | 19 | if err != nil { 20 | return 21 | } 22 | 23 | err = gojay.Unmarshal(resp, &ms) 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // hopefully avoids messagesView going out of range after a 8 | // while 9 | func cleanupBuffer() { 10 | if len(messageStore) > prefetchMessageCount*6 { 11 | messageStore = messageStore[prefetchMessageCount*4:] 12 | 13 | messagesView.SetText( 14 | strings.Join(messageStore, ""), 15 | ) 16 | } 17 | } 18 | 19 | func scrollChat() (clear bool) { 20 | // If the message box is not focused and the input is empty 21 | if !messagesView.HasFocus() && input.GetText() == "" { 22 | clear = true 23 | cleanupBuffer() 24 | } 25 | 26 | current, lines := getLineStatus() 27 | if lines-current > 5 { 28 | return false 29 | } 30 | 31 | if clear { 32 | messagesView.ScrollToEnd() 33 | } 34 | 35 | if Channel == nil { 36 | wrapFrame.SetTitle(generateTitle(Channel)) 37 | } 38 | 39 | return 40 | } 41 | -------------------------------------------------------------------------------- /messagecreate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/diamondburned/discordgo" 8 | ) 9 | 10 | var ( 11 | highlightInterval = time.Duration(time.Second * 7) 12 | messageStore []string 13 | ) 14 | 15 | func messageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { 16 | if d == nil || Channel == nil { 17 | return 18 | } 19 | 20 | if rstore.Check(m.Author, RelationshipBlocked) && cfg.Prop.HideBlocked { 21 | return 22 | } 23 | 24 | // Notify mentions 25 | go mentionHandler(m) 26 | 27 | if m.ChannelID != Channel.ID { 28 | c, err := d.State.Channel(m.ChannelID) 29 | if err == nil { 30 | c.LastMessageID = m.ID 31 | 32 | } else { 33 | log.Println(err) 34 | } 35 | 36 | markUnread(m.Message) 37 | 38 | return 39 | } 40 | 41 | // ackMe(m.ChannelID, m.ID) 42 | 43 | typing.RemoveUser(&discordgo.TypingStart{ 44 | UserID: m.Author.ID, 45 | ChannelID: m.ChannelID, 46 | }) 47 | 48 | // messagerenderer.go 49 | messageRender <- m 50 | } 51 | -------------------------------------------------------------------------------- /messagedelete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | ) 6 | 7 | func messageDelete(s *discordgo.Session, rm *discordgo.MessageDelete) { 8 | if d == nil || Channel == nil { 9 | return 10 | } 11 | 12 | if rm.ChannelID != Channel.ID { 13 | return 14 | } 15 | 16 | messageRender <- rm 17 | } 18 | 19 | func messageDeleteBulk(s *discordgo.Session, rmb *discordgo.MessageDeleteBulk) { 20 | if d == nil || Channel == nil { 21 | return 22 | } 23 | 24 | if rmb.ChannelID != Channel.ID { 25 | return 26 | } 27 | 28 | for _, m := range rmb.Messages { 29 | messageRender <- &discordgo.MessageDelete{ 30 | Message: &discordgo.Message{ 31 | ChannelID: rmb.ChannelID, 32 | ID: m, 33 | }, 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /messagerenderer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/diamondburned/discordgo" 11 | ) 12 | 13 | var ( 14 | messageRender = make(chan interface{}, 12) 15 | ) 16 | 17 | // Function takes in a messageCreate buffer 18 | func messageRenderer() { 19 | var lastmsg *discordgo.Message 20 | 21 | for i := range messageRender { 22 | switch m := i.(type) { 23 | case *discordgo.MessageCreate: 24 | imageRendererPipeline.cache.markUnfetch(m.Message) 25 | rendererCreate(m.Message, lastmsg) 26 | 27 | lastmsg = m.Message 28 | if m.Author.ID == d.State.User.ID { 29 | messagesView.ScrollToEnd() 30 | } else if !scrollChat() { 31 | messagesView.SetTitle( 32 | generateTitle(Channel, "[red]Unread messages[-]"), 33 | ) 34 | } 35 | 36 | case *discordgo.Message: 37 | rendererCreate(m, lastmsg) 38 | 39 | lastmsg = m 40 | messagesView.ScrollToEnd() 41 | 42 | case *discordgo.MessageDelete: 43 | for i := len(messageStore) - 1; i >= 0; i-- { 44 | if ID := getIDfromindex(i); ID != 0 { 45 | if m.ID != ID { 46 | continue 47 | } 48 | 49 | prev := 0 50 | 51 | if (i > 1 && i == len(messageStore)-1 && strings.HasPrefix(messageStore[i-1], authorPrefix)) || 52 | (i > 0 && 53 | strings.HasPrefix(messageStore[i-1], authorPrefix) && 54 | !strings.HasPrefix(messageStore[i+1], messageRawFormat[:3]) && 55 | i != len(messageStore)-1) { 56 | 57 | prev = 1 58 | } 59 | 60 | messageStore = append( 61 | messageStore[:i-prev], 62 | messageStore[i+1:]..., 63 | ) 64 | 65 | messagesView.SetText(strings.Join(messageStore, "")) 66 | break 67 | } 68 | } 69 | 70 | lastmsg = nil 71 | 72 | case *discordgo.MessageUpdate: 73 | message, err := d.State.Message(Channel.ID, m.ID) 74 | if err != nil { 75 | Warn(err.Error()) 76 | break 77 | } 78 | 79 | id := strconv.FormatInt(m.ID, 10) 80 | for i, msg := range messageStore { 81 | if strings.HasPrefix(msg, messageRawFormat[:3]+id+"\"]") { 82 | msg := messageTmpl.ExecuteString(map[string]interface{}{ 83 | "ID": id, 84 | "content": fmtMessage(message), 85 | }) 86 | 87 | messageStore[i] = msg 88 | 89 | messagesView.SetText(strings.Join(messageStore, "")) 90 | break 91 | } 92 | } 93 | 94 | case string: 95 | msg := authorTmpl.ExecuteString(map[string]interface{}{ 96 | "color": fmtHex(defaultNameColor), 97 | "name": "Not Clyde", 98 | "time": time.Now().Format(time.Stamp), 99 | }) 100 | 101 | var ( 102 | l = strings.Split(m, "\n") 103 | c []string 104 | ) 105 | 106 | for i := 0; i < len(l); i++ { 107 | c = append(c, chatPadding+l[i]) 108 | } 109 | 110 | msg += messageTmpl.ExecuteString(map[string]interface{}{ 111 | "ID": "0", 112 | "content": strings.Join(c, "\n"), 113 | }) 114 | 115 | messagesView.Write([]byte(msg)) 116 | messageStore = append(messageStore, msg) 117 | 118 | scrollChat() 119 | lastmsg = nil 120 | 121 | case nil: 122 | messagesView.Clear() 123 | messageStore = make([]string, 0, prefetchMessageCount*2) 124 | imageRendererPipeline.cache.gc() 125 | 126 | default: 127 | Warn(fmt.Sprintf("Message renderer received event type:\n%T", i)) 128 | log.Println(fmt.Sprintf("%#v", i)) 129 | 130 | continue 131 | } 132 | 133 | app.Draw() 134 | } 135 | } 136 | 137 | func rendererCreate(m, lastmsg *discordgo.Message) { 138 | if m.Type != discordgo.MessageTypeDefault { 139 | var messageText string 140 | 141 | // https://github.com/Bios-Marcel/cordless 142 | switch m.Type { 143 | case discordgo.MessageTypeGuildMemberJoin: 144 | messageText = "joined the server." 145 | case discordgo.MessageTypeCall: 146 | messageText = "is calling you." 147 | case discordgo.MessageTypeChannelIconChange: 148 | messageText = "changed the channel icon." 149 | case discordgo.MessageTypeChannelNameChange: 150 | messageText = "changed the channel name to " + m.Content + "." 151 | case discordgo.MessageTypeChannelPinnedMessage: 152 | messageText = fmt.Sprintf("pinned message %d.", m.ID) 153 | case discordgo.MessageTypeRecipientAdd: 154 | messageText = "added " + m.Mentions[0].Username + " to the group." 155 | case discordgo.MessageTypeRecipientRemove: 156 | messageText = "removed " + m.Mentions[0].Username + " from the group." 157 | } 158 | 159 | if messageText != "" { 160 | msg := fmt.Sprintf( 161 | "\n\n[::d][\"%d\"]%s %s[\"\"][::-]", 162 | m.ID, m.Author.Username, messageText, 163 | ) 164 | 165 | messagesView.Write([]byte(msg)) 166 | messageStore = append(messageStore, msg) 167 | } 168 | 169 | return 170 | } 171 | 172 | msgFmt := messageTmpl.ExecuteString(map[string]interface{}{ 173 | "ID": strconv.FormatInt(m.ID, 10), 174 | "content": fmtMessage(m), 175 | }) 176 | 177 | go func() { 178 | if _, err := imageRendererPipeline.cache.upd(m); err != nil { 179 | Message(err.Error()) 180 | } 181 | }() 182 | 183 | if cfg.Prop.CompactMode || (lastmsg == nil || 184 | (lastmsg.Author.ID != m.Author.ID || messageisOld(m, lastmsg))) { 185 | 186 | sentTime, err := m.Timestamp.Parse() 187 | if err != nil { 188 | sentTime = time.Now() 189 | } 190 | 191 | username, color := us.DiscordThis(m) 192 | 193 | msg := authorTmpl.ExecuteString(map[string]interface{}{ 194 | "color": fmtHex(color), 195 | "name": username, 196 | "time": sentTime.Format(time.Stamp), 197 | }) 198 | 199 | messagesView.Write([]byte(msg + msgFmt)) 200 | messageStore = append(messageStore, msg, msgFmt) 201 | } else { 202 | messagesView.Write([]byte(msgFmt)) 203 | messageStore = append(messageStore, msgFmt) 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /messagescroll.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func messageGetTopID() int64 { 10 | if len(messageStore) < 1 { 11 | return 0 12 | } 13 | 14 | for i := 0; i < len(messageStore); i++ { 15 | if index := getIDfromindex(i); index != 0 { 16 | return index 17 | } 18 | } 19 | 20 | return 0 21 | } 22 | 23 | func getIDfromindex(i int) int64 { 24 | if len(messageStore) <= i || i < 0 { 25 | return 0 26 | } 27 | 28 | // magic number lo 29 | if len(messageStore[i]) < 23 { 30 | return 0 31 | } 32 | 33 | switch { 34 | case messageStore[i][1] != '[': 35 | return 0 36 | case messageStore[i][2] != '"': 37 | return 0 38 | } 39 | 40 | var ( 41 | idRune strings.Builder 42 | msg = messageStore[i] 43 | ) 44 | 45 | for i := 3; i < len(msg); i++ { 46 | if msg[i] == '"' { 47 | break 48 | } 49 | 50 | idRune.WriteByte(msg[i]) 51 | } 52 | 53 | r, _ := strconv.ParseInt(idRune.String(), 10, 64) 54 | return r 55 | } 56 | 57 | var loading bool 58 | 59 | func loadMore() { 60 | if d == nil || Channel == nil { 61 | return 62 | } 63 | 64 | if loading { 65 | return 66 | } 67 | 68 | beforeID := messageGetTopID() 69 | if beforeID == 0 { 70 | return 71 | } 72 | 73 | loading = true 74 | input.SetPlaceholder("Loading more...") 75 | 76 | defer func() { 77 | input.SetPlaceholder(cfg.Prop.DefaultStatus) 78 | loading = false 79 | }() 80 | 81 | c, err := d.State.Channel(Channel.ID) 82 | if err != nil { 83 | Warn(err.Error()) 84 | return 85 | } 86 | 87 | msgs, err := d.ChannelMessages(Channel.ID, 35, beforeID, 0, 0) 88 | if err != nil { 89 | return 90 | } 91 | 92 | if len(msgs) == 0 { 93 | // Drop out early if no messages 94 | return 95 | } 96 | 97 | var reversed = make([]string, 0, len(msgs)*2) 98 | 99 | for i := len(msgs) - 1; i >= 0; i-- { 100 | m := msgs[i] 101 | 102 | if rstore.Check(m.Author, RelationshipBlocked) && cfg.Prop.HideBlocked { 103 | continue 104 | } 105 | 106 | sentTime, err := m.Timestamp.Parse() 107 | if err != nil { 108 | sentTime = time.Now() 109 | } 110 | 111 | if i < len(msgs)-1 && (msgs[i+1].Author.ID != m.Author.ID || messageisOld(m, msgs[i+1])) { 112 | username, color := us.DiscordThis(m) 113 | 114 | reversed = append(reversed, 115 | authorTmpl.ExecuteString(map[string]interface{}{ 116 | "color": fmtHex(color), 117 | "name": username, 118 | "time": sentTime.Format(time.Stamp), 119 | }), 120 | ) 121 | } 122 | 123 | reversed = append(reversed, 124 | messageTmpl.ExecuteString(map[string]interface{}{ 125 | "ID": strconv.FormatInt(m.ID, 10), 126 | "content": fmtMessage(m), 127 | }), 128 | ) 129 | } 130 | 131 | //wg.Wait() 132 | 133 | messageStore = append(reversed, messageStore...) 134 | messagesView.SetText(strings.Join(messageStore, "")) 135 | 136 | input.SetPlaceholder("Done.") 137 | 138 | for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { 139 | msgs[i], msgs[j] = msgs[j], msgs[i] 140 | } 141 | 142 | d.State.Lock() 143 | c.Messages = append(msgs, c.Messages...) 144 | d.State.Unlock() 145 | 146 | messagesView.Highlight(strconv.FormatInt(beforeID, 10)) 147 | messagesView.ScrollToHighlight() 148 | 149 | time.Sleep(time.Second * 5) 150 | } 151 | -------------------------------------------------------------------------------- /messageupdate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/diamondburned/discordgo" 5 | ) 6 | 7 | func messageUpdate(s *discordgo.Session, u *discordgo.MessageUpdate) { 8 | if d == nil || Channel == nil { 9 | return 10 | } 11 | 12 | if Channel.ID != u.ChannelID { 13 | return 14 | } 15 | 16 | m, err := d.State.Message(Channel.ID, u.ID) 17 | if err != nil { 18 | Warn(err.Error()) 19 | return 20 | } 21 | 22 | if rstore.Check(m.Author, RelationshipBlocked) && cfg.Prop.HideBlocked { 23 | return 24 | } 25 | 26 | messageRender <- u 27 | } 28 | -------------------------------------------------------------------------------- /notify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "html" 5 | "strings" 6 | 7 | "github.com/diamondburned/discordgo" 8 | "github.com/diamondburned/tview/v2" 9 | "github.com/gen2brain/beeep" 10 | ) 11 | 12 | const pingHighlightNode = " [#ED2939](!)[-]" 13 | 14 | func mentionHandler(m *discordgo.MessageCreate) { 15 | // Crash-prevention 16 | if d.State.Settings == nil { 17 | return 18 | } 19 | 20 | var ( 21 | submessage = "said in a heated channel" 22 | name = m.Author.Username 23 | pinged bool 24 | ) 25 | 26 | if m.Author.ID != d.State.User.ID { 27 | if heatedChannelsExists(m.ChannelID) { 28 | goto Notify 29 | } 30 | } 31 | 32 | if !messagePingable(m.Message, m.GuildID) { 33 | return 34 | } 35 | 36 | pinged = true 37 | 38 | Notify: 39 | if c, err := d.State.Channel(m.ChannelID); err == nil { 40 | switch { 41 | case !pinged: 42 | submessage = "said in a heated channel" 43 | case len(c.Recipients) > 0: 44 | submessage = "messaged" 45 | default: 46 | submessage = "mentioned you" 47 | } 48 | 49 | if c.Name != "" { 50 | submessage += " in #" + c.Name 51 | 52 | m, err := d.State.Member(c.GuildID, m.Author.ID) 53 | if err == nil { 54 | if m.Nick != "" { 55 | name = m.Nick 56 | } 57 | } 58 | 59 | } else { 60 | if len(c.Recipients) > 1 { 61 | var names = make([]string, len(c.Recipients)) 62 | 63 | for i, p := range c.Recipients { 64 | names[i] = p.Username 65 | } 66 | 67 | submessage += " in " + HumanizeStrings(names) 68 | } 69 | } 70 | } 71 | 72 | // Skip if user is busy 73 | if d.State.Settings.Status != discordgo.StatusDoNotDisturb || !pinged { 74 | // we ignore errors for users without dbus/notify-send 75 | beeep.Notify( 76 | name+" "+submessage, 77 | html.EscapeString(m.ContentWithMentionsReplaced()), 78 | "", 79 | ) 80 | 81 | // if it's a heat signal 82 | if !pinged { 83 | return 84 | } 85 | } 86 | 87 | // Walk the tree for the sake of a (1) 88 | 89 | if Channel != nil && m.ChannelID == Channel.ID { 90 | return 91 | } 92 | 93 | root := guildView.GetRoot() 94 | if root == nil { 95 | return 96 | } 97 | 98 | root.Walk(func(node, parent *tview.TreeNode) bool { 99 | if parent == nil { 100 | return true 101 | } 102 | 103 | reference, ok := node.GetReference().(*discordgo.Channel) 104 | if !ok { 105 | return true 106 | } 107 | 108 | if reference.ID != m.ChannelID { 109 | return false 110 | } 111 | 112 | pingNode := tview.NewTreeNode( 113 | "[#ED2939]" + tview.Escape(name) + "[-] mentioned you", 114 | ) 115 | 116 | pingNode.SetSelectable(false) 117 | pingNode.SetIndent(cfg.Prop.SidebarIndent - 1) 118 | 119 | node.AddChild(pingNode) 120 | node.Expand() 121 | 122 | if _, ok := parent.GetReference().(*discordgo.Guild); ok { 123 | if !strings.HasSuffix(parent.GetText(), pingHighlightNode) { 124 | parent.SetText(parent.GetText() + pingHighlightNode) 125 | } 126 | } 127 | 128 | return false 129 | }) 130 | 131 | app.Draw() 132 | } 133 | -------------------------------------------------------------------------------- /onready.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | 7 | "github.com/diamondburned/discordgo" 8 | "github.com/diamondburned/tcell" 9 | "github.com/diamondburned/tview/v2" 10 | "gitlab.com/diamondburned/6cord/keyring" 11 | ) 12 | 13 | func onReady(s *discordgo.Session, r *discordgo.Ready) { 14 | // Store the token in a keyring 15 | go func() { 16 | log.Println("Storing token inside keyring...") 17 | keyring.Set(d.Token) 18 | }() 19 | 20 | rstore.Relationships = r.Relationships 21 | 22 | // loadChannel() 23 | 24 | guildView.SetSingleClick(true) 25 | 26 | guildNode := tview.NewTreeNode("Guilds") 27 | guildNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 28 | guildNode.SetSelectedColor(tcell.ColorBlack) 29 | 30 | pNode := tview.NewTreeNode("Direct Messages") 31 | pNode.SetReference("Direct Messages") 32 | pNode.Collapse() 33 | pNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 34 | pNode.SetSelectedColor(tcell.ColorBlack) 35 | 36 | // https://github.com/Bios-Marcel/cordless 37 | sort.Slice(r.PrivateChannels, func(a, b int) bool { 38 | channelA := r.PrivateChannels[a] 39 | channelB := r.PrivateChannels[b] 40 | 41 | return channelA.LastMessageID > channelB.LastMessageID 42 | }) 43 | 44 | for _, ch := range r.PrivateChannels { 45 | var display = generateName(ch) + "[-::-]" 46 | 47 | if isUnread(ch) { 48 | display = unreadChannelColorPrefix + display 49 | } else { 50 | display = readChannelColorPrefix + display 51 | } 52 | 53 | chNode := tview.NewTreeNode(display) 54 | chNode.SetReference(ch) 55 | chNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 56 | chNode.SetSelectedColor(tcell.ColorBlack) 57 | chNode.SetIndent(cfg.Prop.SidebarIndent - 1) 58 | 59 | pNode.AddChild(chNode) 60 | } 61 | 62 | guildNode.AddChild(pNode) 63 | guildView.SetCurrentNode(pNode) 64 | 65 | // https://github.com/Bios-Marcel/cordless 66 | sort.Slice(r.Guilds, func(a, b int) bool { 67 | aFound := false 68 | for _, guild := range r.Settings.GuildPositions { 69 | if aFound { 70 | if guild == r.Guilds[b].ID { 71 | return true 72 | } 73 | } else { 74 | if guild == r.Guilds[a].ID { 75 | aFound = true 76 | } 77 | } 78 | } 79 | 80 | return false 81 | }) 82 | 83 | for _, g := range r.Guilds { 84 | this := tview.NewTreeNode(readChannelColorPrefix + g.Name + "[-::-]") 85 | this.SetReference(g) 86 | this.Collapse() 87 | this.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 88 | this.SetSelectedColor(tcell.ColorBlack) 89 | 90 | sorted := SortChannels(g.Channels) 91 | 92 | for _, ch := range sorted { 93 | if !isValidCh(ch.Type) { 94 | continue 95 | } 96 | 97 | perm, err := d.State.UserChannelPermissions( 98 | d.State.User.ID, 99 | ch.ID, 100 | ) 101 | 102 | if err != nil { 103 | continue 104 | } 105 | 106 | if perm&discordgo.PermissionReadMessages == 0 { 107 | continue 108 | } 109 | 110 | var name = generateName(ch) 111 | 112 | switch ch.Type { 113 | case discordgo.ChannelTypeGuildCategory: 114 | chNode := tview.NewTreeNode(unreadChannelColorPrefix + name + "[::-]") 115 | chNode.SetSelectable(false) 116 | chNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 117 | chNode.SetSelectedColor(tcell.ColorBlack) 118 | chNode.SetIndent(cfg.Prop.SidebarIndent - 1) 119 | 120 | this.AddChild(chNode) 121 | 122 | case discordgo.ChannelTypeGuildVoice: 123 | chNode := tview.NewTreeNode("[-::-]" + name + "[-::-]") 124 | chNode.SetReference(ch) 125 | chNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 126 | chNode.SetSelectedColor(tcell.ColorBlack) 127 | 128 | if ch.ParentID == 0 { 129 | chNode.SetIndent(cfg.Prop.SidebarIndent - 1) 130 | } else { 131 | chNode.SetIndent(cfg.Prop.SidebarIndent*2 - 1) 132 | } 133 | 134 | this.AddChild(chNode) 135 | 136 | for _, vc := range getVoiceChannel(ch.GuildID, ch.ID) { 137 | vcNode := generateVoiceNode(vc) 138 | if vcNode == nil { 139 | continue 140 | } 141 | 142 | chNode.AddChild(vcNode) 143 | } 144 | 145 | default: 146 | chNode := tview.NewTreeNode(readChannelColorPrefix + name + "[-::-]") 147 | chNode.SetReference(ch) 148 | chNode.SetColor(tcell.Color(cfg.Prop.ForegroundColor)) 149 | chNode.SetSelectedColor(tcell.ColorBlack) 150 | 151 | if ch.ParentID == 0 { 152 | chNode.SetIndent(cfg.Prop.SidebarIndent - 1) 153 | } else { 154 | chNode.SetIndent(cfg.Prop.SidebarIndent*2 - 1) 155 | } 156 | 157 | this.AddChild(chNode) 158 | } 159 | } 160 | 161 | checkGuildNode(g, this) 162 | guildNode.AddChild(this) 163 | } 164 | 165 | guildView.SetRoot(guildNode) 166 | guildView.SetMouseFunc(func(ev *tcell.EventMouse) bool { 167 | return false 168 | }) 169 | guildView.SetSelectedFunc(func(node *tview.TreeNode) { 170 | reference := node.GetReference() 171 | if reference == nil { 172 | CollapseAll(guildNode) 173 | node.SetExpanded(!node.IsExpanded()) 174 | return 175 | } 176 | 177 | switch r := reference.(type) { 178 | case nil: 179 | 180 | case *discordgo.Channel: 181 | node.SetChildren(nil) 182 | loadChannel(r.ID) 183 | 184 | case *discordgo.Guild: 185 | node.SetText(readChannelColorPrefix + r.Name + "[-::-]") 186 | 187 | if !node.IsExpanded() { 188 | CollapseAll(guildNode) 189 | node.SetExpanded(true) 190 | } else { 191 | node.SetExpanded(false) 192 | } 193 | 194 | checkGuildNode(r, node) 195 | 196 | default: // Private Channels 197 | children := pNode.GetChildren() 198 | n := make([]*tview.TreeNode, 0, len(children)) 199 | for i, c := range children { 200 | if c == node { 201 | n = append(n, c) 202 | n = append(n, children[:i]...) 203 | n = append(n, children[i+1:]...) 204 | 205 | pNode.SetChildren(n) 206 | break 207 | } 208 | } 209 | 210 | if !node.IsExpanded() { 211 | CollapseAll(guildNode) 212 | node.SetExpanded(true) 213 | } else { 214 | node.SetExpanded(false) 215 | } 216 | } 217 | }) 218 | 219 | guildView.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { 220 | switch ev.Key() { 221 | case tcell.KeyRight, tcell.KeyCtrlL: 222 | app.SetFocus(input) 223 | return nil 224 | case tcell.KeyLeft: 225 | return nil 226 | case tcell.KeyTab: 227 | return nil 228 | case tcell.KeyCtrlD, tcell.KeyCtrlU: 229 | children := guildNode.GetChildren() 230 | _, _, _, height := messagesView.GetInnerRect() 231 | 232 | hop(children, func(i int) { 233 | // Change fn to up/down functions accordingly 234 | switch ev.Key() { 235 | case tcell.KeyCtrlU: 236 | // Make sure we can scroll up by the desired amount 237 | if i-height/2 >= 0 { 238 | guildView.SetCurrentNode(children[i-height/2]) 239 | } else { 240 | guildView.SetCurrentNode(children[0]) 241 | } 242 | case tcell.KeyCtrlD: 243 | // Make sure we can scroll down by the desired amount 244 | if i+height/2 < len(children) { 245 | guildView.SetCurrentNode(children[i+height/2]) 246 | } else { 247 | guildView.SetCurrentNode(children[len(children)-1]) 248 | } 249 | } 250 | }) 251 | 252 | return nil 253 | case tcell.KeyCtrlJ, tcell.KeyCtrlK, 254 | tcell.KeyPgDn, tcell.KeyPgUp: 255 | children := guildNode.GetChildren() 256 | 257 | hop(children, func(i int) { 258 | // Change fn to up/down functions accordingly 259 | switch ev.Key() { 260 | case tcell.KeyCtrlK, tcell.KeyPgUp: 261 | // If we're not the first guild 262 | if i > 0 { 263 | // Collapse all nodes 264 | CollapseAll(guildNode) 265 | // Set the next node as expanded 266 | children[i-1].SetExpanded(true) 267 | // Set the current node focus 268 | guildView.SetCurrentNode(children[i-1]) 269 | } 270 | case tcell.KeyCtrlJ, tcell.KeyPgDn: 271 | // If we're not the last guild 272 | if i != len(children)-1 { 273 | // Collapse all nodes 274 | CollapseAll(guildNode) 275 | // Set the previous node as expanded 276 | children[i+1].SetExpanded(true) 277 | // Set the current node focus 278 | guildView.SetCurrentNode(children[i+1]) 279 | } 280 | } 281 | }) 282 | 283 | return nil 284 | } 285 | 286 | if ev.Rune() == '/' { 287 | app.SetFocus(input) 288 | input.SetText("/") 289 | 290 | return nil 291 | } 292 | 293 | return ev 294 | }) 295 | 296 | app.Draw() 297 | } 298 | 299 | func hop(children []*tview.TreeNode, fn func(i int)) { 300 | if n := guildView.GetCurrentNode(); n != nil { 301 | switch r := n.GetReference().(type) { 302 | // If the reference is a channel, we know the cursor is over a 303 | // guild's children 304 | case *discordgo.Channel: 305 | // Iterate over guild nodes 306 | for i, gNode := range children { 307 | // Get the dgo guild reference 308 | rg, ok := gNode.GetReference().(*discordgo.Guild) 309 | if !ok { 310 | // Probably not what we're looking for, next 311 | continue 312 | } 313 | 314 | // Not the guild we're in 315 | if rg.ID != r.GuildID { 316 | continue // next 317 | } 318 | 319 | fn(i) 320 | } 321 | 322 | // If the reference is a guild or the direct message thing 323 | case *discordgo.Guild, string: 324 | // Iterate over guild nodes 325 | for i, gNode := range children { 326 | // If the guild node is not the node we're on 327 | if gNode != n { 328 | continue // skip 329 | } 330 | 331 | fn(i) 332 | } 333 | } 334 | } 335 | } 336 | 337 | func isValidCh(t discordgo.ChannelType) bool { 338 | /**/ return t == discordgo.ChannelTypeGuildText || 339 | /*****/ t == discordgo.ChannelTypeDM || 340 | /*****/ t == discordgo.ChannelTypeGroupDM || 341 | /*****/ t == discordgo.ChannelTypeGuildCategory || 342 | /*****/ t == discordgo.ChannelTypeGuildVoice 343 | } 344 | 345 | func isSendCh(t discordgo.ChannelType) bool { 346 | /**/ return t == discordgo.ChannelTypeGuildText || 347 | /*****/ t == discordgo.ChannelTypeDM || 348 | /*****/ t == discordgo.ChannelTypeGroupDM 349 | } 350 | 351 | func generateName(ch *discordgo.Channel) string { 352 | switch ch.Type { 353 | case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM: 354 | return makeDMName(ch) 355 | case discordgo.ChannelTypeGuildVoice: 356 | return "v- " + ch.Name 357 | case discordgo.ChannelTypeGuildCategory: 358 | return ch.Name 359 | default: 360 | return "#" + ch.Name 361 | } 362 | } 363 | 364 | func makeDMName(ch *discordgo.Channel) string { 365 | if ch.Name != "" { 366 | return ch.Name 367 | } 368 | 369 | var names = make([]string, len(ch.Recipients)) 370 | if len(ch.Recipients) == 1 { 371 | p := ch.Recipients[0] 372 | names[0] = p.Username + "#" + p.Discriminator 373 | } else { 374 | for i, p := range ch.Recipients { 375 | names[i] = p.Username 376 | } 377 | } 378 | 379 | return HumanizeStrings(names) 380 | } 381 | -------------------------------------------------------------------------------- /ontyping.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/diamondburned/discordgo" 7 | ) 8 | 9 | // TypingUsers is a store for all typing users 10 | type TypingUsers struct { 11 | Store []*typingEvent 12 | } 13 | 14 | type typingEvent struct { 15 | *discordgo.TypingStart 16 | Meta *typingMeta 17 | } 18 | 19 | type typingMeta struct { 20 | Name string 21 | Time time.Time 22 | } 23 | 24 | var ( 25 | typing = &TypingUsers{} 26 | updateTyping = make(chan struct{}) 27 | ) 28 | 29 | func onTyping(s *discordgo.Session, ts *discordgo.TypingStart) { 30 | if Channel == nil { 31 | return 32 | } 33 | 34 | if ts.ChannelID != Channel.ID { 35 | return 36 | } 37 | 38 | if ts.UserID == d.State.User.ID { 39 | return 40 | } 41 | 42 | go typing.AddUser(ts) 43 | } 44 | 45 | func getTypingMeta(typing *discordgo.TypingStart) *typingMeta { 46 | if typing.GuildID != 0 { 47 | _, user := us.GetUser( 48 | typing.GuildID, typing.UserID, 49 | ) 50 | 51 | m := &typingMeta{ 52 | Time: time.Now(), 53 | } 54 | 55 | if user == nil { 56 | m, err := d.State.Member(typing.GuildID, typing.UserID) 57 | if err != nil { 58 | return nil 59 | } 60 | 61 | user = us.UpdateUser( 62 | m.GuildID, 63 | m.User.ID, 64 | m.User.Username, 65 | m.Nick, 66 | m.User.Discriminator, 67 | getUserColor(m.GuildID, m.Roles), 68 | ) 69 | } 70 | 71 | if user.Nick == "" { 72 | m.Name = user.Name 73 | } else { 74 | m.Name = user.Nick 75 | } 76 | 77 | return m 78 | } 79 | 80 | ch, err := d.State.Channel(Channel.ID) 81 | if err != nil { 82 | return nil 83 | } 84 | 85 | for _, r := range ch.Recipients { 86 | if r.ID == typing.UserID { 87 | return &typingMeta{ 88 | Name: r.Username, 89 | Time: time.Now(), 90 | } 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func renderCallback() { 98 | var ( 99 | animation uint 100 | tick = time.Tick(time.Second) 101 | dots = [6]string{ 102 | " ", "· ", "·· ", 103 | "···", " ··", " ·", 104 | } 105 | ) 106 | 107 | for { 108 | var mems = make([]string, 0, len(typing.Store)) 109 | 110 | select { // 500ms or instant 111 | case <-updateTyping: 112 | case <-tick: 113 | } 114 | 115 | if len(typing.Store) < 1 { 116 | animation = 0 117 | } else { 118 | animation++ 119 | if animation > 5 { 120 | animation = 0 121 | } 122 | } 123 | 124 | for _, t := range typing.Store { 125 | if t.Meta != nil { 126 | mems = append(mems, t.Meta.Name) 127 | } 128 | } 129 | 130 | text := cfg.Prop.DefaultStatus 131 | 132 | switch { 133 | case len(mems) > 3: 134 | text = "Several people are typing" + dots[animation] 135 | case len(mems) == 1: 136 | text = HumanizeStrings(mems) + " is typing" + dots[animation] 137 | case len(mems) > 1: 138 | text = HumanizeStrings(mems) + " are typing" + dots[animation] 139 | } 140 | 141 | if text != input.GetPlaceholder() && !messagesView.HasFocus() { 142 | input.SetPlaceholder(text) 143 | app.Draw() 144 | } 145 | } 146 | } 147 | 148 | func getAnimation(i uint) string { 149 | switch i { 150 | case 0: 151 | return " " 152 | case 1: 153 | return "· " 154 | case 2: 155 | return "·· " 156 | case 3: 157 | return "···" 158 | case 4: 159 | return " ··" 160 | case 5: 161 | return " ·" 162 | } 163 | 164 | return " " 165 | } 166 | 167 | // Reset resets the store 168 | func (tu *TypingUsers) Reset() { 169 | tu.Store = []*typingEvent{} 170 | updateTyping <- struct{}{} 171 | } 172 | 173 | // AddUser this function needs to run in a goroutine 174 | func (tu *TypingUsers) AddUser(ts *discordgo.TypingStart) { 175 | defer func() { 176 | if r := recover(); r != nil { 177 | return 178 | } 179 | }() 180 | 181 | for _, s := range tu.Store { 182 | if s.UserID == ts.UserID && s.Meta != nil { 183 | s.Meta.Time = time.Now() 184 | return 185 | } 186 | } 187 | 188 | ev := &typingEvent{ 189 | TypingStart: ts, 190 | Meta: getTypingMeta(ts), 191 | } 192 | 193 | tu.Store = append(tu.Store, ev) 194 | 195 | updateTyping <- struct{}{} 196 | 197 | time.Sleep(time.Second * 10) 198 | 199 | // should always pass UNLESS there's another AddUser call bumping the 200 | // time up 201 | for { 202 | t := ev.Meta.Time 203 | if t.Add(10 * time.Second).Before(time.Now()) { 204 | tu.RemoveUser(ts) 205 | break 206 | } 207 | 208 | time.Sleep(time.Second * 1) 209 | } 210 | } 211 | 212 | // RemoveUser removes a user from a store array 213 | // true is returned when a user is found and removed 214 | func (tu *TypingUsers) RemoveUser(ts *discordgo.TypingStart) bool { 215 | for i, d := range tu.Store { 216 | if d.UserID == ts.UserID { 217 | tu.Store = append( 218 | tu.Store[:i], 219 | tu.Store[i+1:]..., 220 | ) 221 | 222 | updateTyping <- struct{}{} 223 | return true 224 | } 225 | } 226 | 227 | return false 228 | } 229 | -------------------------------------------------------------------------------- /parsementions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/diamondburned/discordgo" 10 | "github.com/diamondburned/tview/v2" 11 | "gitlab.com/diamondburned/6cord/md" 12 | ) 13 | 14 | var ( 15 | patternChannels = regexp.MustCompile("<#[^>]*>") 16 | ) 17 | 18 | // ParseMentionsFallback parses mentions into strings without failing 19 | func ParseMentionsFallback(m *discordgo.Message) (content string) { 20 | content = md.Parse(m.Content) 21 | 22 | for _, user := range m.Mentions { 23 | var username = tview.Escape(user.Username) 24 | 25 | content = strings.NewReplacer( 26 | // <@ID> 27 | fmt.Sprintf("<@%d>", user.ID), 28 | "[:blue:]@"+username+"[:-:]", 29 | // <@!ID> 30 | fmt.Sprintf("<@!%d>", user.ID), 31 | "[:blue:]@"+username+"[:-:]", 32 | ).Replace(content) 33 | } 34 | 35 | return 36 | } 37 | 38 | func parseMessageContent(m *discordgo.Message) (content string, emojiMap map[string][]string) { 39 | channel, err := d.State.Channel(m.ChannelID) 40 | if err != nil { 41 | content = ParseMentionsFallback(m) 42 | return 43 | } 44 | 45 | _c, emojiMap := parseEmojis(m.Content) 46 | content = md.Parse(_c) 47 | 48 | for _, user := range m.Mentions { 49 | var username = tview.Escape(user.Username) 50 | 51 | member, err := d.State.Member(channel.GuildID, user.ID) 52 | if err == nil && member.Nick != "" { 53 | username = tview.Escape(member.Nick) 54 | } 55 | 56 | var color = "[-:" + cfg.Prop.MentionColor + ":-]" 57 | if user.ID == d.State.User.ID { 58 | color = "[-:" + cfg.Prop.MentionSelfColor + ":-]" 59 | } 60 | 61 | content = strings.NewReplacer( 62 | // <@ID> 63 | fmt.Sprintf("<@%d>", user.ID), 64 | color+"@"+username+"[:-:]", 65 | // <@!ID> 66 | fmt.Sprintf("<@!%d>", user.ID), 67 | color+"@"+username+"[:-:]", 68 | ).Replace(content) 69 | } 70 | 71 | var color = "[-:" + cfg.Prop.MentionColor + ":-]" 72 | 73 | for _, roleID := range m.MentionRoles { 74 | role, err := d.State.Role(channel.GuildID, roleID) 75 | if err != nil { 76 | continue 77 | } 78 | 79 | content = strings.Replace( 80 | content, 81 | fmt.Sprintf("<@&%d>", role.ID), 82 | color+"@"+role.Name+"[:-:]", 83 | 1, 84 | ) 85 | } 86 | 87 | content = patternChannels.ReplaceAllStringFunc(content, func(mention string) string { 88 | id, err := strconv.ParseInt(mention[2:len(mention)-1], 10, 64) 89 | if err != nil { 90 | return mention 91 | } 92 | 93 | channel, err := d.State.Channel(id) 94 | if err != nil || channel.Type == discordgo.ChannelTypeGuildVoice { 95 | return mention 96 | } 97 | 98 | return color + "#" + channel.Name + "[:-:]" 99 | }) 100 | 101 | return 102 | } 103 | -------------------------------------------------------------------------------- /reactionevents.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/diamondburned/discordgo" 4 | 5 | func reactionAdd(s *discordgo.Session, ra *discordgo.MessageReactionAdd) { 6 | if Channel == nil || ra.ChannelID != Channel.ID { 7 | return 8 | } 9 | 10 | m, err := d.State.Message(Channel.ID, ra.MessageID) 11 | if err != nil { 12 | return 13 | } 14 | 15 | d.State.Lock() 16 | 17 | for _, r := range m.Reactions { 18 | if !isSameEmoji(r, ra.MessageReaction) { 19 | continue 20 | } 21 | 22 | r.Count++ 23 | 24 | if ra.UserID == d.State.User.ID { 25 | r.Me = true 26 | } 27 | 28 | goto Found 29 | } 30 | 31 | m.Reactions = append( 32 | m.Reactions, 33 | &discordgo.MessageReactions{ 34 | Count: 1, 35 | Me: ra.UserID == d.State.User.ID, 36 | Emoji: &ra.Emoji, 37 | }, 38 | ) 39 | 40 | Found: 41 | d.State.Unlock() 42 | handleReactionEvent(m) 43 | } 44 | 45 | func reactionRemove(s *discordgo.Session, rm *discordgo.MessageReactionRemove) { 46 | if Channel == nil || rm.ChannelID != Channel.ID { 47 | return 48 | } 49 | 50 | m, err := d.State.Message(Channel.ID, rm.MessageID) 51 | if err != nil { 52 | return 53 | } 54 | 55 | d.State.Lock() 56 | 57 | for i, r := range m.Reactions { 58 | if !isSameEmoji(r, rm.MessageReaction) { 59 | continue 60 | } 61 | 62 | r.Count-- 63 | 64 | if r.Count == 0 { 65 | m.Reactions = removeAllReactions( 66 | m.Reactions, i, 67 | ) 68 | 69 | break 70 | } 71 | 72 | if rm.UserID == d.State.User.ID { 73 | r.Me = false 74 | } 75 | } 76 | 77 | d.State.Unlock() 78 | handleReactionEvent(m) 79 | } 80 | 81 | func reactionRemoveAll(s *discordgo.Session, rm *discordgo.MessageReactionRemoveAll) { 82 | if Channel == nil || rm.ChannelID != Channel.ID { 83 | return 84 | } 85 | 86 | m, err := d.State.Message(Channel.ID, rm.MessageID) 87 | if err != nil { 88 | return 89 | } 90 | 91 | d.State.Lock() 92 | 93 | m.Reactions = []*discordgo.MessageReactions{} 94 | 95 | d.State.Unlock() 96 | 97 | handleReactionEvent(m) 98 | } 99 | -------------------------------------------------------------------------------- /reactions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/diamondburned/discordgo" 9 | ) 10 | 11 | const ( 12 | formatReactionConstant = `[:%s:] %d %s [:-:] ` 13 | 14 | unreactColor = "#383838" 15 | reactColor = "#2196F3" 16 | ) 17 | 18 | func removeAllReactions(rs []*discordgo.MessageReactions, i int) []*discordgo.MessageReactions { 19 | if i > 0 { 20 | return append(rs[:i], rs[i+1:]...) 21 | } 22 | 23 | return rs[i+1:] 24 | } 25 | 26 | func isSameEmoji(rs *discordgo.MessageReactions, r *discordgo.MessageReaction) bool { 27 | if rs.Emoji.ID != 0 || r.Emoji.ID != 0 { 28 | return rs.Emoji.ID == r.Emoji.ID 29 | } 30 | 31 | return rs.Emoji.Name == r.Emoji.Name 32 | } 33 | 34 | func handleReactionEvent(m *discordgo.Message) { 35 | if rstore.Check(m.Author, RelationshipBlocked) && cfg.Prop.HideBlocked { 36 | return 37 | } 38 | 39 | id := strconv.FormatInt(m.ID, 10) 40 | for i, msg := range messageStore { 41 | if strings.HasPrefix(msg, messageRawFormat[:3]+id+"\"]") { 42 | msg := messageTmpl.ExecuteString(map[string]interface{}{ 43 | "ID": strconv.FormatInt(m.ID, 10), 44 | "content": fmtMessage(m), 45 | }) 46 | 47 | messageStore[i] = msg 48 | 49 | break 50 | } 51 | } 52 | 53 | messagesView.SetText(strings.Join(messageStore, "")) 54 | 55 | scrollChat() 56 | } 57 | 58 | func formatReactions(rs []*discordgo.MessageReactions) (f string, eM map[string][]string) { 59 | eM = make(map[string][]string) 60 | 61 | for _, r := range rs { 62 | f += formatReactionString(r) 63 | 64 | if r.Emoji.ID == 0 { 65 | continue 66 | } 67 | 68 | var format = "png" 69 | if r.Emoji.Animated { 70 | format = "gif" 71 | } 72 | 73 | IDstring := fmt.Sprintf("%d", r.Emoji.ID) 74 | 75 | eM[IDstring] = []string{ 76 | r.Emoji.Name, 77 | `https://cdn.discordapp.com/emojis/` + IDstring + `.` + format, 78 | } 79 | } 80 | 81 | return 82 | } 83 | 84 | func formatReactionString(r *discordgo.MessageReactions) string { 85 | if r.Emoji == nil { 86 | return "" 87 | } 88 | 89 | var color = unreactColor 90 | if r.Me { 91 | color = reactColor 92 | } 93 | 94 | return fmt.Sprintf( 95 | formatReactionConstant, 96 | color, r.Count, strings.TrimSpace(r.Emoji.Name), 97 | ) 98 | 99 | } 100 | -------------------------------------------------------------------------------- /readstate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/diamondburned/discordgo" 9 | "github.com/diamondburned/tview/v2" 10 | ) 11 | 12 | const readChannelColorPrefix = "[#808080::]" 13 | const unreadChannelColorPrefix = "[::b]" 14 | 15 | func messageAck(s *discordgo.Session, a *discordgo.MessageAck) { 16 | // Sets ReadState to the message you read 17 | for _, c := range d.State.ReadState { 18 | if c.ID == a.ChannelID && c.LastMessageID != 0 { 19 | c.LastMessageID = a.MessageID 20 | } 21 | } 22 | 23 | c, err := d.State.Channel(a.ChannelID) 24 | if err != nil { 25 | return 26 | } 27 | 28 | if c.GuildID == 0 { 29 | ackMeUI(c) 30 | } else { 31 | g, err := d.State.Guild(c.GuildID) 32 | if err != nil { 33 | return 34 | } 35 | 36 | checkGuild(g) 37 | } 38 | } 39 | 40 | // "[::b]actual string[::-]" 41 | func stripFormat(a string) string { 42 | if len(a) <= 10 { 43 | return a 44 | } 45 | 46 | if strings.HasPrefix(a, readChannelColorPrefix) { 47 | a = a[len(readChannelColorPrefix):] 48 | } 49 | 50 | return strings.TrimSuffix(a, "[-::-]") 51 | } 52 | 53 | var ( 54 | guildSettingsMuted = map[int64]bool{} 55 | channelSettingsMuted = map[int64]bool{} 56 | settingsCacheMutex = &sync.Mutex{} 57 | ) 58 | 59 | func guildMuted(g *discordgo.Guild) bool { 60 | if g == nil { 61 | return false 62 | } 63 | 64 | settingsCacheMutex.Lock() 65 | defer settingsCacheMutex.Unlock() 66 | 67 | guMuted, ok := guildSettingsMuted[g.ID] 68 | if !ok { 69 | gs := getGuildFromSettings(g.ID) 70 | 71 | guMuted = settingGuildIsMuted(gs) 72 | guildSettingsMuted[g.ID] = guMuted 73 | } 74 | 75 | return guMuted 76 | } 77 | 78 | // true if channelID has unread msgs 79 | func isUnread(ch *discordgo.Channel) bool { 80 | var gs *discordgo.UserGuildSettings 81 | 82 | settingsCacheMutex.Lock() 83 | 84 | chMuted, ok := channelSettingsMuted[ch.ID] 85 | if !ok { 86 | if gs == nil { 87 | gs = getGuildFromSettings(ch.GuildID) 88 | } 89 | 90 | cs := getChannelFromGuildSettings(ch.ID, gs) 91 | 92 | chMuted = settingChannelIsMuted(cs, gs) 93 | channelSettingsMuted[ch.ID] = chMuted 94 | } 95 | 96 | var guMuted = false 97 | 98 | if ch.GuildID != 0 { 99 | guMuted, ok = guildSettingsMuted[ch.GuildID] 100 | if !ok { 101 | if gs == nil { 102 | gs = getGuildFromSettings(ch.GuildID) 103 | } 104 | 105 | guMuted = settingGuildIsMuted(gs) 106 | guildSettingsMuted[ch.GuildID] = guMuted 107 | } 108 | } 109 | 110 | settingsCacheMutex.Unlock() 111 | 112 | if chMuted { 113 | return false 114 | } 115 | 116 | if ch.LastMessageID == 0 { 117 | return false 118 | } 119 | 120 | for _, c := range d.State.ReadState { 121 | if c.ID == ch.ID { 122 | return c.LastMessageID != ch.LastMessageID 123 | } 124 | } 125 | 126 | return false 127 | } 128 | 129 | func markUnread(m *discordgo.Message) { 130 | var unread bool 131 | 132 | c, err := d.State.Channel(m.ChannelID) 133 | if err != nil { 134 | return 135 | } 136 | 137 | if c.GuildID == 0 { 138 | // If the latest DM message is not the current message, 139 | // it's unread. 140 | for _, r := range d.State.ReadState { 141 | if r.ID == c.ID { 142 | unread = (m.ID != r.LastMessageID) 143 | break 144 | } 145 | } 146 | } else { 147 | // If neither the channel nor the guild is muted, it's 148 | // unread. 149 | gs := getGuildFromSettings(c.GuildID) 150 | chSettings := getChannelFromGuildSettings(c.ID, gs) 151 | 152 | var ( 153 | chMuted = settingChannelIsMuted(chSettings, gs) 154 | guMuted = settingGuildIsMuted(gs) 155 | ) 156 | 157 | unread = !(guMuted || chMuted) 158 | } 159 | 160 | if !unread { 161 | return 162 | } 163 | 164 | root := guildView.GetRoot() 165 | if root == nil { 166 | return 167 | } 168 | 169 | root.Walk(func(node, parent *tview.TreeNode) bool { 170 | if parent == nil { 171 | return true 172 | } 173 | 174 | switch reference := node.GetReference().(type) { 175 | case *discordgo.Guild: 176 | if reference.ID != m.GuildID { 177 | return false 178 | } 179 | case *discordgo.Channel: 180 | if reference.ID != m.ChannelID { 181 | return false 182 | } 183 | 184 | if g, ok := parent.GetReference().(*discordgo.Guild); ok { 185 | parent.SetText(unreadChannelColorPrefix + g.Name + "[-::-]") 186 | } else { 187 | parent.SetText(unreadChannelColorPrefix + "Direct Messages[-::-]") 188 | } 189 | 190 | var name = generateName(reference) 191 | node.SetText(unreadChannelColorPrefix + name + "[-::-]") 192 | 193 | return false 194 | default: 195 | return true 196 | } 197 | 198 | return true 199 | }) 200 | 201 | app.Draw() 202 | } 203 | 204 | var lastAck string 205 | 206 | func ackMe(chID, ID int64) { 207 | c, err := d.State.Channel(chID) 208 | if err != nil { 209 | return 210 | } 211 | 212 | if isUnread(c) { 213 | // triggers messageAck 214 | a, err := d.ChannelMessageAck(c.ID, ID, lastAck) 215 | 216 | if err != nil { 217 | log.Println(err) 218 | return 219 | } 220 | 221 | lastAck = a.Token 222 | } 223 | 224 | if c.GuildID == 0 { 225 | ackMeUI(c) 226 | } else { 227 | g, err := d.State.Guild(c.GuildID) 228 | if err != nil { 229 | return 230 | } 231 | 232 | checkGuild(g) 233 | } 234 | } 235 | 236 | func ackMeUI(ch *discordgo.Channel) { 237 | root := guildView.GetRoot() 238 | if root == nil { 239 | return 240 | } 241 | 242 | root.Walk(func(node, parent *tview.TreeNode) bool { 243 | if parent == nil { 244 | return true 245 | } 246 | 247 | switch reference := node.GetReference().(type) { 248 | case *discordgo.Guild: 249 | if reference.ID != ch.GuildID { 250 | return false 251 | } 252 | case *discordgo.Channel: 253 | if reference.ID != ch.ID { 254 | return false 255 | } 256 | 257 | var name = generateName(reference) 258 | node.SetText(readChannelColorPrefix + name + "[-::-]") 259 | default: 260 | return true 261 | } 262 | 263 | return true 264 | }) 265 | 266 | app.Draw() 267 | } 268 | 269 | func checkGuild(g *discordgo.Guild) { 270 | root := guildView.GetRoot() 271 | if root == nil { 272 | return 273 | } 274 | 275 | for _, n := range root.GetChildren() { 276 | gd, ok := n.GetReference().(*discordgo.Guild) 277 | if !ok { 278 | continue 279 | } 280 | 281 | if gd.ID != g.ID { 282 | continue 283 | } 284 | 285 | checkGuildNode(g, n) 286 | 287 | app.Draw() 288 | return 289 | } 290 | } 291 | 292 | func checkGuildNode(g *discordgo.Guild, n *tview.TreeNode) { 293 | var unreads = make([]*discordgo.Channel, 0, len(g.Channels)) 294 | for _, c := range g.Channels { 295 | if isUnread(c) { 296 | unreads = append(unreads, c) 297 | } 298 | } 299 | 300 | if len(unreads) == 0 { 301 | n.SetText(readChannelColorPrefix + g.Name + "[-::-]") 302 | } else if !guildMuted(g) { 303 | n.SetText(unreadChannelColorPrefix + g.Name + "[-::-]") 304 | } 305 | 306 | Main: 307 | for _, node := range n.GetChildren() { 308 | reference := node.GetReference() 309 | if reference == nil { 310 | continue 311 | } 312 | 313 | ch, ok := reference.(*discordgo.Channel) 314 | if !ok { 315 | continue 316 | } 317 | 318 | var name = generateName(ch) 319 | 320 | for _, u := range unreads { 321 | if u.ID == ch.ID { 322 | node.SetText(unreadChannelColorPrefix + name + "[-::-]") 323 | continue Main 324 | } 325 | } 326 | 327 | node.SetText(readChannelColorPrefix + name + "[-::-]") 328 | } 329 | } 330 | 331 | // TODO: Check if guild has unread channel 332 | -------------------------------------------------------------------------------- /relationships.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Taken from 4 | // https://gitlab.com/diamondburned/shittercord/blob/master/relationships.go 5 | 6 | import ( 7 | "sync" 8 | 9 | "github.com/diamondburned/discordgo" 10 | ) 11 | 12 | func relationshipAdd(s *discordgo.Session, ra *discordgo.RelationshipAdd) { 13 | for i, r := range d.State.Relationships { 14 | if r.ID == ra.ID { 15 | d.State.Relationships[i] = ra.Relationship 16 | return 17 | } 18 | } 19 | 20 | d.State.Relationships = append( 21 | d.State.Relationships, 22 | ra.Relationship, 23 | ) 24 | } 25 | 26 | func relationshipRemove(s *discordgo.Session, rm *discordgo.RelationshipRemove) { 27 | rs := d.State.Relationships 28 | 29 | for i, r := range rs { 30 | if r.ID == rm.ID { 31 | rs = append(rs[:i], rs[i+1:]...) 32 | return 33 | } 34 | } 35 | 36 | d.State.Relationships = rs 37 | } 38 | 39 | // RStore contains Discord relationships 40 | type RStore struct { 41 | Relationships []*discordgo.Relationship 42 | lock sync.RWMutex 43 | } 44 | 45 | type Relationship int 46 | 47 | const ( 48 | RelationshipNone Relationship = iota 49 | 50 | RelationshipFriend // friend 51 | RelationshipBlocked // blocked 52 | RelationshipIncomingFriendRequest // incoming friend request 53 | RelationshipSentFriendRequest // sent friend request 54 | ) 55 | 56 | var ( 57 | rstore = &RStore{} 58 | ) 59 | 60 | // Check returns true if user is blocked 61 | func (rs *RStore) Check(u *discordgo.User, relationship Relationship) bool { 62 | if !cfg.Prop.HideBlocked && relationship == RelationshipBlocked { 63 | return false 64 | } 65 | 66 | rs.lock.RLock() 67 | defer rs.lock.RUnlock() 68 | 69 | for _, r := range rs.Relationships { 70 | if r == nil { 71 | continue 72 | } 73 | 74 | if r.User == nil { 75 | continue 76 | } 77 | 78 | if r.Type == int(relationship) && r.User.ID == u.ID { 79 | return true 80 | } 81 | } 82 | 83 | return false 84 | } 85 | 86 | // Get gets the relationship of a user 87 | func (rs *RStore) Get(u *discordgo.User) Relationship { 88 | rs.lock.RLock() 89 | defer rs.lock.RUnlock() 90 | 91 | for _, r := range rs.Relationships { 92 | if r.User.ID == u.ID { 93 | return parseInt(r.Type) 94 | } 95 | } 96 | 97 | return RelationshipNone 98 | } 99 | 100 | // Remove removes a relationship from the array 101 | func (rs *RStore) Remove(r *discordgo.Relationship) { 102 | rs.lock.Lock() 103 | defer rs.lock.Unlock() 104 | 105 | for i, rr := range rs.Relationships { 106 | if rr == r { 107 | // arr := *rs 108 | // arr[i] = arr[len(arr)-1] 109 | // *rs = arr[:len(arr)-1] 110 | 111 | rs.Relationships = append(rs.Relationships[:i], rs.Relationships[i+1:]...) 112 | } 113 | } 114 | } 115 | 116 | // Add adds a relationship to the store 117 | func (rs *RStore) Add(r *discordgo.Relationship) { 118 | rs.lock.Lock() 119 | defer rs.lock.Unlock() 120 | 121 | rs.Relationships = append(rs.Relationships, r) 122 | } 123 | 124 | func parseInt(i int) Relationship { 125 | switch i { 126 | case int(RelationshipFriend): 127 | return RelationshipFriend 128 | case int(RelationshipBlocked): 129 | return RelationshipBlocked 130 | case int(RelationshipIncomingFriendRequest): 131 | return RelationshipIncomingFriendRequest 132 | case int(RelationshipSentFriendRequest): 133 | return RelationshipSentFriendRequest 134 | default: 135 | return RelationshipNone 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /screenguild.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func currentGuildScreen() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /screenmiddleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/diamondburned/tcell" 4 | 5 | var drawingMiddlewareFn func() 6 | 7 | func onDrawingMiddleware(s *tcell.Screen) { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /scroll.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func handleScroll() { 9 | current, lines := getLineStatus() 10 | 11 | if current == 0 { 12 | go loadMore() 13 | } 14 | 15 | input.SetPlaceholder(fmt.Sprintf( 16 | "%d/%d %d%%", 17 | current, lines, min(current*100/lines, 100), 18 | )) 19 | 20 | app.Draw() 21 | } 22 | 23 | func getLineStatus() (current, total int) { 24 | total = len( 25 | strings.Split( 26 | strings.Join(messageStore, ""), 27 | "\n", 28 | ), 29 | ) 30 | 31 | if total <= 1 { 32 | total = len(messagesView.GetText(false)) 33 | } 34 | 35 | var ( 36 | toplinepos, _ = messagesView.GetScrollOffset() 37 | _, _, _, height = messagesView.GetInnerRect() 38 | ) 39 | 40 | if toplinepos == 0 { 41 | height = 0 42 | } 43 | 44 | current = toplinepos + height 45 | 46 | return 47 | } 48 | -------------------------------------------------------------------------------- /shortener/port.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "strconv" 7 | ) 8 | 9 | // URL is the current URL 10 | var URL = "" 11 | 12 | // GetOpenPort finds an open port 13 | func GetOpenPort() (int, error) { 14 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 15 | if err != nil { 16 | return 0, err 17 | } 18 | 19 | l, err := net.ListenTCP("tcp", addr) 20 | if err != nil { 21 | return 0, err 22 | } 23 | 24 | defer l.Close() 25 | return l.Addr().(*net.TCPAddr).Port, nil 26 | } 27 | 28 | func StartHTTP(ip string) error { 29 | p, err := GetOpenPort() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | URL = ip + ":" + strconv.Itoa(p) 35 | 36 | http.HandleFunc("/", Handler) 37 | go http.ListenAndServe(URL, nil) 38 | 39 | Enabled = true 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /shortener/shortener.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | ) 10 | 11 | var ( 12 | shortenerState = map[string]string{} 13 | shortenerMutex = &sync.RWMutex{} 14 | 15 | incr int 16 | 17 | // Enabled once StartHTTP is run 18 | Enabled = false 19 | ) 20 | 21 | func Handler(w http.ResponseWriter, r *http.Request) { 22 | shortenerMutex.RLock() 23 | defer shortenerMutex.RUnlock() 24 | 25 | ou, ok := shortenerState[r.URL.Path] 26 | if !ok { 27 | http.NotFound(w, r) 28 | return 29 | } 30 | 31 | http.Redirect(w, r, ou, http.StatusTemporaryRedirect) 32 | } 33 | 34 | func ShortenURL(targetURL string) string { 35 | if !Enabled { 36 | return targetURL 37 | } 38 | 39 | shortenerMutex.Lock() 40 | defer shortenerMutex.Unlock() 41 | 42 | u, err := url.Parse(targetURL) 43 | if err != nil { 44 | return targetURL 45 | } 46 | 47 | var id string 48 | var ext = GetExtension(u.Path) 49 | 50 | var fileshards []string 51 | if u.Path != "" { 52 | fileshards = strings.Split(u.Path, "/") 53 | } else { 54 | fileshards = []string{u.Host} 55 | } 56 | 57 | filename := fileshards[len(fileshards)-1] 58 | filename = filename[:max(len(filename)-len(ext), 0)] 59 | filename = filename[:min(len(filename), 8)] 60 | 61 | slug := filename + "-" + increment() 62 | id = "/" + slug + ext 63 | 64 | shortenerState[id] = targetURL 65 | return "http://" + URL + id 66 | } 67 | 68 | func min(i, j int) int { 69 | if i < j { 70 | return i 71 | } 72 | 73 | return j 74 | } 75 | 76 | func max(i, j int) int { 77 | if i > j { 78 | return i 79 | } 80 | 81 | return j 82 | } 83 | func increment() string { 84 | incr++ 85 | return strconv.Itoa(incr) 86 | } 87 | -------------------------------------------------------------------------------- /shortener/url.go: -------------------------------------------------------------------------------- 1 | package shortener 2 | 3 | import "strings" 4 | 5 | func GetExtension(name string) string { 6 | parts := strings.Split(name, "/") 7 | ss := strings.Split(parts[len(parts)-1], ".") 8 | 9 | if len(ss) < 2 { 10 | return "" 11 | } 12 | 13 | return "." + ss[len(ss)-1] 14 | } 15 | -------------------------------------------------------------------------------- /sixel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "image" 4 | 5 | type sixelImage struct { 6 | source string 7 | image *image.Image 8 | sixel []byte 9 | } 10 | 11 | func newSixelImage(source string) *sixelImage { 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /sortchannels.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Source: https://gist.github.com/diamondburned/c9ebd9fb84a8955495d4fb7983345530 4 | 5 | import ( 6 | "sort" 7 | 8 | "github.com/diamondburned/discordgo" 9 | ) 10 | 11 | type ChannelGeneric struct { 12 | Underlying *discordgo.Channel 13 | 14 | Children []*discordgo.Channel 15 | } 16 | 17 | func SortChannels(cs []*discordgo.Channel) (out []*discordgo.Channel) { 18 | p := make(map[int64]*ChannelGeneric) 19 | 20 | for _, c := range cs { 21 | if c.Type != discordgo.ChannelTypeGuildCategory && c.ParentID != 0 { 22 | v, ok := p[c.ParentID] 23 | if ok { 24 | v.Children = append(v.Children, c) 25 | } else { 26 | p[c.ParentID] = &ChannelGeneric{ 27 | Children: []*discordgo.Channel{c}, 28 | } 29 | } 30 | 31 | continue 32 | } 33 | 34 | if c.Type == discordgo.ChannelTypeGuildCategory { 35 | v, ok := p[c.ID] 36 | 37 | if ok { 38 | v.Underlying = c 39 | } else { 40 | p[c.ID] = &ChannelGeneric{ 41 | Underlying: c, 42 | } 43 | } 44 | 45 | continue 46 | } 47 | 48 | p[c.ID] = &ChannelGeneric{ 49 | Underlying: c, 50 | } 51 | } 52 | 53 | a := make([]*ChannelGeneric, 0, len(p)) 54 | 55 | for _, v := range p { 56 | if v.Children != nil { 57 | sort.Slice(v.Children, func(i, j int) bool { 58 | return v.Children[i].Position < v.Children[j].Position 59 | }) 60 | 61 | sort.SliceStable(v.Children, func(i, j int) bool { 62 | if v.Children[j].Type == discordgo.ChannelTypeGuildVoice { 63 | return true 64 | } 65 | 66 | if v.Children[i].Type == discordgo.ChannelTypeGuildVoice { 67 | return true 68 | } 69 | 70 | return false 71 | }) 72 | } 73 | 74 | a = append(a, v) 75 | } 76 | 77 | sort.Slice(a, func(i, j int) bool { 78 | return a[i].Underlying.Position < a[j].Underlying.Position 79 | }) 80 | 81 | sort.SliceStable(a, func(i, j int) bool { 82 | return a[i].Children == nil 83 | }) 84 | 85 | for _, v := range a { 86 | out = append(out, v.Underlying) 87 | 88 | if v.Children != nil { 89 | for _, k := range v.Children { 90 | out = append(out, k) 91 | } 92 | } 93 | } 94 | 95 | return 96 | } 97 | -------------------------------------------------------------------------------- /syscall.go: -------------------------------------------------------------------------------- 1 | // +build !linux,!darwin,!freebsd,!windows 2 | 3 | package main 4 | 5 | import "os" 6 | 7 | func syscallSilenceStderr(f *os.File) { 8 | // since we're unsure if the platform has dup2, we can just 9 | // silent all errors 10 | d.Debug = false 11 | d.LogLevel = 0 12 | } 13 | -------------------------------------------------------------------------------- /syscall_linux.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | func syscallSilenceStderr(f *os.File) { 12 | if err := syscall.Dup3(int(f.Fd()), 2, 0); err != nil { 13 | log.Println("Can't steal stderr, instabilities may occur") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /syscall_unix.go: -------------------------------------------------------------------------------- 1 | // +build darwin freebsd 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | func syscallSilenceStderr(f *os.File) { 12 | if err := syscall.Dup2(int(f.Fd()), 2); err != nil { 13 | log.Println("Can't steal stderr, instabilities may occur") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /syscall_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | var ( 12 | kernel32 = syscall.MustLoadDLL("kernel32.dll") 13 | procSetStdHandle = kernel32.MustFindProc("SetStdHandle") 14 | ) 15 | 16 | func setStdHandle(stdhandle int32, handle syscall.Handle) error { 17 | r0, _, e1 := syscall.Syscall(procSetStdHandle.Addr(), 2, uintptr(stdhandle), uintptr(handle), 0) 18 | if r0 == 0 { 19 | if e1 != 0 { 20 | return error(e1) 21 | } 22 | 23 | return syscall.EINVAL 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func syscallSilenceStderr(f *os.File) { 30 | err := setStdHandle(syscall.STD_ERROR_HANDLE, syscall.Handle(f.Fd())) 31 | if err != nil { 32 | log.Fatalf("Failed to redirect stderr to file: %v", err) 33 | } 34 | 35 | os.Stderr = f 36 | } 37 | -------------------------------------------------------------------------------- /treenodefns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/diamondburned/tview/v2" 4 | 5 | // CollapseAll collapses all tree nodes 6 | func CollapseAll(gn *tview.TreeNode) { 7 | for _, c := range gn.GetChildren() { 8 | c.Collapse() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /typing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | var ( 6 | typingDelay = time.Duration(time.Second * 8) 7 | typingTimer = time.NewTimer(typingDelay) 8 | ) 9 | 10 | func typingTrigger() { 11 | select { 12 | case <-typingTimer.C: 13 | if cfg.Prop.TriggerTyping { 14 | if Channel == nil { 15 | return 16 | } 17 | 18 | go d.ChannelTyping(Channel.ID) 19 | } 20 | default: 21 | return 22 | } 23 | 24 | typingTimer.Reset(typingDelay) 25 | } 26 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var showChannels bool 4 | 5 | func toggleChannels() { 6 | if showChannels { 7 | wrapFrame.SetBorder(true) 8 | appflex.RemoveItem(guildView) 9 | appflex.RemoveItem(wrapFrame) 10 | 11 | wrapFrame.SetBorders(0, 0, 0, 0, 1, 1) 12 | 13 | appflex.AddItem(guildView, 0, 1, true) 14 | appflex.AddItem(wrapFrame, 0, cfg.Prop.SidebarRatio, true) 15 | 16 | app.SetFocus(guildView) 17 | } else { 18 | wrapFrame.SetBorder(false) 19 | appflex.RemoveItem(guildView) 20 | 21 | wrapFrame.SetBorders(0, 0, 0, 0, 0, 0) 22 | 23 | app.SetFocus(input) 24 | } 25 | 26 | showChannels = !showChannels 27 | } 28 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | 7 | "github.com/diamondburned/discordgo" 8 | ) 9 | 10 | func userSettingsUpdate(s *discordgo.Session, settings *discordgo.UserSettingsUpdate) { 11 | if settings == nil { 12 | return 13 | } 14 | 15 | _settings := *settings 16 | 17 | if status, ok := _settings["status"]; ok { 18 | if str, ok := status.(string); ok { 19 | st := discordgo.Status(str) 20 | d.State.Settings.Status = st 21 | } 22 | } 23 | } 24 | 25 | func safeAuthor(u *discordgo.User) (string, int64) { 26 | if u != nil { 27 | return u.Username, u.ID 28 | } 29 | 30 | return "invalid user", 0 31 | } 32 | 33 | func getUserData(u *discordgo.User, chID int64) (name string, color int) { 34 | var id int64 35 | color = defaultNameColor 36 | name, id = safeAuthor(u) 37 | 38 | if d == nil { 39 | return 40 | } 41 | 42 | channel, err := d.State.Channel(chID) 43 | if err != nil { 44 | if channel, err = d.Channel(chID); err != nil { 45 | log.Println(err) 46 | return 47 | } 48 | } 49 | 50 | if channel.GuildID == 0 { 51 | return 52 | } 53 | 54 | member, err := d.State.Member(channel.GuildID, id) 55 | if err != nil { 56 | if member, err = d.GuildMember(channel.GuildID, id); err != nil { 57 | log.Println(err) 58 | return 59 | } 60 | } 61 | 62 | name = member.Nick 63 | color = getUserColor(channel.GuildID, member.Roles) 64 | 65 | return 66 | } 67 | 68 | func getUserColor(guildID int64, rls discordgo.IDSlice) int { 69 | g, err := d.State.Guild(guildID) 70 | if err != nil { 71 | if g, err = d.Guild(guildID); err != nil { 72 | log.Println(err) 73 | return defaultNameColor 74 | } 75 | } 76 | 77 | roles := g.Roles 78 | sort.Slice(roles, func(i, j int) bool { 79 | return roles[i].Position > roles[j].Position 80 | }) 81 | 82 | for _, role := range roles { 83 | for _, roleID := range rls { 84 | if role.ID == roleID && role.Color != 0 { 85 | return role.Color 86 | } 87 | } 88 | } 89 | 90 | return defaultNameColor 91 | } 92 | 93 | // ReflectStatusColor converts Discord status to HEX colors (#RRGGBB) 94 | func ReflectStatusColor(status discordgo.Status) string { 95 | switch status { 96 | case discordgo.StatusOnline: 97 | return "#43b581" 98 | case discordgo.StatusDoNotDisturb: 99 | return "#f04747" 100 | case discordgo.StatusIdle: 101 | return "#faa61a" 102 | default: // includes invisible and offline 103 | return "#747f8d" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /user_store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/diamondburned/discordgo" 7 | "github.com/diamondburned/tview/v2" 8 | ) 9 | 10 | // User is used for one user 11 | type User struct { 12 | ID int64 13 | Discrim string 14 | Name string 15 | Nick string 16 | Color int 17 | Bot bool 18 | } 19 | 20 | // UserStore stores multiple users 21 | type UserStore struct { 22 | sync.RWMutex 23 | Guilds map[int64]UserStoreArray 24 | } 25 | 26 | // UserStoreArray is an array 27 | type UserStoreArray []*User 28 | 29 | var us = &UserStore{ 30 | Guilds: map[int64]UserStoreArray{}, 31 | } 32 | 33 | // Populated returns a bool on whether or not the array 34 | // alraedy is populated 35 | func (s *UserStore) Populated(guildID int64) bool { 36 | if s == nil { 37 | return false 38 | } 39 | 40 | if guildID == 0 { 41 | return true 42 | } 43 | 44 | return len(s.Guilds[guildID]) > 0 45 | } 46 | 47 | // InStore checks if a user is in the store 48 | func (s *UserStore) InStore(guildID, id int64) bool { 49 | if s == nil { 50 | return false 51 | } 52 | 53 | if _, u := s.GetUser(guildID, id); u != nil { 54 | return true 55 | } 56 | 57 | return false 58 | } 59 | 60 | // DiscordThis interfaces with DiscordGo 61 | func (s *UserStore) DiscordThis(m *discordgo.Message) (n string, c int) { 62 | n = "invalid user" 63 | c = defaultNameColor 64 | 65 | if m.Author == nil || s == nil { 66 | return 67 | } 68 | 69 | defer func() { 70 | if m.Author.Bot { 71 | n += " [#7289da][BOT[][-::-]" 72 | } 73 | }() 74 | 75 | if m.GuildID == 0 { 76 | channel, err := d.State.Channel(m.ChannelID) 77 | if err != nil { 78 | return 79 | } 80 | 81 | m.GuildID = channel.GuildID 82 | } 83 | 84 | _, user := s.GetUser(m.GuildID, m.Author.ID) 85 | if user != nil { 86 | n = user.Name 87 | c = user.Color 88 | 89 | if user.Nick != "" { 90 | n = user.Nick 91 | } 92 | 93 | return 94 | } 95 | 96 | nick, color := getUserData(m.Author, m.ChannelID) 97 | 98 | u := s.UpdateUser( 99 | m.GuildID, 100 | m.Author.ID, 101 | m.Author.Username, 102 | nick, 103 | m.Author.Discriminator, 104 | color, 105 | ) 106 | 107 | n = u.Name 108 | c = u.Color 109 | 110 | if u.Nick != "" { 111 | n = u.Nick 112 | } 113 | 114 | return 115 | } 116 | 117 | // GetUser returns the index and user for that ID 118 | func (s *UserStore) GetUser(guildID, id int64) (int, *User) { 119 | s.RLock() 120 | defer s.RUnlock() 121 | 122 | if v, ok := s.Guilds[guildID]; ok { 123 | for i, u := range v { 124 | if u.ID == id { 125 | return i, u 126 | } 127 | } 128 | } 129 | 130 | return 0, nil 131 | } 132 | 133 | // RemoveUser removes the user from the store 134 | func (s *UserStore) RemoveUser(guildID, id int64) { 135 | var index int 136 | 137 | s.Lock() 138 | defer s.Unlock() 139 | 140 | if v, ok := s.Guilds[guildID]; ok { 141 | for i, u := range v { 142 | if u.ID == id { 143 | index = i 144 | goto Remove 145 | } 146 | } 147 | } 148 | 149 | return 150 | 151 | Remove: 152 | var st = s.Guilds[guildID] 153 | 154 | st[len(st)-1], st[index] = st[index], st[len(st)-1] 155 | s.Guilds[guildID] = st[:len(st)-1] 156 | } 157 | 158 | // UpdateUser updates an user 159 | func (s *UserStore) UpdateUser(guildID, id int64, name, nick, discrim string, color int) *User { 160 | if s == nil { 161 | return nil 162 | } 163 | 164 | if i, u := s.GetUser(guildID, id); u != nil { 165 | if name != "" { 166 | u.Name = tview.Escape(name) 167 | } 168 | 169 | if nick != "" { 170 | u.Nick = tview.Escape(nick) 171 | } 172 | 173 | if discrim != "" { 174 | u.Discrim = discrim 175 | } 176 | 177 | if color > 0 { 178 | u.Color = color 179 | } 180 | 181 | s.Lock() 182 | defer s.Unlock() 183 | 184 | s.Guilds[guildID][i] = u 185 | return u 186 | } 187 | 188 | s.Lock() 189 | defer s.Unlock() 190 | 191 | u := &User{ 192 | ID: id, 193 | Discrim: discrim, 194 | Name: tview.Escape(name), 195 | Nick: tview.Escape(nick), 196 | Color: color, 197 | } 198 | 199 | s.Guilds[guildID] = append(s.Guilds[guildID], u) 200 | return u 201 | } 202 | -------------------------------------------------------------------------------- /voice.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/diamondburned/discordgo" 7 | "github.com/diamondburned/tview/v2" 8 | ) 9 | 10 | func voiceStateUpdate(s *discordgo.Session, vsu *discordgo.VoiceStateUpdate) { 11 | refreshVoiceStates(vsu.VoiceState) 12 | } 13 | 14 | func getVoiceChannel(guildID, channelID int64) (vcs []*discordgo.VoiceState) { 15 | g, err := d.State.Guild(guildID) 16 | if err != nil { 17 | return 18 | } 19 | 20 | for _, vc := range g.VoiceStates { 21 | if vc.ChannelID == channelID { 22 | vcs = append(vcs, vc) 23 | } 24 | } 25 | 26 | return 27 | } 28 | 29 | func canIhearthem(vc *discordgo.VoiceState) bool { 30 | return !(vc.SelfMute || vc.Mute || vc.Suppress) 31 | } 32 | 33 | func refreshVoiceStates(vc *discordgo.VoiceState) { 34 | defer func() { 35 | if r := recover(); r != nil { 36 | Warn(fmt.Sprintln(r)) 37 | return 38 | } 39 | }() 40 | 41 | root := guildView.GetRoot() 42 | if root == nil { 43 | return 44 | } 45 | 46 | if vc == nil { 47 | return 48 | } 49 | 50 | root.Walk(func(node, parent *tview.TreeNode) bool { 51 | if parent == nil || node == nil { 52 | return true 53 | } 54 | 55 | reference := node.GetReference() 56 | if reference == nil { 57 | return true 58 | } 59 | 60 | id, ok := reference.(int64) 61 | if !ok { 62 | return true 63 | } 64 | 65 | // user left voice chat 66 | if vc.ChannelID == 0 { 67 | // checks for ID should match the userID instead, 68 | // as the user left voice chat 69 | if id == vc.UserID { 70 | // parent node at this point should be the voice 71 | // channel 72 | var nodes []*tview.TreeNode 73 | for _, ch := range parent.GetChildren() { 74 | if node != ch { 75 | // we add everything except for the user 76 | // that left by adding everything else back 77 | nodes = append(nodes, ch) 78 | } 79 | } 80 | 81 | parent.SetChildren(nodes) 82 | return false 83 | } 84 | 85 | return true 86 | } 87 | 88 | // user joined a voice channel 89 | 90 | if id != vc.ChannelID { 91 | return true 92 | } 93 | 94 | // checks should all pass to confirm this is 95 | // the right voice channel 96 | 97 | refreshVoiceTreeNode(node, vc.GuildID, vc.ChannelID) 98 | return false 99 | }) 100 | } 101 | 102 | func refreshVoiceTreeNode(node *tview.TreeNode, guildID, channelID int64) { 103 | var ( 104 | nodes []*tview.TreeNode 105 | vcs = getVoiceChannel(guildID, channelID) 106 | ) 107 | 108 | for _, vc := range vcs { 109 | vcNode := generateVoiceNode(vc) 110 | if vcNode == nil { 111 | continue 112 | } 113 | 114 | nodes = append(nodes, vcNode) 115 | } 116 | 117 | node.SetChildren(nodes) 118 | } 119 | 120 | func generateVoiceNode(vc *discordgo.VoiceState) *tview.TreeNode { 121 | var color = "d" 122 | 123 | // Reserved for onSpeak 124 | //if !canIhearthem(vc) || vc.SelfDeaf || vc.Deaf { 125 | // color = "d" 126 | //} 127 | 128 | u, err := d.State.Member(vc.GuildID, vc.UserID) 129 | if err != nil { 130 | return nil 131 | } 132 | 133 | if u.User == nil { 134 | return nil 135 | } 136 | 137 | var name = u.User.Username 138 | if u.Nick != "" { 139 | name = u.Nick 140 | } 141 | 142 | var suffix string 143 | 144 | if vc.SelfMute || vc.Mute { 145 | suffix += " [gray][M[][-]" 146 | } 147 | 148 | if vc.SelfDeaf || vc.Deaf { 149 | suffix += " [gray][D[][-]" 150 | } 151 | 152 | if vc.Suppress { 153 | suffix += " [red][Suppressed[][-]" 154 | } 155 | 156 | vcNode := tview.NewTreeNode( 157 | "[::" + color + "]" + tview.Escape(name) + "[::-]" + suffix, 158 | ) 159 | 160 | vcNode.SetSelectable(false) 161 | vcNode.SetReference(vc.UserID) 162 | vcNode.SetIndent(cfg.Prop.SidebarIndent - 1) 163 | 164 | return vcNode 165 | } 166 | -------------------------------------------------------------------------------- /voiceaudio.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //import ( 4 | //"log" 5 | 6 | //"github.com/gordonklaus/portaudio" 7 | //"github.com/diamondburned/discordgo" 8 | //"gitlab.com/diamondburned/dgvoice" 9 | //) 10 | 11 | //var inVoice int64 12 | 13 | //func toggleVoiceJoin(chID int64) { 14 | //if inVoice != 0 { 15 | //d.GatewayManager.ChannelVoiceLeave(chID) 16 | //inVoice = 0 17 | //if chID != 0 { 18 | //Record(chID) 19 | //} 20 | //} else { 21 | //Record(chID) 22 | //} 23 | //} 24 | 25 | //// Record .. 26 | //func Record(chID int64) { 27 | //c, err := d.State.Channel(chID) 28 | //if err != nil { 29 | //Warn("Voice error: " + err.Error()) 30 | //} 31 | 32 | //dgv, err := d.GatewayManager.ChannelVoiceJoin( 33 | //c.GuildID, c.ID, true, false, 34 | //) 35 | 36 | //if err != nil { 37 | //Warn(err.Error()) 38 | //return 39 | //} 40 | 41 | //inVoice = chID 42 | 43 | //portaudio.Initialize() 44 | //defer portaudio.Terminate() 45 | 46 | //out := make([]int16, 960) 47 | 48 | //stream, err := portaudio.OpenDefaultStream(0, 2, 48000, len(out), &out) 49 | //if err != nil { 50 | //Warn(err.Error()) 51 | //return 52 | //} 53 | 54 | //defer stream.Close() 55 | 56 | //if err := stream.Start(); err != nil { 57 | //Warn(err.Error()) 58 | //return 59 | //} 60 | 61 | //defer stream.Stop() 62 | 63 | //recv := make(chan *discordgo.Packet, 2) 64 | //go dgvoice.ReceivePCM(dgv, recv) 65 | 66 | //for { 67 | //p, ok := <-recv 68 | //if !ok { 69 | //return 70 | //} 71 | 72 | //log.Println(len(p.PCM)) 73 | 74 | //copy(out, p.PCM) 75 | 76 | //if err := stream.Write(); err != nil { 77 | //Warn(err.Error()) 78 | //break 79 | //} 80 | //} 81 | 82 | ////portaudio.Initialize() 83 | ////defer portaudio.Terminate() 84 | 85 | ////in := make([]int32, 64) 86 | ////stream, err := portaudio.OpenDefaultStream(1, 0, 44100, len(in), in) 87 | ////if (err) != nil { 88 | ////Warn(err.Error()) 89 | ////return 90 | ////} 91 | 92 | ////defer stream.Close() 93 | 94 | ////if err := stream.Start(); err != nil { 95 | ////Warn(err.Error()) 96 | ////return 97 | ////} 98 | 99 | ////for { 100 | ////if err := stream.Read(); err != nil { 101 | ////Warn(err.Error()) 102 | ////return 103 | ////} 104 | 105 | ////if err := binary.Write(f, binary.BigEndian, in); err != nil { 106 | ////Warn(err.Error()) 107 | ////return 108 | ////} 109 | 110 | ////nSamples += len(in) 111 | ////select { 112 | ////case <-sig: 113 | ////return 114 | ////default: 115 | ////} 116 | ////} 117 | ////chk(stream.Stop()) 118 | //} 119 | -------------------------------------------------------------------------------- /w3m/locate.go: -------------------------------------------------------------------------------- 1 | package w3m 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // Thanks dylan 10 | var ( 11 | paths = []string{ 12 | "/usr/local/lib/w3m/w3mi*", 13 | "/usr/local/libexec/w3m/w3mi*", 14 | "/usr/local/lib64/w3m/w3mi*", 15 | "/usr/local/libexec64/w3m/w3mi*", 16 | "/usr/lib/w3m/w3mimgdisplay", 17 | "/usr/libexec/w3m/w3mi*", 18 | "/usr/lib64/w3m/w3mimgdisplay", 19 | "/usr/libexec64/w3m/w3mi*", 20 | } 21 | ) 22 | 23 | // GetExecPath finds w3mimgdisplay 24 | func GetExecPath() string { 25 | // Todo: find a more performant way to do this 26 | for _, p := range paths { 27 | m, err := filepath.Glob(p) 28 | if err != nil { 29 | log.Println(err) 30 | continue 31 | } 32 | 33 | for _, path := range m { 34 | info, err := os.Stat(path) 35 | if err != nil { 36 | log.Println(err) 37 | continue 38 | } 39 | 40 | if info.Mode()&0111 != 0 { 41 | return path 42 | } 43 | } 44 | } 45 | 46 | return "" 47 | } 48 | -------------------------------------------------------------------------------- /w3m/w3m.go: -------------------------------------------------------------------------------- 1 | package w3m 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | w3mpath = GetExecPath() 12 | 13 | // ErrNotFound is returned when w3m can't be found 14 | ErrNotFound = errors.New("w3m not found") 15 | ) 16 | 17 | // Arguments is the struct for w3m arguments 18 | // All fields are required 19 | type Arguments struct { 20 | Xoffset int // default: 0 21 | Yoffset int // default: 0 22 | Width int 23 | Height int 24 | Filename string 25 | } 26 | 27 | func Spawn(a Arguments) error { 28 | if w3mpath == "" { 29 | return ErrNotFound 30 | } 31 | 32 | var ( 33 | cmd = exec.Command(w3mpath) 34 | args = fmt.Sprintf( 35 | "0;1;%d;%d;%d;%d;;;;;%s\n3;\n4\n", 36 | a.Xoffset, a.Yoffset, 37 | a.Width, a.Height, 38 | a.Filename, 39 | ) 40 | ) 41 | 42 | reader := strings.NewReader(args) 43 | 44 | cmd.Stdin = reader 45 | 46 | cmd.Run() 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /warn.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "runtime" 7 | 8 | "github.com/diamondburned/tview/v2" 9 | ) 10 | 11 | // Warn .. 12 | func Warn(c string) { 13 | var content string 14 | 15 | { 16 | _, fn, line, ok := runtime.Caller(1) 17 | if ok { 18 | content += fmt.Sprintf("%s:%d ->", fn, line) 19 | } 20 | } 21 | 22 | { 23 | _, fn, line, ok := runtime.Caller(2) 24 | if ok { 25 | content += fmt.Sprintf(" %s:%d -> ", fn, line) 26 | } 27 | } 28 | 29 | log.Println(content + c) 30 | 31 | modal := tview.NewModal() 32 | modal.AddButtons([]string{"mkay"}) 33 | modal.SetText(c) 34 | modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { 35 | if buttonLabel == "mkay" { 36 | app.SetRoot(appflex, true) 37 | app.SetFocus(input) 38 | } 39 | }) 40 | 41 | app.SetRoot(modal, false) 42 | app.SetFocus(modal) 43 | app.Draw() 44 | } 45 | 46 | // Message prints a system message 47 | func Message(m string) { 48 | messageRender <- m 49 | } 50 | -------------------------------------------------------------------------------- /wraplines.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/diamondburned/tview/v2" 7 | "gitlab.com/diamondburned/6cord/md" 8 | ) 9 | 10 | // 2nd arg ::- 11 | // 3rd arg -:: 12 | func splitEmbedLine(e string, customMarkup ...string) (spl []string) { 13 | lines := strings.Split(e, "\n") 14 | 15 | // Todo: clean this up ETA never 16 | 17 | var ( 18 | cm = "" 19 | ce = "" 20 | ) 21 | 22 | if len(customMarkup) > 0 { 23 | cm = customMarkup[0] 24 | ce = "[::-]" 25 | } 26 | 27 | if len(customMarkup) > 1 { 28 | cm += customMarkup[1] 29 | ce += "[-::]" 30 | } 31 | 32 | _, _, col, _ := messagesView.GetInnerRect() 33 | 34 | for _, l := range lines { 35 | splwrap := strings.Split( 36 | md.Parse(tview.Escape(strings.Join( 37 | tview.WordWrap(l, min(col-5, 100)), 38 | "\n", 39 | ))), 40 | "\n", 41 | ) 42 | 43 | for _, s := range splwrap { 44 | spl = append(spl, cm+s+ce) 45 | } 46 | } 47 | 48 | return 49 | } 50 | --------------------------------------------------------------------------------