├── .travis.yml ├── spec ├── spec_helper.cr └── crcophony_spec.cr ├── .env.sample.bash ├── .env.sample.fish ├── .gitignore ├── .editorconfig ├── shard.yml ├── src ├── noir_link │ ├── setup.cr │ └── formatter.cr ├── elements │ ├── searcher.cr │ ├── message_prompt.cr │ └── channel_list.cr ├── config.cr ├── progress_bar.cr ├── crcophony.cr ├── channel.cr ├── message_parser.cr └── application.cr ├── shard.lock ├── PKGBUILD ├── LICENSE ├── CHANGELOG.md └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/crcophony" 3 | -------------------------------------------------------------------------------- /.env.sample.bash: -------------------------------------------------------------------------------- 1 | # Sample environment file for use in the bash shell 2 | 3 | # token 4 | export CRCOPHONY_TOKEN="" 5 | # user id 6 | export CRCOPHONY_USER_ID="" 7 | -------------------------------------------------------------------------------- /.env.sample.fish: -------------------------------------------------------------------------------- 1 | # Sample environment file for use in the fish shell 2 | 3 | # token 4 | set -x CRCOPHONY_TOKEN "" 5 | # user id 6 | set -x CRCOPHONY_USER_ID "" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | *.log 7 | .env 8 | *.tar.gz 9 | 10 | # makepkg stuff 11 | crcophony-git/ 12 | src/crcophony-git/ 13 | -------------------------------------------------------------------------------- /spec/crcophony_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Crcophony do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crcophony 2 | version: 0.7.1 3 | 4 | authors: 5 | - freyamade 6 | 7 | targets: 8 | crcophony: 9 | main: src/crcophony.cr 10 | 11 | dependencies: 12 | discordcr: 13 | github: freyamade/discordcr 14 | branch: bugfix/channel-icon-type 15 | hydra: 16 | github: freyamade/hydra 17 | branch: master 18 | noir: 19 | github: freyamade/noir 20 | branch: master 21 | notify: 22 | github: woodruffw/notify.cr 23 | branch: master 24 | 25 | crystal: 0.31.0 26 | 27 | license: MIT 28 | -------------------------------------------------------------------------------- /src/noir_link/setup.cr: -------------------------------------------------------------------------------- 1 | # File that properly sets up noir to lex whatever we can 2 | require "noir" 3 | # Have to import all lexers manually for some reason... 4 | require "noir/lexers/crystal" 5 | require "noir/lexers/css" 6 | require "noir/lexers/html" 7 | require "noir/lexers/javascript" 8 | require "noir/lexers/json" 9 | require "noir/lexers/python" 10 | require "noir/lexers/ruby" 11 | 12 | # Also import the solarized themes 13 | require "noir/themes/monokai" 14 | require "noir/themes/solarized" 15 | 16 | # Also require the custom formatter that links Noir back to Hydra 17 | require "./formatter" 18 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | shards: 3 | dbus: 4 | github: oprypin/crystal-dbus 5 | commit: dd744b951dc2bc12399ac518f3a042965f64392b 6 | 7 | discordcr: 8 | github: freyamade/discordcr 9 | commit: 9a4029b312fd0d1205afc09d6faa34dc6796e29c 10 | 11 | hydra: 12 | github: freyamade/hydra 13 | commit: df8a6b0578eae16cd78aea32e733266aa320d008 14 | 15 | noir: 16 | github: freyamade/noir 17 | commit: 87e77d739d3bbcc42cd7570366010988cf4e16a7 18 | 19 | notify: 20 | github: woodruffw/notify.cr 21 | commit: c1902634ce60acbd13cb445d441b3ca32631021e 22 | 23 | termbox: 24 | github: andrewsuzuki/termbox-crystal 25 | commit: 9471d1f1852dec251d6bcb6f8a583e7abdf04f68 26 | 27 | -------------------------------------------------------------------------------- /src/elements/searcher.cr: -------------------------------------------------------------------------------- 1 | require "hydra" 2 | 3 | module Crcophony 4 | # Extension of the Hydra::Prompt class that binds to and searches a channel list 5 | class Searcher < Hydra::Prompt 6 | @channel_list : Crcophony::ChannelList 7 | 8 | def initialize(@channel_list, id : String, options = Hash(Symbol, String).new) 9 | super id, options 10 | end 11 | 12 | # Overwrite some methods 13 | def append(string : String) 14 | super string 15 | # Call the search method on the channel list instance 16 | @channel_list.search @value 17 | end 18 | 19 | def remove_last 20 | super 21 | @channel_list.search @value 22 | end 23 | 24 | # When the prompt is cleared, also clear the search value on the list 25 | def clear 26 | super 27 | @channel_list.search "" 28 | end 29 | 30 | def hide 31 | super 32 | # Clear the input 33 | clear 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: freyamade 2 | 3 | pkgname=crcophony-git 4 | pkgver=0.7.0 5 | pkgrel=1 6 | pkgdesc='Fast, neat discord TUI written in Crystal' 7 | arch=('x86_64') 8 | url='https://github.com/freyamade/crcophony' 9 | license=('MIT') 10 | md5sums=('SKIP') 11 | depends=('dbus' 'termbox-git') 12 | makedepends=('git' 'crystal>=0.31.0' 'shards') 13 | source=("$pkgname::git+https://github.com/freyamade/crcophony.git") 14 | provides=('crcophony') 15 | 16 | pkgver() { 17 | cd "$pkgname" 18 | printf "%s" "$(git rev-parse --short HEAD)" 19 | } 20 | 21 | build() { 22 | cd "$srcdir/$pkgname" 23 | shards build crcophony --release --progress -Dpreview_mt 24 | } 25 | 26 | package() { 27 | mkdir -p "$pkgdir/usr/bin/" 28 | mkdir -p "$pkgdir/opt" 29 | printf "#!/bin/bash\n/opt/crcophony" > "$pkgdir/usr/bin/crcophony" 30 | chmod +x "$pkgdir/usr/bin/crcophony" 31 | mv "$srcdir/$pkgname/bin/crcophony" "$pkgdir/opt/crcophony" 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 freyamade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | 3 | module Crcophony 4 | # Environment variable config loader class 5 | class Config 6 | # Env key names 7 | @@TOKEN_KEY = "CRCOPHONY_TOKEN" 8 | @@USER_ID_KEY = "CRCOPHONY_USER_ID" 9 | 10 | # Config variables 11 | @token : String 12 | @user_id : UInt64 13 | 14 | # The user token used to log in 15 | getter token 16 | # The user id (isn't necessary to specify initially so I might get rid of this requirement) 17 | getter user_id 18 | 19 | # Attempt to load configuration from the environment 20 | def initialize 21 | # User token 22 | if ENV[@@TOKEN_KEY]? 23 | @token = ENV[@@TOKEN_KEY] 24 | else 25 | puts "Crcophony Config Error: Could not load `#{@@TOKEN_KEY}`. Have you set the environment variable?".colorize :red 26 | Process.exit 1 27 | end 28 | 29 | # User ID 30 | if ENV[@@USER_ID_KEY]? 31 | user_id = ENV[@@USER_ID_KEY] 32 | if user_id.to_u64? 33 | @user_id = user_id.to_u64 34 | else 35 | puts "Crcophony Config Error: Could not load `#{@@USER_ID_KEY}` as it is not a valid 64 bit unsigned integer.".colorize :red 36 | Process.exit 1 37 | end 38 | else 39 | puts "Crcophony Config Error: Could not load `#{@@USER_ID_KEY}`. Have you set the environment variable?".colorize :red 40 | Process.exit 1 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/noir_link/formatter.cr: -------------------------------------------------------------------------------- 1 | require "hydra" 2 | require "noir/formatter" 3 | require "noir/theme" 4 | 5 | module Crcophony 6 | class SyntaxFormatter < Noir::Formatter 7 | def initialize(@theme : Noir::Theme, @out : IO) 8 | end 9 | 10 | def format(token, value) : Nil 11 | if token 12 | style = @theme.style_for token 13 | else 14 | style = @theme.base_style 15 | end 16 | 17 | value.each_line(chomp: false) do |line| 18 | wrote = false 19 | color_name = "" 20 | if c = style.fore 21 | color_int = "#{c.red.to_s 16}#{c.green.to_s 16}#{c.blue.to_s 16}".to_u32 16 22 | color_name = Hydra::Color.new(color_int).name 23 | @out << "<#{color_name}-fg>" 24 | wrote = true 25 | end 26 | 27 | # These aren't handled by Hydra yet 28 | # if style.bold 29 | # @out << "\e[" unless wrote 30 | # @out << ";" if wrote 31 | # @out << "1" 32 | # wrote = true 33 | # end 34 | 35 | # if style.italic 36 | # @out << "\e[" unless wrote 37 | # @out << ";" if wrote 38 | # @out << "3" 39 | # wrote = true 40 | # end 41 | 42 | # if style.underline 43 | # @out << "\e[" unless wrote 44 | # @out << ";" if wrote 45 | # @out << "4" 46 | # wrote = true 47 | # end 48 | 49 | @out << line.chomp 50 | @out << "" 51 | @out.puts if line.ends_with?("\n") 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /src/progress_bar.cr: -------------------------------------------------------------------------------- 1 | module Crcophony 2 | # A simple terminal progress bar to render progress bars when loading things 3 | class ProgressBar 4 | # The final value 5 | @max_value : Int32 6 | # The number of percent completion each `=` is worth 7 | @percent_per_symbol : Float64 = 5.0 8 | # The symbol used to render the progress bar 9 | @symbol = "=" 10 | # The current value 11 | @value : Int32 = 0 12 | # The width of the bar, in character (default 40) 13 | @width : Int32 = 20 14 | 15 | def initialize(@max_value : Int32) 16 | end 17 | 18 | def initialize(@max_value : Int32, @symbol : String) 19 | end 20 | 21 | def initialize(@max_value : Int32, @width : Int32) 22 | @percent_per_symbol = 100.0 / @width 23 | end 24 | 25 | def initialize(@max_value : Int32, @width : Int32, @symbol : String) 26 | @percent_per_symbol = 100.0 / @width 27 | end 28 | 29 | # Increment the current value 30 | def tick 31 | @value += 1 32 | end 33 | 34 | # Render the progress bar, including the \r character that will return to the start of the line 35 | def to_s 36 | # First calculate the percentage progress that has been made 37 | progress = (@value * 100) / @max_value 38 | # Progress bar will be @width characters long, so determine how symbols to display 39 | symbols = (progress / @percent_per_symbol).to_i 40 | # Generate the bar first, left justified to the length of the bar 41 | bar = (@symbol * symbols).ljust @width 42 | # Generate the string 43 | return "\r[#{bar}] [#{@value} / #{@max_value}]" 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /src/crcophony.cr: -------------------------------------------------------------------------------- 1 | require "discordcr" 2 | require "hydra" 3 | require "logger" 4 | require "./*" 5 | 6 | module Crcophony 7 | VERSION = "0.7.1" 8 | 9 | # Load config from the environment 10 | config = Crcophony::Config.new 11 | 12 | # Set up Discord 13 | client = Discord::Client.new token: config.token, client_id: config.user_id, logger: Logger.new(nil) 14 | cache = Discord::Cache.new(client) 15 | client.cache = cache 16 | 17 | # Retrieve the list of guilds and channels out here, so we can process them and display progress before opening the application 18 | puts "Loading Server Data" 19 | user_guilds = client.get_current_user_guilds 20 | progress = ProgressBar.new user_guilds.size, 40 21 | print progress.to_s 22 | user_guilds.each.with_index do |user_guild, index| 23 | # Fetch the proper guild object for the channel 24 | guild = cache.resolve_guild user_guild.id 25 | # Fetch the channels for the guild 26 | client.get_guild_channels(guild.id).each do |channel| 27 | # Ignore non text channels 28 | next unless channel.type.guild_text? 29 | # Add channels to the cache to pull them out later 30 | cache.cache channel 31 | end 32 | # Fetch roles (no way to fetch single roles, so just fetch them all) 33 | client.get_guild_roles(user_guild.id).each do |role| 34 | cache.cache role 35 | end 36 | 37 | # Update the progress 38 | progress.tick 39 | print progress.to_s 40 | end 41 | # Lading DMs and Group Chats 42 | puts "\nLoading DMs / Group Chats" 43 | private_channels = client.get_user_dms 44 | progress = ProgressBar.new private_channels.size, 40 45 | print progress.to_s 46 | private_channels.each.with_index do |dm, index| 47 | # Resolve the channel 48 | cache.resolve_channel dm.id 49 | 50 | # Update the progress 51 | progress.tick 52 | print progress.to_s 53 | end 54 | 55 | # Create a Crcophony Application instance 56 | app = Crcophony::Application.new client 57 | 58 | # Add message handling to the Discord bot 59 | client.on_message_create do |payload| 60 | app.handle_message payload 61 | end 62 | 63 | # start the client in a separate thread 64 | spawn do 65 | client.run 66 | end 67 | 68 | begin 69 | app.run # => Screen is cleared and the application is displayed 70 | # The application will loop until ctrl-c is pressed or an exception is raised 71 | app.teardown # => Reset the screen 72 | rescue ex 73 | app.teardown # Teardown first to print stuff after 74 | puts ex.inspect_with_backtrace 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /src/elements/message_prompt.cr: -------------------------------------------------------------------------------- 1 | # An extension of Hydra::Prompt that handles multi-line input as nicely as possible by expanding the box as lines are added 2 | require "hydra" 3 | 4 | module Crcophony 5 | # Extension of the Hydra::Prompt class that binds to and searches a channel list 6 | class MessagePrompt < Hydra::Prompt 7 | @messages : Hydra::Logbox 8 | @initial_height : Int32 = 0 9 | @messages_initial_height : Int32 = 0 10 | @initial_position : String = "" 11 | 12 | def initialize(@messages, id : String, options = Hash(Symbol, String).new) 13 | super id, options 14 | @messages_initial_height = @messages.height 15 | end 16 | 17 | private def box_content(content) 18 | content = content.insert @cursor_position, '|' 19 | if content.size > (width - 2) 20 | content = "…" + content[-(width - 3)..-1] 21 | end 22 | top_bar = "─" + @label.ljust(width - 3, '─') 23 | res = "┌" + top_bar + "┐\n" 24 | content.split("\n").each do |line| 25 | res += "│" + Hydra::ExtendedString.escape(line.ljust(width - 2)) + "│\n" 26 | end 27 | res += "└" + "─" * (width - 2) + "┘" 28 | res 29 | end 30 | 31 | # Method called when the element is registered 32 | def on_register(event_hub : Hydra::EventHub) 33 | @initial_height = @height 34 | @initial_position = @position 35 | super 36 | end 37 | 38 | # Define methods for increasing and decreasing the size of the prompt 39 | def increase_size 40 | # Increase height by 1, move position up one and decrease the messages height by one 41 | @messages.height -= 1 42 | @height += 1 43 | x, y = @position.split(":").map(&.to_i) 44 | @position = "#{x - 1}:#{y}" 45 | end 46 | 47 | def decrease_size 48 | # Decrease height by 1, move position down one and increase the messages height by one 49 | @messages.height += 1 50 | @height -= 1 51 | x, y = @position.split(":").map(&.to_i) 52 | @position = "#{x + 1}:#{y}" 53 | end 54 | 55 | # Override methods that interact with the text to control the height 56 | def append(string : String) 57 | super string 58 | # If we've added a newline, increase the height of the prompt 59 | if (@value.count('\n') + @initial_height) > @height 60 | increase_size 61 | end 62 | end 63 | 64 | def remove_character_at!(position : Int32) 65 | super position 66 | # If we've removed a newline, decrease the height of the prompt 67 | if (@value.count('\n') + @initial_height) < @height 68 | decrease_size 69 | end 70 | end 71 | 72 | def clear 73 | super 74 | # Reset all positions and heights 75 | @position = @initial_position 76 | @height = @initial_height 77 | @messages.height = @messages_initial_height 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | One stop shop for the updates made to the project with each version, and to check master's status against the latest release 4 | 5 | ## 0.7.1 - Latest Release 6 | - Fixing issues with username colours 7 | - System used to just take the topmost role for colours 8 | - Now takes the topmost role with a colour, to match how the GUI client works 9 | - Implemented a small form of channel message caching to improve performance 10 | 11 | ## 0.7.0 12 | - Updated to Crystal 0.31 13 | - Crcophony now requires the multithreading preview to be enabled to work properly 14 | - Notifications are back in master 15 | 16 | ## 0.6.0 17 | - Added multi line input that expands the prompt box accordingly (Ctrl+N to add a new line) 18 | - Fixed bug where characters always get added to the end of a prompt even when the cursor is moved elsewhere (requires `shards update`) 19 | 20 | ## 0.5.0 21 | - Changed loading messages to use a progress bar 22 | - Added parsing of code blocks 23 | - Added syntax highlighting using the [noir library](https://github.com/MakeNowJust/noir) 24 | - Currently, the lib only supports the following languages, and as such these are the only languages that will be highlighted 25 | - crystal 26 | - css 27 | - html 28 | - javascript 29 | - json 30 | - python 31 | - ruby 32 | - I'm currently trying to talk to the maintainer of the lib to allow me to add more lexers so this list should hopefully grow soon 33 | - Fixed bug where colouring text would interfere with the text wrapping process 34 | - Fixed bug where a message containing just an image would have a blank line 35 | - Added keybinds for up and down arrows to scroll through messages / channels 36 | 37 | ## 0.4.0 38 | - Removed duplicate colour names so all 256 colours are available 39 | - Now renders the timestamp at the right hand side of the screen, similar to some shell themes 40 | - Colour the title of embeds based on the colour that they are in the normal client 41 | - Added handling for Direct Messages and Group Chats 42 | 43 | ## 0.3.0 44 | - Usernames now have colours 45 | - Powered by 256 colour terminals. No idea what will happen if you run the system on a system with less colours. 46 | 47 | ## 0.2.1 48 | - Fixed rendering issue regarding embeds with multi line descriptions 49 | - Fixed major issue regarding the application taking a lot of CPU usage to just run idly 50 | 51 | ## 0.2.0 52 | - Fixed issue with parts of messages being removed during the text wrapping process 53 | - Fixed bug that caused channel names to appear twice in the switcher with no search, when your previous channel also has notifications 54 | - Slightly improved channel searching algorithm 55 | - Searcher currently only searches through channel names, doesn't include server names to avoid issues 56 | - Uses an improved algorithm that scores channel names instead of using basic levenshtein ratios 57 | - Handling of attachments 58 | - Attachments are now displayed as links below the message body 59 | - Handling of embeds 60 | - Embeds are now rendered in text form below the message body, and below any attachments 61 | 62 | ## 0.1.0 63 | - Currently this application only supports server channels. DMs and Group Chats will come later. 64 | - Mentions are parsed back into usernames, and any mention of the connected user will show up in yellow. 65 | - Loading channel history when a channel is changed to (this can and will be improved). 66 | - Long messages are wrapped. 67 | - Unread messages are kept track of per channel, and a total number can be found at the top right corner. 68 | - Channel Switching that behaves somewhat similarly to Discord's client 69 | - Without providing search text, it will display the previously visited channel and channels that have notifications 70 | - Typing search text will filter channels based on Levenshtein ratios 71 | - The algorithm could be improved somewhat however 72 | -------------------------------------------------------------------------------- /src/channel.cr: -------------------------------------------------------------------------------- 1 | require "discordcr" 2 | 3 | module Crcophony 4 | # A wrapper class around Discord's channel class that makes things a little easier in this project 5 | class Channel 6 | # The Discord equivalent for this channel 7 | @channel : Discord::Channel 8 | # A flag stating whether or not the initial load for the channel has been done 9 | @loaded : Bool = false 10 | # Keep an array of messages sent in the channel 11 | @messages : Array(Discord::Message) 12 | # The Discord Server this channel is attached to, if any 13 | @server : Discord::Guild? 14 | # Remember the last search string that we calculated 15 | @prev_search_string : String = "" 16 | # And the score that was calculated 17 | @prev_score : Int32 = 0 18 | # Maintain a count of how many messages we haven't read (can use this for later) 19 | @unread_messages : UInt64 = 0_u64 20 | 21 | # Private Channels do not have guild ids 22 | def initialize(@channel : Discord::Channel) 23 | @messages = [] of Discord::Message 24 | end 25 | 26 | def initialize(@channel : Discord::Channel, @server : Discord::Guild) 27 | @messages = [] of Discord::Message 28 | end 29 | 30 | def <<(message : Discord::Message) 31 | @messages << message 32 | end 33 | 34 | def to_s : String 35 | builder = String::Builder.new 36 | if !@server.nil? 37 | builder << "#{@server.not_nil!.name}##{@channel.name}" 38 | else 39 | # Generate the name for the DM 40 | if @channel.name 41 | builder << @channel.name 42 | else 43 | names = [] of String 44 | @channel.recipients.not_nil!.each do |user| 45 | names << user.username 46 | end 47 | name = names.join ", " 48 | # Cache the name in the channel 49 | @channel.name = name 50 | builder << name 51 | end 52 | end 53 | if @unread_messages > 0 54 | builder << " [#{@unread_messages}]" 55 | end 56 | return builder.to_s 57 | end 58 | 59 | def id 60 | return @channel.id 61 | end 62 | 63 | def guild_id 64 | if @server.nil? 65 | return nil 66 | end 67 | return @server.not_nil!.id 68 | end 69 | 70 | # Given a search string, calculate a score based on the following table. 71 | # 72 | # - Score starts at 0 73 | # - Matched letter: +0 points 74 | # - Unmatched letter: -1 point 75 | # - Consecutive match bonus: +5 points 76 | # - Unmatched characters from search string: -3 per character 77 | def match_score(search_string : String) : Int32 78 | if search_string == @prev_search_string 79 | return @prev_score 80 | end 81 | # Calculate a score for this channel name 82 | score = 0 83 | name : String 84 | if @server 85 | name = self.to_s.split("#")[1].downcase 86 | else 87 | name = self.to_s.downcase 88 | end 89 | # Index into this channel's name 90 | name_index = 0 91 | # Index into the search_string 92 | search_index = 0 93 | # Keep track of the position of the previous find 94 | prev_matched = false 95 | while name_index < name.size && search_index < search_string.size 96 | if name[name_index] == search_string[search_index] 97 | # Check the previous found index 98 | if prev_matched 99 | score += 5 100 | end 101 | prev_matched = true 102 | search_index += 1 103 | else 104 | # We didn't find one, subtract one from the score 105 | score -= 1 106 | prev_matched = false 107 | end 108 | name_index += 1 109 | end 110 | while search_index < search_string.size 111 | # Subtract 3 from the score for all missed search characters 112 | score -= 3 113 | search_index += 1 114 | end 115 | # Cache stuff 116 | @prev_search_string = search_string 117 | @prev_score = score 118 | # Just return the score 119 | return score 120 | end 121 | 122 | # Keep track of whether or not we have done an inital load of messages for this channel 123 | property loaded 124 | # The cached messages 125 | getter messages 126 | # The number of unread messages in the channel (used only in the switcher) 127 | property unread_messages 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crcophony */kəˈkɒf(ə)ni/* 2 | *read: cacophony* 3 | 4 | [![release badge](https://img.shields.io/github/tag-date/freyamade/crcophony.svg?label=version&style=flat-square)](https://github.com/freyamade/crcophony/releases/latest) 5 | 6 | A simple Discord Terminal UI written in Crystal. 7 | 8 | ## WARNING 9 | Self-bots are not allowed by Discord's Terms of Service. 10 | Using crcophony could technically count as using a self-bot. 11 | Use this project at your own risk, it isn't my fault if it gets you banned. 12 | 13 | That being said, I'm trying my best to ensure the application is as safe as possible. 14 | You cannot do anything in crcophony that you can't do in the normal Discord client (in fact, there are things you can do in the Discord client that you can't do in crcophony) so it *should* be okay. 15 | 16 | Bottom line: ***Use at your own risk*** 17 | 18 | ## Keybinds 19 | ### Normal 20 | - Ctrl+C: Quit Application 21 | - Enter: Send Message 22 | - Ctrl+W / Up: Scroll Up 23 | - Ctrl+S / Down: Scroll Down 24 | - Ctrl+N: Add line break to message input 25 | 26 | ### Channel Switching 27 | - Ctrl+K: Open / Close Channel Selection Menu 28 | - Enter: Select Channel 29 | - Ctrl+W / Up: Scroll Selection Up 30 | - Ctrl+S / Down: Scroll Selection Down 31 | - ESC: Alternative Close Button 32 | 33 | ## Installation 34 | 35 | ### PKGBUILD 36 | If you use Arch Linux or any similar variant, then there's a PKGBUILD in the repo. 37 | I haven't published this project to the AUR yet but I intend to at some stage. 38 | 39 | ### From source 40 | If you're not on Arch, currently the only way is to install from source. 41 | 42 | #### Install requirements 43 | The requirements for the application are as follows; 44 | - [`crystal>=0.31.0`](https://crystal-lang.org/reference/installation/) 45 | - [`termbox`](https://github.com/nsf/termbox) 46 | - 'libdbus' 47 | 48 | #### Build 49 | 1. Clone this repo 50 | 2. Run `shards install` and then `shards build --release -Dpreview_mt` to install all the requirements and build the application. 51 | - This will create an executable in the `bin` folder local to the cloned repo, which can then be moved wherever it needs to be moved. 52 | 53 | ## Usage 54 | Before you can run Crcophony, you need to gather a bit of data. 55 | 56 | ### Gathering Data 57 | To use the system, you must gather the following information and export the data as environment variables. 58 | These variables are as follows; 59 | 60 | - `CRCOPHONY_TOKEN`: Your user token used to authenticate yourself with the client 61 | - `CRCOPHONY_USER_ID`: Your user id (might not be necessary, requires investigation and could be removed at a later point) 62 | 63 | Here are the instructions for you to get these bits of data; 64 | 1. Turn on [Developer Mode](https://discordia.me/developer-mode) 65 | 3. To get the `user_id`, right click on your own name in the Users sidebar of any channel and click "Copy ID". This is the value you should put in as the `user_id` 66 | 4. Follow [this guide](https://discordhelp.net/discord-token) to get your token. 67 | 68 | If you use the `fish` or `bash` shells, a sample `.env` file has been included in this project (`.env.sample.fish` and `env.sample.bash` respectively). 69 | Simply rename the appropriate file to `.env`, populate the strings inside with your gathered data and run `source .env` in the directory to get the correct environment variables created. 70 | 71 | ### Running the Application 72 | After the environment variables are defined, simply run the crcophony executable. 73 | 74 | ### NOTE 75 | As far as I am currently aware, placing `crcophony` in a bin folder and running it as `crcophony` does not work when attempting to spawn threads. 76 | This is because Crystal tries to spawn by reading the file passed in as the command following it like a path from your current directory. 77 | The workaround I currently use is creating a small bash script that runs crcophony using an absolute path, and placing this executable script in your bin folder. 78 | 79 | ## Contributing 80 | 81 | 1. Fork it (https://github.com/freyamade/crcophony/fork) 82 | 2. Create your feature branch (git checkout -b my-new-feature) 83 | 3. Commit your changes (git commit -am 'Add some feature') 84 | 4. Push to the branch (git push origin my-new-feature) 85 | 5. Create a new Pull Request 86 | 87 | Contributors 88 | 89 | - freyamade - creator, maintainer 90 | -------------------------------------------------------------------------------- /src/elements/channel_list.cr: -------------------------------------------------------------------------------- 1 | require "levenshtein" 2 | require "discordcr" 3 | require "hydra" 4 | 5 | module Crcophony 6 | # A class for maintaing a list of channels the user has access to 7 | # Set up to allow for the switching of channels within the application 8 | class ChannelList < Hydra::List 9 | # Array of channels in the system, not all will be displayed 10 | @channels : Array(Crcophony::Channel) 11 | # Keep track of unread messages from all channels here 12 | @unread_messages : UInt64 = 0 13 | # Search string used to filter channel names 14 | @search_string : String = "" 15 | # Maintain information on the previous channel visited 16 | @prev_channel : Crcophony::Channel? 17 | # Remember the currently filtered channels 18 | @filtered_channels : Array(Crcophony::Channel) = [] of Crcophony::Channel 19 | 20 | getter unread_messages 21 | setter prev_channel 22 | 23 | def initialize(client : Discord::Client, id : String, options = Hash(Symbol, String).new) 24 | super id, options 25 | @channels = get_channels client 26 | select_first 27 | end 28 | 29 | # Retrieve a list of channels 30 | private def get_channels(client : Discord::Client) : Array(Crcophony::Channel) 31 | channels = [] of Crcophony::Channel 32 | cache = client.cache.not_nil! 33 | 34 | # Loop through the channels in the cache, resolving the guild from the cache also and adding them to the array 35 | cache.channels.each do |_, channel| 36 | if channel.guild_id 37 | guild = cache.resolve_guild channel.guild_id.not_nil! 38 | channels << Crcophony::Channel.new channel, guild 39 | else 40 | channels << Crcophony::Channel.new channel 41 | end 42 | end 43 | 44 | return channels 45 | end 46 | 47 | # Get the currently selected channel 48 | def get_channel : Crcophony::Channel 49 | if @filtered_channels.size > 0 50 | return @filtered_channels[@selected] 51 | else 52 | return @channels[@selected] 53 | end 54 | end 55 | 56 | # Get a channel by its ID 57 | def get_channel(id : Discord::Snowflake) : Crcophony::Channel? 58 | @channels.each do |channel| 59 | if channel.id == id 60 | return channel 61 | end 62 | end 63 | return nil 64 | end 65 | 66 | # Get the name of the channel given an id 67 | def get_channel_name(channel_id : Discord::Snowflake | UInt64) : String 68 | id = channel_id.to_u64 69 | @channels.each do |channel| 70 | if channel.id.to_u64 == id 71 | return channel.to_s 72 | end 73 | end 74 | return "" 75 | end 76 | 77 | # Search through the list of channels, using levenshtein similarity for fuzzy searching 78 | def search(string : String) 79 | @search_string = string 80 | end 81 | 82 | def add_unread(channel : Crcophony::Channel) 83 | @unread_messages += 1 84 | channel.unread_messages += 1 85 | end 86 | 87 | # Reset the number of unread messages on the current channel to 0, and update the list's total unreads also 88 | def reset_current_notifications(channel : Crcophony::Channel) 89 | unread_messages = channel.unread_messages 90 | channel.unread_messages = 0_u64 91 | @unread_messages -= unread_messages 92 | end 93 | 94 | def content : Hydra::ExtendedString 95 | lower_bound = @scroll_index * -1 96 | upper_bound = lower_bound + inner_height - 1 97 | items = Array(Hydra::ExtendedString).new 98 | @filtered_channels = filter_channels 99 | @filtered_channels[lower_bound..upper_bound].each_with_index do |item, index| 100 | if index - @scroll_index == @selected 101 | items << Hydra::ExtendedString.new "#{item.to_s}" 102 | else 103 | items << Hydra::ExtendedString.new item.to_s 104 | end 105 | end 106 | res = add_box(items) 107 | Hydra::ExtendedString.new(res) 108 | end 109 | 110 | # Filter channels based on the current value of the search string 111 | def filter_channels : Array(Crcophony::Channel) 112 | if @search_string == "" 113 | # Return just the previously visited channel and any channel with notifs 114 | channels : Array(Crcophony::Channel) 115 | if @prev_channel.nil? 116 | channels = [] of Crcophony::Channel 117 | else 118 | channels = [@prev_channel.not_nil!] 119 | end 120 | @channels.each do |channel| 121 | # Ensure we don't duplicate the prev_channel 122 | if !@prev_channel.nil? && @prev_channel.not_nil!.id == channel.id 123 | next 124 | end 125 | if channel.unread_messages > 0 126 | channels << channel 127 | end 128 | end 129 | return channels 130 | else 131 | sorted = @channels.sort do |a, b| 132 | # Sort by scoring algorithms 133 | next b.match_score(@search_string) <=> a.match_score(@search_string) 134 | end 135 | return sorted[0..10] 136 | end 137 | end 138 | 139 | def add_item(item : Crcophony::Channel) 140 | @channels << item 141 | if @channels.size == 1 142 | select_first 143 | end 144 | end 145 | 146 | def select_first 147 | @selected = 0 148 | end 149 | 150 | def reset_selection 151 | @selected = 0 152 | @filtered_channels = [] of Crcophony::Channel 153 | @scroll_index = 0 154 | end 155 | 156 | def change_item(index, item : Crcophony::Channel) 157 | if @channels[index]? 158 | @channels[index] = item 159 | end 160 | end 161 | 162 | def scroll(value : Int32) 163 | @scroll_index += value 164 | end 165 | 166 | def name 167 | "channel_list" 168 | end 169 | 170 | def clear 171 | @channels.clear 172 | end 173 | 174 | def select_item(index : Int32) 175 | @selected = index 176 | end 177 | 178 | def value : String 179 | return "" if none_selected? 180 | @filtered_channels[@selected].to_s 181 | end 182 | 183 | def none_selected? : Bool 184 | @selected == NONE_SELECTED 185 | end 186 | 187 | def min_scroll_index 188 | inner_height - @filtered_channels.size 189 | end 190 | 191 | def can_select_up? : Bool 192 | @selected > 0 193 | end 194 | 195 | def can_select_down? : Bool 196 | @selected < @filtered_channels.size 197 | end 198 | 199 | def select_down 200 | select_item(@selected + 1) 201 | scroll(-1) if can_scroll_down? 202 | end 203 | 204 | def select_up 205 | select_item(@selected - 1) 206 | scroll(1) if can_scroll_up? 207 | end 208 | 209 | def can_scroll_up? : Bool 210 | return false if @filtered_channels.size <= inner_height 211 | @scroll_index < 0 212 | end 213 | 214 | def can_scroll_down? : Bool 215 | return false if @filtered_channels.size <= inner_height 216 | @scroll_index > min_scroll_index 217 | end 218 | 219 | def trigger(behavior : String, payload = Hash(Symbol, String).new) 220 | case behavior 221 | when "scroll_up" 222 | scroll(1) if can_scroll_up? 223 | when "scroll_down" 224 | scroll(-1) if can_scroll_down? 225 | when "select_up" 226 | select_up if can_select_up? 227 | when "select_down" 228 | select_down if can_select_down? 229 | else 230 | super 231 | end 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /src/message_parser.cr: -------------------------------------------------------------------------------- 1 | require "discordcr" 2 | require "hydra" 3 | require "./noir_link/setup" 4 | 5 | module Crcophony 6 | # Class that contains a collection of static methods used to parse retrieved messages into a good output format 7 | class MessageParser 8 | # Regex for matching code blocks with an optional language 9 | @@code_block_regex = /```(?P[a-zA-Z]+\n)?(?!```)(?P[\s\S]*)```/ 10 | # Regex for matching colour tags to avoid them when calculating line lengths 11 | @@colour_tag_regex = /<\/?[a-z0-9\-]+>/ 12 | # The Client Cache, so the parser can do lookups 13 | @cache : Discord::Cache 14 | # Array of special characters that should be escaped 15 | @to_escape : Array(Char) = ['<'] 16 | # The user currently using crcophony 17 | @user_id : UInt64 18 | # The width of the screen (after subtracting 8 cells for padding) 19 | @width : Int32 20 | 21 | def initialize(@user_id : UInt64, @cache : Discord::Cache, width : Int32) 22 | # Subtract 8 cells from the width to give 4 cells of padding on each side 23 | @width = width - 8 24 | end 25 | 26 | # Parse a received message into a form that looks nice, given the user's highest role in the server 27 | def parse(message : Discord::Message, role : Discord::Role?) : Array(String) 28 | content = message.content 29 | # Escape special characters from the Discord Message so it displays properly 30 | content = escape_special_chars content 31 | # Parse code blocks (contained within ``` symbols) 32 | content = parse_code_blocks content 33 | # Find mentions of the user by username or nickname and turn it into the name 34 | content = parse_user_mentions content, message.mentions 35 | # Parse bodies of embeds and add them to the message 36 | content = parse_embeds content, message.embeds 37 | # Add links to attachments to the end of the message body 38 | content = parse_attachments content, message.attachments 39 | # Take the now parsed message and format it for output on the screen 40 | return format_output message, content, role 41 | end 42 | 43 | # Take the now parsed message and format it for output on the screen 44 | private def format_output(payload : Discord::Message, message : String, role : Discord::Role?) : Array(String) 45 | # Put the timestamp on the right hand side in a subtle colour 46 | timestamp = payload.timestamp.to_s "%H:%M:%S %d/%m/%y" 47 | 48 | # Get the colour for the username 49 | username = get_color_for_username payload.author.username, role 50 | 51 | # Figure out the amount of spacing to put between the timestamp and the username 52 | # Add on 6 for padding from the TUI lib 53 | spacing = " " * (@width - timestamp.size - payload.author.username.size + 6) 54 | 55 | # Generate the first line 56 | lines = ["#{username}#{spacing}#{timestamp}"] 57 | 58 | # Wrap the rest of the text to fit in the width 59 | wrap_text! message, lines 60 | 61 | return lines 62 | end 63 | 64 | # Escape special characters from the Discord Message so it displays properly. 65 | private def escape_special_chars(content : String) : String 66 | @to_escape.each do |char| 67 | content = content.gsub char, "\\#{char}" 68 | end 69 | return content 70 | end 71 | 72 | # Find mentions of the user by username or nickname and turn it into the username. 73 | # Also colour in mentions of the running user to make them stand out. 74 | private def parse_user_mentions(content : String, mentions : Array(Discord::User)) : String 75 | mentions.each do |user| 76 | replace_string = "@#{user.username}" 77 | if user.id == @user_id 78 | replace_string = "#{replace_string}" 79 | end 80 | # <@id> => username, <@!id> => nickname, convert both 81 | content = content.gsub /\\<@!?#{user.id}>/, replace_string 82 | end 83 | return content 84 | end 85 | 86 | # Parse code blocks (contained within ``` symbols). 87 | # Will have to be expanded for syntax highlighting at a later date. 88 | private def parse_code_blocks(content : String) : String 89 | # Ensure that all code blocks start and finish on a new line 90 | content = content.gsub /([^\n])```/, "\\1\n```" 91 | while match = @@code_block_regex.match content 92 | # We have the potential for a "language" key to syntax highlight later 93 | 94 | # Build the parsed code block by prepending a coloured bar character 95 | block = match["content"].strip 96 | parsed_lines : Array(String) 97 | if match["language"]? 98 | lexer = Noir.find_lexer match["language"].not_nil!.strip 99 | if lexer 100 | parsed_lines = block.split("\n").map { |line| 101 | builder = String::Builder.new 102 | Noir.highlight line, lexer: lexer, formatter: Crcophony::SyntaxFormatter.new(Noir::Themes::SolarizedLight.new, builder) 103 | line = builder.to_s 104 | next "#{line}" 105 | } 106 | else 107 | parsed_lines = block.split("\n").map { |line| "#{line}" } 108 | end 109 | else 110 | parsed_lines = block.split("\n").map { |line| "#{line}" } 111 | end 112 | parsed = parsed_lines.join "\n" 113 | # Replace the block with the parsed lines 114 | content = content.sub @@code_block_regex, parsed 115 | end 116 | return content 117 | end 118 | 119 | # Parse bodies of embeds and add them to the message. 120 | private def parse_embeds(content : String, embeds : Array(Discord::Embed)) : String 121 | return content if embeds.size == 0 122 | embed_strings = [] of String 123 | embeds.each do |embed| 124 | text : String 125 | if embed.title.nil? 126 | text = "Embed" 127 | else 128 | text = embed.title.not_nil! 129 | end 130 | if !embed.colour.nil? 131 | colour = Hydra::Color.new(embed.colour.not_nil!).name 132 | text = "<#{colour}-fg>#{text}" 133 | end 134 | if !embed.description.nil? 135 | text += "\n #{embed.description.not_nil!.split("\n").join("\n ")}" 136 | end 137 | if !embed.url.nil? 138 | text += "\n #{embed.url.not_nil!}" 139 | end 140 | embed_strings << text 141 | end 142 | return content + "\n#{embed_strings.join "\n"}" 143 | end 144 | 145 | # Add links to attachments to the end of the message body. 146 | private def parse_attachments(content : String, attachments : Array(Discord::Attachment)) : String 147 | return content if attachments.size == 0 148 | urls = [] of String 149 | attachments.each do |attachment| 150 | urls << attachment.proxy_url 151 | end 152 | return content + "\nAttachments:\n #{urls.join "\n "}" 153 | end 154 | 155 | # Calculate the color for the username to be rendered in 156 | private def get_color_for_username(username : String, role : Discord::Role?) : String 157 | if role.nil? 158 | return username 159 | end 160 | # Look up the color of the role, find the closest to it and add it to the string 161 | role = role.not_nil! 162 | color_name = Hydra::Color.new(role.color).name 163 | return "<#{color_name}-fg>#{username}" 164 | end 165 | 166 | # Handle word wrapping, also adding the indentation for lines 167 | private def wrap_text!(message : String, lines : Array(String)) 168 | message_lines = message.split "\n" 169 | # Check for empty message body (for example when uploading an image the text is blank but there is an attachment) 170 | if message_lines[0] == "" 171 | message_lines.shift 172 | end 173 | message_lines.each do |line| 174 | # Add initial indentation 175 | line = (" " * 4) + line 176 | # Now handle lines 177 | if message.gsub(@@colour_tag_regex, "").size < @width 178 | lines << line 179 | else 180 | while line.gsub(@@colour_tag_regex, "").size > @width 181 | # Strip the first `width` characters from the line 182 | lines << line[0..@width] 183 | line = (" " * 4) + line[@width..line.size] 184 | end 185 | # Add the remaining part of the line to lines 186 | lines << line 187 | end 188 | end 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /src/application.cr: -------------------------------------------------------------------------------- 1 | require "discordcr" 2 | require "hydra" 3 | require "logger" 4 | require "notify" 5 | require "./elements/*" 6 | 7 | module Crcophony 8 | # Class that manages the display and all stuff related to it 9 | # 10 | # This class also provides helper methods for cleanly updating parts of the display 11 | class Application 12 | @app : Hydra::Application 13 | @channel : Crcophony::Channel 14 | @channel_list : Crcophony::ChannelList 15 | @channel_name : Hydra::Text 16 | @channel_prompt : Crcophony::Searcher 17 | @client : Discord::Client 18 | @messages : Hydra::Logbox 19 | @notifier : Notify 20 | @parser : Crcophony::MessageParser 21 | @prompt : Crcophony::MessagePrompt 22 | @screen : Hydra::TerminalScreen 23 | # user_id => guild_id => color role 24 | @user_color_cache : Hash(UInt64, Hash(UInt64, Discord::Role)) = Hash(UInt64, Hash(UInt64, Discord::Role)).new 25 | # {user_id, guild_id} 26 | @user_nil_role_cache : Set(Tuple(UInt64, UInt64)) = Set(Tuple(UInt64, UInt64)).new 27 | 28 | def initialize(@client : Discord::Client) 29 | @screen = Hydra::TerminalScreen.new 30 | @app = Hydra::Application.setup screen: @screen, logger: Logger.new(nil) 31 | 32 | # Create a notifier for notifying received messages 33 | @notifier = Notify.new 34 | 35 | # Create a parser instance 36 | @parser = Crcophony::MessageParser.new @client.client_id.to_u64, @client.cache.not_nil!, @screen.width 37 | 38 | # Set up the elements in the application display 39 | # Channel name text display 40 | @channel_name = Hydra::Text.new("channel_name", { 41 | :position => "0:0", 42 | :width => @screen.width.to_s, 43 | :height => "1", 44 | :label => "Crcophony #{VERSION}", 45 | }) 46 | 47 | # Messages container 48 | @messages = Hydra::Logbox.new("messages", { 49 | :position => "2:0", 50 | :width => @screen.width.to_s, 51 | :height => (@screen.height - 4).to_s, 52 | }) 53 | 54 | # Create a channel list 55 | channel_x = (@screen.height.to_f / 2 - 13.to_f / 2).floor.to_i 56 | channel_y = (@screen.width.to_f / 2 - (@screen.width / 4 * 3).to_f / 2).floor.to_i 57 | @channel_list = Crcophony::ChannelList.new(@client, "channels", { 58 | :position => "#{channel_x}:#{channel_y}", 59 | :width => "#{@screen.width // 4 * 3}", 60 | :height => "13", # "#{@screen.height / 4 * 3}", 61 | :z_index => "1", 62 | :visible => "false", 63 | }) 64 | 65 | # Set the current channel to be the first channel in the list 66 | # Set up the channel list to open the dev channel first by default 67 | @channel = @channel_list.get_channel 68 | 69 | # Set up necessary bindings before creating the prompt as we want our keybinds to made before the prompt's default ones 70 | setup_bindings 71 | 72 | # Message Prompt 73 | @prompt = Crcophony::MessagePrompt.new(@messages, "prompt", { 74 | :position => "#{@screen.height - 3}:0", 75 | :width => @screen.width.to_s, 76 | :height => "2", 77 | }) 78 | 79 | # Channel Searching Prompt 80 | @channel_prompt = Crcophony::Searcher.new(@channel_list, "channel_prompt", { 81 | :position => "#{channel_x - 2}:#{channel_y}", 82 | :width => "#{@screen.width // 4 * 3}", 83 | :height => "2", 84 | :z_index => "1", 85 | :visible => "false", 86 | :label => "Search for Channels", 87 | }) 88 | 89 | # Add the elements to the application, ensuring to add messages last so the scrollbar is visible 90 | @app.add_element @channel_list 91 | @app.add_element @channel_prompt 92 | @app.add_element @channel_name 93 | @app.add_element @prompt 94 | @app.add_element @messages 95 | 96 | # Set the initial server label 97 | set_channel @channel 98 | end 99 | 100 | # Wrapper around @app.run 101 | def run 102 | @app.run 103 | end 104 | 105 | # Wrapper around @app.teardown 106 | def teardown 107 | @app.teardown 108 | end 109 | 110 | # Change the app to display the new channel in the specified server 111 | # 112 | # This function updates the top label, clears the old message box and retrieves some (50) messages for context 113 | def set_channel(channel : Crcophony::Channel) 114 | # Set the unread messages to 0 now that we have opened the channel 115 | @channel_list.reset_current_notifications channel 116 | if @channel.id != channel.id 117 | @channel_list.prev_channel = @channel 118 | end 119 | @channel = channel 120 | @channel_name.value = generate_label channel 121 | @messages.clear 122 | 123 | # Retrieve a message history if necessary 124 | if !@channel.loaded 125 | @client.get_channel_messages(@channel.id).reverse.each do |message| 126 | # Add a guild id to the message 127 | message.guild_id = @channel.guild_id 128 | # Handle the message with the append flag set to true 129 | handle_message message, false, true 130 | end 131 | else 132 | # Just load from the cache without reappending 133 | @channel.messages.each do |message| 134 | handle_message message, false, false 135 | end 136 | end 137 | 138 | # Scroll to the bottom 139 | while @messages.can_scroll_down? 140 | @messages.scroll -1 141 | end 142 | # Update manually here 143 | @app.trigger "update" 144 | end 145 | 146 | # Handler for receiving a message via the Discord client 147 | # Update the screen if update is true (this flag is used to not update until all messages are loaded in case of changing channel) 148 | def handle_message(message : Discord::Message, update : Bool = true, append : Bool = true) 149 | if message.channel_id == @channel.id 150 | # Notification 151 | @notifier.notify("#{@channel.to_s}", body: "#{message.author.username}: #{message.content}") if (update && message.author.id != @client.client_id) 152 | 153 | # First do the various parsing and escaping we need to do 154 | # Then add the message to the logbox 155 | # Get the role for the username colours 156 | role = get_role_for_message message 157 | @parser.parse(message, role).each do |line| 158 | @messages.add_message line 159 | end 160 | 161 | # Append the message to the channel if necessary 162 | if append 163 | @channel << message 164 | end 165 | else 166 | # Notification 167 | @notifier.notify("#{@channel_list.get_channel_name message.channel_id}", body: "#{message.author.username}: #{message.content}") if (update && message.author.id != @client.client_id) 168 | 169 | # If the channel has been loaded already, add the message to it 170 | channel = @channel_list.get_channel(message.channel_id).not_nil! 171 | if @channel.loaded && append 172 | @channel << message 173 | end 174 | 175 | # Update the unread status of the channel 176 | @channel_list.add_unread channel 177 | 178 | # Update the label with the current number of unreads 179 | @channel_name.value = generate_label @channel 180 | end 181 | # Trigger an update manually 182 | if update 183 | @app.trigger "update" 184 | end 185 | end 186 | 187 | # Given a message, look up the user and the guild to get a list of roles and return the highest one that provides a colour (if any) 188 | private def get_role_for_message(message : Discord::Message) : Discord::Role? 189 | if message.guild_id.nil? 190 | return nil 191 | end 192 | 193 | # Get the necessary information from the message 194 | user_id = message.author.id.to_u64 195 | guild_id = message.guild_id.not_nil!.to_u64 196 | nil_cache_key = {user_id, guild_id} 197 | 198 | # Check if the nil cache exist 199 | if @user_nil_role_cache.includes? nil_cache_key 200 | return nil 201 | end 202 | 203 | # Check the user color cache 204 | if @user_color_cache[user_id]? && @user_color_cache[user_id][guild_id]? 205 | return @user_color_cache[user_id][guild_id] 206 | end 207 | 208 | # Not in cache, so attempt to fetch if 209 | cache = @client.cache.not_nil! 210 | guild_member : Discord::GuildMember 211 | 212 | # Wrap in a begin to ensure 404 errors are handled properly 213 | begin 214 | guild_member = cache.resolve_member message.guild_id.not_nil!, message.author.id 215 | rescue 216 | # Add the nil cache 217 | @user_nil_role_cache.add nil_cache_key 218 | return nil 219 | end 220 | 221 | # Check if the user has a role in the server 222 | if guild_member.roles.size == 0 223 | # Add the nil cache 224 | @user_nil_role_cache.add nil_cache_key 225 | return nil 226 | end 227 | 228 | # Map the role ids to their object forms and get the one with the highest position 229 | roles = guild_member.roles.map { |a| cache.resolve_role(a) } 230 | roles.sort! { |a, b| b.position <=> a.position } 231 | 232 | # Iterate through and find the first with a color 233 | role : Discord::Role? = nil 234 | roles.each do |r| 235 | if r.color != 0 236 | role = r 237 | break 238 | end 239 | end 240 | 241 | if role == nil 242 | return nil 243 | end 244 | role = role.not_nil! 245 | 246 | # Cache the role 247 | if !@user_color_cache[user_id]? 248 | @user_color_cache[user_id] = {} of UInt64 => Discord::Role 249 | end 250 | @user_color_cache[user_id][guild_id] = role 251 | return role 252 | end 253 | 254 | # Set up the various bindings used within the system 255 | # 256 | # ## Keybinds 257 | # - Ctrl+C: Quit Application 258 | # - Enter: Send Message 259 | # - Ctrl+W: Scroll Up 260 | # - Ctrl+S: Scroll Down 261 | # ## Channel Switching 262 | # - Ctrl+K: Open / Close Channel Selection Menu 263 | # - Enter: Select Channel 264 | # - Ctrl+W: Scroll Selection Up 265 | # - Ctrl+S: Scroll Selection Down 266 | # - ESC: Alternative Close Button 267 | private def setup_bindings 268 | # Set up a ready listener that auto adds focus to the prompt 269 | # Give focus to the message entry prompt on start up 270 | @app.bind("ready") do |event_hub, _, _, _| 271 | event_hub.focus "prompt" 272 | false 273 | end 274 | # Close the application on Ctrl+C 275 | @app.bind("keypress.ctrl-c", "application", "stop") 276 | # Send message on Enter 277 | @app.bind("prompt", "keypress.enter") do |event_hub, _, elements, _| 278 | # send to discord 279 | message = elements.by_id("prompt").as(Hydra::Prompt).value.strip 280 | next false unless message.size > 0 281 | @client.create_message @channel.id, message 282 | event_hub.trigger "prompt", "clear" 283 | false 284 | end 285 | 286 | # Insert newline into prompt with ctrl + n 287 | @app.bind("prompt", "keypress.ctrl-n") do |event_hub| 288 | event_hub.trigger "prompt", "append", {:char => "\n"} 289 | false 290 | end 291 | 292 | # Scroll up to scroll box 293 | @app.bind("prompt", "keypress.ctrl-w") do |event_hub| 294 | event_hub.trigger "messages", "scroll_up" 295 | false 296 | end 297 | @app.bind("prompt", "keypress.up") do |event_hub| 298 | event_hub.trigger "messages", "scroll_up" 299 | false 300 | end 301 | # Scroll down the scrollbox 302 | @app.bind("prompt", "keypress.ctrl-s") do |event_hub| 303 | event_hub.trigger "messages", "scroll_down" 304 | false 305 | end 306 | @app.bind("prompt", "keypress.down") do |event_hub| 307 | event_hub.trigger "messages", "scroll_down" 308 | false 309 | end 310 | 311 | # Show the Channel Switcher 312 | @app.bind("prompt", "keypress.ctrl-k") do |event_hub| 313 | event_hub.trigger "channels", "show" 314 | event_hub.trigger "channel_prompt", "show" 315 | event_hub.focus "channel_prompt" 316 | false 317 | end 318 | 319 | # Move channel selection up one 320 | @app.bind("channel_prompt", "keypress.ctrl-w") do |event_hub| 321 | event_hub.trigger "channels", "select_up" 322 | false 323 | end 324 | @app.bind("channel_prompt", "keypress.up") do |event_hub| 325 | event_hub.trigger "channels", "select_up" 326 | false 327 | end 328 | # Move channel selection down one 329 | @app.bind("channel_prompt", "keypress.ctrl-s") do |event_hub| 330 | event_hub.trigger "channels", "select_down" 331 | false 332 | end 333 | @app.bind("channel_prompt", "keypress.down") do |event_hub| 334 | event_hub.trigger "channels", "select_down" 335 | false 336 | end 337 | 338 | # Make Channel Selection 339 | @app.bind("channel_prompt", "keypress.enter") do |event_hub| 340 | channel : Crcophony::Channel = @channel_list.not_nil!.get_channel 341 | # Move selection back to 0 since the filtered list could change 342 | @channel_list.not_nil!.reset_selection 343 | set_channel channel 344 | event_hub.trigger "channels", "hide" 345 | event_hub.trigger "channel_prompt", "hide" 346 | event_hub.focus "prompt" 347 | false 348 | end 349 | 350 | # Hide the Channel Switcher 351 | @app.bind("channel_prompt", "keypress.ctrl-k") do |event_hub| 352 | event_hub.trigger "channels", "hide" 353 | event_hub.trigger "channel_prompt", "hide" 354 | event_hub.focus "prompt" 355 | false 356 | end 357 | @app.bind("channel_prompt", "keypress.escape") do |event_hub| 358 | event_hub.trigger "channels", "hide" 359 | event_hub.trigger "channel_prompt", "hide" 360 | event_hub.focus "prompt" 361 | false 362 | end 363 | end 364 | 365 | # Generate a centered channel name given a server and a channel 366 | private def generate_label(channel : Crcophony::Channel) : String 367 | label = channel.to_s 368 | left_padding = (@screen.width - label.size) // 2 369 | right_string = "" 370 | if @channel_list.unread_messages > 0 371 | # Add on a notifications display at the top right 372 | notif_string = "[#{@channel_list.unread_messages}]" 373 | right_padding = (@screen.width - label.size - notif_string.size - 8) // 2 374 | right_string = "#{" " * right_padding}#{notif_string}" 375 | end 376 | return "#{" " * left_padding}#{label}#{right_string}" 377 | end 378 | end 379 | end 380 | --------------------------------------------------------------------------------