├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rustfmt.toml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── find_token.py ├── parsing ├── .gitignore ├── Cargo.toml ├── README.md └── src │ ├── lib.rs │ └── main.rs └── src ├── bar_items.rs ├── bin └── main.rs ├── buffers.rs ├── command.rs ├── config.rs ├── discord ├── client.rs ├── event_handler.rs ├── formatting.rs └── mod.rs ├── hook.rs ├── lib.rs ├── sync.rs ├── utils.rs └── weechat_utils ├── buffer_manager.rs ├── message_manager.rs └── mod.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest] 11 | fail-fast: false 12 | 13 | steps: 14 | - name: Install Linux packages 15 | if: runner.os == 'Linux' 16 | run: | 17 | sudo apt-key adv --keyserver hkps://keys.openpgp.org --recv-keys 11E9DE8848F2B65222AA75B8D1820DB22A11534E 18 | sudo add-apt-repository "deb https://weechat.org/ubuntu $(lsb_release -cs) main" 19 | sudo apt-get update 20 | sudo apt install weechat-devel-curses libclang-dev 21 | - name: Install macOS packages 22 | if: runner.os == 'macOS' 23 | run: | 24 | brew install weechat llvm 25 | echo LIBCLANG_PATH=$(brew --prefix llvm)/lib/libclang.dylib >> $GITHUB_ENV 26 | # NB: We install gnu-tar because BSD tar is buggy on Github's macos machines. https://github.com/actions/cache/issues/403 27 | - name: Install GNU tar (macOS) 28 | if: runner.os == 'macOS' 29 | run: | 30 | brew install gnu-tar 31 | echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH 32 | 33 | - name: Display Rust and Cargo versions 34 | run: | 35 | rustc -Vv 36 | cargo -V 37 | 38 | - uses: actions/checkout@v2 39 | 40 | - name: Cache cargo build 41 | uses: actions/cache@v2 42 | with: 43 | path: | 44 | ~/.cargo/bin/ 45 | ~/.cargo/registry/index/ 46 | ~/.cargo/registry/cache/ 47 | ~/.cargo/git/db/ 48 | target/ 49 | key: ${{ runner.os }}-cargo--${{ hashFiles('**/Cargo.lock') }} 50 | 51 | - name: Build binaries 52 | run: cargo build --release 53 | 54 | - uses: actions/upload-artifact@v2 55 | if: runner.os == 'Linux' 56 | with: 57 | name: weechat-discord-linux 58 | path: target/release/libweecord.* 59 | - uses: actions/upload-artifact@v2 60 | if: runner.os == 'macOS' 61 | with: 62 | name: weechat-discord-macos 63 | path: target/release/libweecord.* 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | /test_dir*/ 5 | 6 | /.vscode/ 7 | *.iml 8 | /.idea/ 9 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | merge_imports = true 2 | match_block_trailing_comma = true 3 | use_field_init_shorthand = true 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "weechat-discord" 3 | version = "0.2.0" 4 | authors = ["Noskcaj "] 5 | edition = "2018" 6 | 7 | [lib] 8 | name = "weecord" 9 | crate-type = ["dylib"] 10 | 11 | [features] 12 | default = ["onig", "logging"] 13 | 14 | pcre = ["parsing/pcre"] 15 | onig = ["parsing/onig"] 16 | logging = ["flexi_logger"] 17 | 18 | [dependencies] 19 | libc = "0.2.70" 20 | lazy_static = "1.4.0" 21 | dirs = "2.0.2" 22 | crossbeam-channel = "0.4.2" 23 | regex = "1.3.7" 24 | indexmap = "1.3.2" 25 | json = "0.12.4" 26 | 27 | [dependencies.flexi_logger] 28 | version = "0.17.1" 29 | optional = true 30 | 31 | [dependencies.parking_lot] 32 | rev = "046a171" 33 | git = "https://github.com/terminal-discord/parking_lot" 34 | 35 | [dependencies.serenity] 36 | git = "https://github.com/terminal-discord/serenity" 37 | rev = "c4ae4c61" 38 | default_features = false 39 | features = [ 40 | "builder", 41 | "cache", 42 | "client", 43 | "gateway", 44 | "model", 45 | "utils", 46 | "rustls_backend", 47 | ] 48 | 49 | [dependencies.parsing] 50 | path = "parsing" 51 | 52 | [dependencies.weechat] 53 | git = "https://github.com/terminal-discord/rust-weechat" 54 | rev = "d49cdd0" 55 | 56 | [dependencies.weechat-sys] 57 | git = "https://github.com/terminal-discord/rust-weechat" 58 | rev = "d49cdd0" 59 | 60 | #[patch."https://github.com/terminal-discord/rust-weechat"] 61 | #weechat-sys = { path = "../rust-weechat/weechat-sys" } 62 | #weechat = { path = "../rust-weechat/weechat-rs" } 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jackson Nunley 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | testdir=./test_dir 2 | 3 | .PHONY: all install install_test test run format clippy 4 | 5 | all: src/* 6 | cargo build --release 7 | 8 | all_debug: src/* 9 | cargo build 10 | 11 | .ONESHELL: 12 | install: all 13 | if [[ ! -z $${WEECHAT_HOME} ]]; then 14 | installdir=$${WEECHAT_HOME}/plugins 15 | elif [[ ! -z $${XDG_DATA_HOME} ]]; then 16 | installdir=$${XDG_DATA_HOME}/weechat/plugins 17 | else 18 | installdir=$${HOME}/.weechat/plugins 19 | fi 20 | mkdir -p $${installdir} 21 | cp target/release/libweecord.* $${installdir} 22 | 23 | install_test: all_debug 24 | mkdir -p $(testdir)/plugins 25 | cp target/debug/libweecord.* $(testdir)/plugins 26 | 27 | run: install 28 | weechat -a 29 | 30 | test: install_test 31 | weechat -d $(testdir) 32 | 33 | format: 34 | cargo fmt 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weechat Discord 2 | 3 | 4 | ## Warning 5 | 6 | The developer of [cordless](https://github.com/Bios-Marcel/cordless) (another 3rd party client) has had his [account banned for using a 3rd party client](https://github.com/Bios-Marcel/cordless#i-am-closing-down-the-cordless-project). 7 | 8 | ***It is very possible Discord is now actively enforcing TOS violations, I cannot recommending using this project with an account you are not ok with loosing*** 9 | 10 | --- 11 | 12 | ***Usage of self-tokens is a violation of Discord's TOS*** 13 | 14 | This client makes use of the "user api" and is essentially a self-bot. 15 | This client does not abuse the api however it is still a violation of the TOS. 16 | 17 | Use at your own risk, using this program could get your account or ip disabled, banned, etc. 18 | 19 | --- 20 | 21 | ![CI](https://github.com/terminal-discord/weechat-discord/workflows/CI/badge.svg) 22 | [![Discord](https://img.shields.io/discord/715036059712356372?label=discord&logo=discord&logoColor=white)](https://discord.gg/BcPku6R) 23 | 24 | A plugin that adds Discord to [Weechat](https://weechat.org/) 25 | 26 | (Beta) 27 | 28 | This branch (`master`) is now in maintenance mode as the plugin is being rewritten in the `mk3` branch. 29 | 30 | --- 31 | 32 | ### Installation 33 | 34 | Binaries are automatically compiled for macOS and linux on [Github Actions](https://terminal-discord.vercel.app/api/latest-build?repo=weechat-discord&workflow=1329556&branch=master&redirect) 35 | 36 | #### Building 37 | 38 | Dependencies: 39 | 40 | * Weechat developer libraries. Usually called `weechat-dev`, or sometimes just `weechat` includes them. 41 | * [Rust](https://www.rust-lang.org). Ensure you have the latest version. 42 | * [libclang](https://rust-lang.github.io/rust-bindgen/requirements.html) 43 | 44 | Then just run `make install` 45 | 46 | cd weechat-discord # or wherever you cloned it 47 | make install 48 | 49 | This will produce a shared object called `target/release/libweecord.so` (or `.dylib` on macos). Place it in your weechat plugins directory, which is probably located at either `~/.weechat/plugins` or `${XDG_DATA_HOME}/weechat/plugins` (may need to be created) 50 | 51 | The Makefile has several other development commands: 52 | 53 | make # (same as make all) just runs that `cargo build --release` command, produces weecord.so 54 | make install # builds and copies the .so to the weechat plugins dir, creating the dir if required 55 | make test # install to ./test_dir/ and opens weechat with that dir 56 | make run # installs and runs `weechat -a` (-a means "don't autoconnect to servers") 57 | 58 | Quitting weechat before installing is recommended 59 | 60 | ### Set up 61 | 62 | [You will need to obtain a login token](https://github.com/discordapp/discord-api-docs/issues/69#issuecomment-223886862). 63 | You can either use a python script to find the tokens, or try and grab them manually. 64 | 65 | #### Python Script 66 | 67 | `find_token.py` is a simple python3 script to search the computer for localstorage databases. It will present a list of all found databases. 68 | 69 | If ripgrep is installed it will use that, if not, it will use `find`. 70 | 71 | 72 | #### Manually 73 | 74 | With the discord app open in your browser: 75 | 1) Open Devtools (ctrl+shift+i or cmd+opt+i) 76 | 2) Navigate to the Network tab 77 | 3) View only WebSockets by clicking "WS" in the inspector bar 78 | 4) Reload the page and select the "gateway.discord.gg" connection 79 | 5) Navigate to the "Response" tab of the request 80 | 6) The first or second message should begin with `{"op":2,"d":{"token":""...` 81 | 82 | 83 | ### Usage 84 | 85 | First, you either need to load the plugin, or have it set to autoload. 86 | 87 | Then, set your token: 88 | 89 | /discord token 123456789ABCDEF 90 | 91 | This saves the discord token in `/plugins.conf`, **so make sure not to commit this file or share it with anyone.** 92 | 93 | You can also secure your token with [secure data](https://weechat.org/blog/post/2013/08/04/Secured-data). 94 | If you saved your token as `discord_token` then you would run 95 | 96 | /discord token ${sec.data.discord_token} 97 | 98 | Then, connect: 99 | 100 | /discord connect 101 | 102 | If you want to always connect on load, you can enable autostart with: 103 | 104 | /discord autostart 105 | 106 | Note you may also have to adjust a few settings for best use: 107 | 108 | weechat.bar.status.items -> replace buffer_name with buffer_short_name 109 | # additionally, buffer_guild_name, buffer_channel_name, and buffer_discord_full_name bar 110 | # items can be used 111 | plugins.var.python.go.short_name -> on (if you use go.py) 112 | 113 | If you want a more irc-style interface, you can enable irc-mode: 114 | 115 | /discord irc-mode 116 | 117 | In irc-mode, weecord will not automatically "join" every Discord channel. You must join a channel using the 118 | `/discord join []` command. 119 | 120 | Watched channels: 121 | You can use `/discord watch []` to start watching a channel or entire guild. 122 | This means that if a message is received in a watched channel, that channel will be joined and added to the nicklist. 123 | 124 | Autojoin channels: 125 | You can use `/discord autojoin []` to start watching a channel or entire guild. 126 | Any channel or guild marked as autojoin will be automatically joined when weecord connects. 127 | 128 | A typing indicator can be added with the `discord_typing` bar item by appending `,discord_typing` to `weechat.bar.status.items`. 129 | 130 | Messages can be edited and deleted using ed style substitutions. 131 | 132 | To edit: 133 | 134 | s/foo/bar/ 135 | 136 | To delete: 137 | 138 | s/// 139 | 140 | An optional message id can also be passed to target the nth most recent message: 141 | 142 | 3s/// 143 | 144 | --- 145 | 146 | ## MacOS 147 | 148 | Weechat does not search for mac dynamic libraries (.dylib) by default, this can be fixed by adding dylibs to the plugin search path, 149 | 150 | ``` 151 | /set weechat.plugin.extension ".so,.dll,.dylib" 152 | ``` 153 | -------------------------------------------------------------------------------- /find_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import string 5 | import platform 6 | from base64 import b64decode 7 | import urllib.request 8 | import json 9 | from functools import cache 10 | from datetime import datetime 11 | from collections import namedtuple 12 | from typing import Optional, Iterator, List 13 | import re 14 | 15 | ParsedToken = namedtuple("ParsedToken", ["raw", "userid", "created", "hmac"]) 16 | DB_FILTER = ["chrome", "vivaldi", "discord"] 17 | _urlsafe_decode_translation = str.maketrans("-_", "+/") 18 | 19 | 20 | def round_down(num, divisor): 21 | return num - (num % divisor) 22 | 23 | def urlsafe_b64decode(s: str): 24 | s = s.translate(_urlsafe_decode_translation) 25 | return b64decode(s, validate=True) 26 | 27 | 28 | @cache 29 | def id2username(id: str) -> str: 30 | try: 31 | resp = urllib.request.urlopen( 32 | "https://terminal-discord.vercel.app/api/lookup-user?id={}".format(id) 33 | ) 34 | data = json.load(resp) 35 | return data.get("username") or "Unknown" 36 | except: 37 | return "Unknown" 38 | 39 | 40 | def parseIdPart(id_part: str) -> str: 41 | return urlsafe_b64decode(id_part).decode() 42 | 43 | # This doesn't return the correct value anymore, discord changed something 44 | def parseTimePart(time_part: str) -> datetime: 45 | if len(time_part) < 6: 46 | raise Exception("Time part too short") 47 | padded_time_part = time_part + "=" * ( 48 | (round_down(len(time_part), 4) + 4) - len(time_part) 49 | ) 50 | decoded = urlsafe_b64decode(padded_time_part) 51 | timestamp = sum((item * 256 ** idx for idx, item in enumerate(reversed(decoded)))) 52 | if timestamp < 1293840000: 53 | timestamp += 1293840000 54 | return datetime.fromtimestamp(timestamp) 55 | 56 | 57 | def parseToken(token: str) -> ParsedToken: 58 | parts = token.split(".") 59 | return ParsedToken( 60 | raw=token, 61 | userid=parseIdPart(parts[0]), 62 | created=parseTimePart(parts[1]), 63 | hmac=parts[2], 64 | ) 65 | 66 | 67 | def run_command(cmd: str) -> List[str]: 68 | output = subprocess.Popen( 69 | [cmd], shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL 70 | ) 71 | return output.communicate()[0].decode().splitlines() 72 | 73 | 74 | def main(): 75 | skip_username_lookup = "--no-lookup" in sys.argv 76 | print("Searching for Discord localstorage databases...") 77 | # First, we search for .ldb files, these are the leveldb files used by chromium to store localstorage data, 78 | # which contains the discord token. 79 | # Try and use ripgrep, because it's much faster, otherwise, fallback to `find`. 80 | try: 81 | subprocess.check_output(["rg", "--version"]) 82 | results = run_command("rg ~/ --hidden --files -g '*.ldb' -g '*.log' -g 'data.sqlite'") 83 | except FileNotFoundError: 84 | results = run_command("find ~/ -name '*.ldb' -or -name '*.log' -or -name 'data.sqlite'") 85 | 86 | if len(results) == 0: 87 | print("No databases found.") 88 | sys.exit(1) 89 | 90 | # Only search for tokens in local starage directories belonging known Chromium browsers or discord 91 | discord_databases = list( 92 | filter( 93 | lambda x: any([db in x.lower() for db in DB_FILTER]) 94 | and ("Local Storage" in x or "ls/" in x), 95 | results, 96 | ) 97 | ) 98 | 99 | # Then collect strings that look like discord tokens. 100 | token_candidates = {} 101 | token_re = re.compile(rb'([a-z0-9_-]{23,28}\.[a-z0-9_-]{6,7}\.[a-z0-9_-]{27})', flags=re.IGNORECASE) 102 | for database in discord_databases: 103 | for line in open(database, 'rb'): 104 | for result in token_re.finditer(line): 105 | try: 106 | token_candidates[parseToken(result.group(0).decode())] = database 107 | except: 108 | continue 109 | token_candidates = list(token_candidates.items()) 110 | 111 | if len(token_candidates) == 0: 112 | print("No Discord tokens found") 113 | return 114 | 115 | print("Possible Discord tokens found (sorted newest to oldest):\n") 116 | token_candidates = sorted(token_candidates, key=lambda t: t[0].created, reverse=True) 117 | for [token, db] in token_candidates: 118 | if "discord/Local Storage/" in db: 119 | source = "Discord App" 120 | elif "ivaldi" in db: # case insensitive hack 121 | source = "Vivaldi" 122 | elif "Local Storage/" in db: 123 | source = "Chrome" 124 | elif "ls/data.sqlite" in db: 125 | source = "Firefox" 126 | 127 | if skip_username_lookup: 128 | print("{} created: {}, source: {}".format(token.raw, token.created, source)) 129 | else: 130 | print( 131 | "@{}: {} created: {}, source: {}".format( 132 | id2username(token.userid), token.raw, token.created, source 133 | ) 134 | ) 135 | 136 | 137 | if __name__ == "__main__": 138 | main() 139 | -------------------------------------------------------------------------------- /parsing/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | 4 | Cargo.lock 5 | -------------------------------------------------------------------------------- /parsing/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "parsing" 3 | version = "0.1.0" 4 | authors = ["Noskcaj "] 5 | edition = "2018" 6 | 7 | [features] 8 | pcre = ["simple_ast/pcre"] 9 | onig = ["simple_ast/onig"] 10 | 11 | [dependencies] 12 | lazy_static = "1.3.0" 13 | 14 | [dependencies.simple_ast] 15 | git = "https://github.com/Noskcaj19/simple-ast" 16 | rev = "7b9d765" 17 | version = "0.1.0" 18 | default-features = false 19 | -------------------------------------------------------------------------------- /parsing/README.md: -------------------------------------------------------------------------------- 1 | # Parsing 2 | 3 | Sub-crate to handle text parsing for weechat-discord 4 | -------------------------------------------------------------------------------- /parsing/src/lib.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | pub use simple_ast::MarkdownNode; 3 | use simple_ast::{regex::Regex, Parser, Rule, Styled}; 4 | 5 | pub fn parse_markdown(str: &str) -> Styled { 6 | use simple_ast::markdown_rules::*; 7 | let rules: &[&dyn Rule] = &[ 8 | &Escape, 9 | &Newline, 10 | &Bold, 11 | &Underline, 12 | &Italic, 13 | &Strikethrough, 14 | &Spoiler, 15 | &BlockQuote::new(), 16 | &Code, 17 | &InlineCode, 18 | &Text, 19 | ]; 20 | 21 | Parser::with_rules(rules).parse(str) 22 | } 23 | 24 | pub fn weechat_arg_strip(str: &str) -> String { 25 | str.trim().replace(' ', "_") 26 | } 27 | 28 | lazy_static! { 29 | static ref LINE_SUB_REGEX: Regex = 30 | Regex::new(r"^(\d+)?s/(.*?(? { 36 | Sub { 37 | line: usize, 38 | old: &'a str, 39 | new: &'a str, 40 | options: Option<&'a str>, 41 | }, 42 | Delete { 43 | line: usize, 44 | }, 45 | } 46 | 47 | #[derive(Debug)] 48 | pub struct Reaction<'a> { 49 | pub add: bool, 50 | pub unicode: &'a str, 51 | pub line: usize, 52 | } 53 | 54 | pub fn parse_line_edit(input: &str) -> Option { 55 | let caps = LINE_SUB_REGEX.captures(input)?; 56 | 57 | let line = caps.at(1).and_then(|l| l.parse().ok()).unwrap_or(1); 58 | let old = caps.at(2)?; 59 | let new = caps.at(3)?; 60 | 61 | if old.is_empty() && new.is_empty() { 62 | Some(LineEdit::Delete { line }) 63 | } else { 64 | Some(LineEdit::Sub { 65 | line, 66 | old, 67 | new, 68 | options: caps.at(4), 69 | }) 70 | } 71 | } 72 | 73 | pub fn parse_reaction(input: &str) -> Option { 74 | let caps = REACTION_REGEX.captures(input)?; 75 | let line = caps.at(1).and_then(|l| l.parse().ok()).unwrap_or(1); 76 | let unicode_opt = caps.at(3); 77 | let add = caps.at(2) == Some("+"); 78 | unicode_opt.map(|unicode| Reaction { add, unicode, line }) 79 | } 80 | -------------------------------------------------------------------------------- /parsing/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let mut buffer = String::new(); 3 | loop { 4 | std::io::stdin().read_line(&mut buffer).unwrap(); 5 | 6 | println!("{:#?}", parsing::parse_markdown(buffer.trim())); 7 | buffer.clear(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/bar_items.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::BufferExt; 2 | use serenity::model::id::{ChannelId, GuildId}; 3 | use std::borrow::Cow; 4 | use weechat::{bar::BarItem, ConfigOption, Weechat}; 5 | 6 | pub struct BarHandles { 7 | _guild_name: BarItem<()>, 8 | _channel_name: BarItem<()>, 9 | _full_name: BarItem<()>, 10 | _typing_indicator: BarItem<()>, 11 | } 12 | 13 | pub fn init(weechat: &Weechat) -> BarHandles { 14 | let _guild_name = weechat.new_bar_item( 15 | "buffer_guild_name", 16 | |_, _, buffer| { 17 | buffer 18 | .get_localvar("guild_name") 19 | .map(Cow::into_owned) 20 | .unwrap_or_default() 21 | }, 22 | None, 23 | ); 24 | 25 | let _channel_name = weechat.new_bar_item( 26 | "buffer_channel_name", 27 | |_, _, buffer| { 28 | buffer 29 | .get_localvar("channel") 30 | .map(Cow::into_owned) 31 | .unwrap_or_default() 32 | }, 33 | None, 34 | ); 35 | 36 | let _full_name = weechat.new_bar_item( 37 | "buffer_discord_full_name", 38 | |_, _, buffer| { 39 | let guild_name = buffer.get_localvar("guild_name"); 40 | let channel_name = buffer.get_localvar("channel"); 41 | match (guild_name, channel_name) { 42 | // i don't think the second pattern is possible 43 | (Some(name), None) | (None, Some(name)) => format!("{}", name), 44 | (Some(guild_name), Some(channel_name)) => { 45 | format!("{}:{}", guild_name, channel_name) 46 | }, 47 | (None, None) => String::new(), 48 | } 49 | }, 50 | None, 51 | ); 52 | 53 | let _typing_indicator = weechat.new_bar_item( 54 | "discord_typing", 55 | |_, _, buffer| { 56 | if let Some(channel_id) = buffer.channel_id() { 57 | let weechat = buffer.get_weechat(); 58 | let config = &crate::upgrade_plugin(&weechat).config; 59 | let max_users = config.user_typing_list_max.value() as usize; 60 | let expanded = config.user_typing_list_expanded.value(); 61 | let guild_id = buffer.guild_id(); 62 | 63 | if expanded { 64 | expanded_typing_list(channel_id, guild_id, max_users) 65 | } else { 66 | terse_typing_list(channel_id, guild_id, max_users) 67 | } 68 | } else { 69 | "".into() 70 | } 71 | }, 72 | None, 73 | ); 74 | 75 | BarHandles { 76 | _guild_name, 77 | _channel_name, 78 | _full_name, 79 | _typing_indicator, 80 | } 81 | } 82 | 83 | fn terse_typing_list(channel_id: ChannelId, guild_id: Option, max_names: usize) -> String { 84 | let (head, has_more) = get_users_for_typing_list(channel_id, guild_id, max_names); 85 | 86 | let mut users = head.join(", "); 87 | if has_more { 88 | users = users + ", ..."; 89 | } 90 | if users.is_empty() { 91 | "".into() 92 | } else { 93 | format!("typing: {}", users) 94 | } 95 | } 96 | 97 | fn expanded_typing_list( 98 | channel_id: ChannelId, 99 | guild_id: Option, 100 | max_names: usize, 101 | ) -> String { 102 | let (head, has_more) = get_users_for_typing_list(channel_id, guild_id, max_names); 103 | 104 | if head.is_empty() { 105 | "".into() 106 | } else if has_more { 107 | "Several people are typing...".into() 108 | } else if head.len() == 1 { 109 | format!("{} is typing", head[0]) 110 | } else { 111 | let prefix = &head[..head.len() - 1]; 112 | format!( 113 | "{} and {} are typing", 114 | prefix.join(", "), 115 | head[head.len() - 1] 116 | ) 117 | } 118 | } 119 | 120 | fn get_users_for_typing_list( 121 | channel_id: ChannelId, 122 | guild_id: Option, 123 | max_names: usize, 124 | ) -> (Vec, bool) { 125 | let mut users = crate::discord::TYPING_EVENTS 126 | .lock() 127 | .entries 128 | .iter() 129 | .filter(|e| e.guild_id == guild_id && e.channel_id == channel_id) 130 | .map(|e| e.user_name.clone()) 131 | .collect::>(); 132 | users.dedup(); 133 | let (head, has_more) = if users.len() > max_names { 134 | (&users[..max_names], true) 135 | } else { 136 | (&users[..], false) 137 | }; 138 | (head.to_vec(), has_more) 139 | } 140 | -------------------------------------------------------------------------------- /src/bin/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | std::process::Command::new("make") 3 | .arg("test") 4 | .status() 5 | .unwrap(); 6 | } 7 | -------------------------------------------------------------------------------- /src/buffers.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | on_main, 3 | sync::on_main_blocking, 4 | utils, 5 | utils::{BufferExt, ChannelExt}, 6 | weechat_utils::{BufferManager, MessageManager}, 7 | Discord, 8 | }; 9 | use indexmap::IndexMap; 10 | use serenity::{ 11 | cache::{Cache, CacheRwLock}, 12 | client::bridge::gateway, 13 | constants::OpCode, 14 | model::prelude::*, 15 | prelude::*, 16 | }; 17 | use std::{ 18 | collections::{HashMap, HashSet, VecDeque}, 19 | sync::Arc, 20 | }; 21 | use weechat::{buffer::HotlistPriority, Buffer, ConfigOption, NickArgs, Weechat}; 22 | 23 | const OFFLINE_GROUP_NAME: &str = "99999|Offline"; 24 | const ONLINE_GROUP_NAME: &str = "99998|Online"; 25 | const BOT_GROUP_NAME: &str = "99997|Bot"; 26 | 27 | pub fn init(weechat: &Weechat) -> BufferManager { 28 | BufferManager::new(Weechat::from_ptr(weechat.as_ptr())) 29 | } 30 | 31 | pub fn create_buffers(ready_data: &Ready) { 32 | let ctx = match crate::discord::get_ctx() { 33 | Some(ctx) => ctx, 34 | _ => return, 35 | }; 36 | let current_user = ctx.cache.read().user.clone(); 37 | 38 | let guilds = match current_user.guilds(ctx) { 39 | Ok(guilds) => guilds, 40 | Err(e) => { 41 | crate::plugin_print(&format!("Error getting user guilds: {:?}", e)); 42 | vec![] 43 | }, 44 | }; 45 | let mut map: HashMap<_, _> = guilds.iter().map(|g| (g.id, g)).collect(); 46 | 47 | let mut sorted_guilds = VecDeque::new(); 48 | 49 | // Add the guilds ordered from the client 50 | for guild_id in &ready_data.user_settings.guild_positions { 51 | if let Some(guild) = map.remove(&guild_id) { 52 | sorted_guilds.push_back(guild); 53 | } 54 | } 55 | 56 | // Prepend any remaning guilds 57 | for guild in map.values() { 58 | sorted_guilds.push_front(guild); 59 | } 60 | 61 | for guild in &sorted_guilds { 62 | let guild_settings = ready_data.user_guild_settings.get(&guild.id.into()); 63 | let guild_muted; 64 | let mut channel_muted = HashMap::new(); 65 | if let Some(guild_settings) = guild_settings { 66 | guild_muted = guild_settings.muted; 67 | for (channel_id, channel_override) in &guild_settings.channel_overrides { 68 | channel_muted.insert(channel_id, channel_override.muted); 69 | } 70 | } else { 71 | guild_muted = false; 72 | } 73 | create_guild_buffer(guild.id, &guild.name); 74 | 75 | // TODO: Colors? 76 | let nick = if let Ok(current_member) = guild.id.member(ctx, current_user.id) { 77 | format!("@{}", current_member.display_name()) 78 | } else { 79 | format!("@{}", current_user.name) 80 | }; 81 | let channels = guild.id.channels(ctx).expect("Unable to fetch channels"); 82 | let mut channels = channels.values().collect::>(); 83 | channels.sort_by_key(|g| g.position); 84 | for channel in channels { 85 | let is_muted = 86 | guild_muted || channel_muted.get(&channel.id).cloned().unwrap_or_default(); 87 | create_buffer_from_channel(&ctx.cache, &guild.name, &channel, &nick, is_muted); 88 | } 89 | } 90 | } 91 | 92 | // TODO: Merge these functions 93 | pub fn create_autojoin_buffers(_ready: &Ready) { 94 | let ctx = match crate::discord::get_ctx() { 95 | Some(ctx) => ctx, 96 | _ => return, 97 | }; 98 | 99 | let current_user = ctx.cache.read().user.clone(); 100 | 101 | // TODO: Add sorting 102 | let mut autojoin_items: Vec<_> = on_main_blocking(|weecord| weecord.config.autojoin_channels()); 103 | 104 | let watched_items: Vec<_> = on_main_blocking(|weecord| weecord.config.watched_channels()); 105 | 106 | let watched_channels = utils::flatten_guilds(&ctx, &watched_items); 107 | 108 | let cache = ctx.cache.read(); 109 | for (guild_id, channels) in watched_channels { 110 | for channel in channels { 111 | let read_state = match cache.read_state.get(&channel) { 112 | Some(rs) => rs, 113 | None => continue, 114 | }; 115 | let last_msg = match channel 116 | .to_channel_cached(ctx) 117 | .and_then(|c| c.last_message()) 118 | { 119 | Some(msg) => msg, 120 | None => continue, 121 | }; 122 | 123 | if read_state.last_message_id != last_msg { 124 | autojoin_items.push(utils::GuildOrChannel::Channel(guild_id, channel)) 125 | } 126 | } 127 | } 128 | 129 | // flatten guilds into channels 130 | let autojoin_channels = utils::flatten_guilds(&ctx, &autojoin_items); 131 | 132 | create_buffers_from_flat_items(&ctx, ¤t_user, &autojoin_channels); 133 | } 134 | 135 | pub fn create_buffers_from_flat_items( 136 | ctx: &Context, 137 | current_user: &CurrentUser, 138 | channels: &IndexMap, Vec>, 139 | ) { 140 | // TODO: Flatten and iterate by guild, then channel 141 | for guild_id in channels.iter() { 142 | match guild_id { 143 | (Some(guild_id), channels) => { 144 | let guild = match guild_id.to_guild_cached(&ctx.cache) { 145 | Some(guild) => guild, 146 | None => continue, 147 | }; 148 | let guild = guild.read(); 149 | 150 | // TODO: Colors? 151 | let nick = if let Ok(current_member) = guild.id.member(ctx, current_user.id) { 152 | format!("@{}", current_member.display_name()) 153 | } else { 154 | format!("@{}", current_user.name) 155 | }; 156 | let nick = &nick; 157 | 158 | create_guild_buffer(guild.id, &guild.name); 159 | 160 | parking_lot::RwLockReadGuard::unlock_fair(guild); 161 | 162 | for channel in channels { 163 | // TODO: Muting 164 | let () = on_main_blocking(move |_| { 165 | let ctx = match crate::discord::get_ctx() { 166 | Some(ctx) => ctx, 167 | _ => return, 168 | }; 169 | 170 | let guild = match guild_id.to_guild_cached(&ctx.cache) { 171 | Some(guild) => guild, 172 | None => return, 173 | }; 174 | let guild = guild.read(); 175 | 176 | let channel = match channel 177 | .to_channel_cached(&ctx.cache) 178 | .and_then(Channel::guild) 179 | { 180 | Some(channel) => channel, 181 | None => return, 182 | }; 183 | 184 | create_buffer_from_channel( 185 | &ctx.cache, 186 | &guild.name, 187 | &channel.read(), 188 | &nick, 189 | false, 190 | ); 191 | }); 192 | } 193 | }, 194 | (None, channels) => { 195 | let ctx = match crate::discord::get_ctx() { 196 | Some(ctx) => ctx, 197 | _ => return, 198 | }; 199 | let cache = ctx.cache.read(); 200 | let nick = cache.user.name.to_string(); 201 | 202 | for channel_id in channels { 203 | let nick = format!("@{}", nick); 204 | let channel = if let Ok(channel) = channel_id.to_channel(ctx) { 205 | channel 206 | } else { 207 | crate::plugin_print("cache miss"); 208 | continue; 209 | }; 210 | 211 | match channel { 212 | channel @ Channel::Private(_) => on_main(move |weecord| { 213 | let ctx = match crate::discord::get_ctx() { 214 | Some(ctx) => ctx, 215 | _ => return, 216 | }; 217 | create_buffer_from_dm(&ctx.cache, weecord, channel, &nick, false); 218 | }), 219 | 220 | channel @ Channel::Group(_) => on_main(move |weecord| { 221 | let ctx = match crate::discord::get_ctx() { 222 | Some(ctx) => ctx, 223 | _ => return, 224 | }; 225 | create_buffer_from_group(&ctx.cache, weecord, channel, &nick); 226 | }), 227 | _ => unreachable!(), 228 | } 229 | } 230 | }, 231 | } 232 | } 233 | } 234 | 235 | fn user_online(cache: &Cache, user_id: UserId) -> bool { 236 | if user_id == cache.user.id { 237 | true 238 | } else { 239 | let presence = cache.presences.get(&user_id); 240 | presence 241 | .map(|p| utils::status_is_online(p.status)) 242 | .unwrap_or(false) 243 | } 244 | } 245 | 246 | pub fn create_guild_buffer(id: GuildId, name: &str) { 247 | let guild_name_id = utils::buffer_id_for_guild(id); 248 | let () = on_main_blocking(move |weecord| { 249 | let buffer = weecord.buffer_manager.get_or_create_buffer(&guild_name_id); 250 | 251 | buffer.set_localvar("guild_name", name); 252 | buffer.set_localvar("server", name); 253 | buffer.set_short_name(name); 254 | buffer.set_localvar("guildid", &id.0.to_string()); 255 | buffer.set_localvar("type", "server"); 256 | }); 257 | } 258 | 259 | pub fn create_buffer_from_channel( 260 | cache: &CacheRwLock, 261 | guild_name: &str, 262 | channel: &GuildChannel, 263 | nick: &str, 264 | muted: bool, 265 | ) { 266 | let current_user = cache.read().user.clone(); 267 | if let Ok(perms) = channel.permissions_for_user(cache, current_user.id) { 268 | if !perms.read_message_history() { 269 | return; 270 | } 271 | } 272 | 273 | let channel_type = match channel.kind { 274 | // TODO: Should we display store channels somehow? 275 | ChannelType::Category 276 | | ChannelType::Voice 277 | | ChannelType::Store 278 | | ChannelType::Stage 279 | | ChannelType::Directory 280 | | ChannelType::Fourm => return, 281 | ChannelType::Private => "private", 282 | ChannelType::Group | ChannelType::Text | ChannelType::News => "channel", 283 | ChannelType::__Nonexhaustive => unreachable!(), 284 | }; 285 | 286 | let name_id = utils::buffer_id_for_channel(Some(channel.guild_id), channel.id); 287 | let has_unread = cache 288 | .read() 289 | .read_state 290 | .get(&channel.id) 291 | .map(|rs| rs.last_message_id) 292 | != channel.last_message_id; 293 | 294 | let () = on_main_blocking(|weecord| { 295 | let buffer = weecord.buffer_manager.get_or_create_buffer(&name_id); 296 | 297 | buffer.set_short_name(&channel.name); 298 | 299 | buffer.set_localvar("channelid", &channel.id.0.to_string()); 300 | buffer.set_localvar("guildid", &channel.guild_id.0.to_string()); 301 | buffer.set_localvar("channel", &channel.name); 302 | buffer.set_localvar("guild_name", guild_name); 303 | buffer.set_localvar("server", guild_name); 304 | buffer.set_localvar("type", channel_type); 305 | buffer.set_localvar("nick", &nick); 306 | if has_unread && !muted { 307 | buffer.set_hotlist(HotlistPriority::Message); 308 | } 309 | 310 | let mut title = if let Some(ref topic) = channel.topic { 311 | if !topic.is_empty() { 312 | format!("{} | {}", channel.name, topic) 313 | } else { 314 | channel.name.clone() 315 | } 316 | } else { 317 | channel.name.clone() 318 | }; 319 | 320 | if muted { 321 | title += " (muted)"; 322 | } 323 | buffer.set_title(&title); 324 | buffer.set_localvar("muted", &(muted as u8).to_string()); 325 | }); 326 | } 327 | 328 | // TODO: Reduce code duplication 329 | pub fn create_buffer_from_dm( 330 | cache: &CacheRwLock, 331 | weecord: &crate::Discord, 332 | channel: Channel, 333 | nick: &str, 334 | switch_to: bool, 335 | ) { 336 | let channel = match channel.private() { 337 | Some(chan) => chan, 338 | None => return, 339 | }; 340 | let channel = channel.read(); 341 | 342 | let name_id = utils::buffer_id_for_channel(None, channel.id); 343 | let buffer = weecord.buffer_manager.get_or_create_buffer(&name_id); 344 | 345 | buffer.set_short_name(&channel.name()); 346 | buffer.set_localvar("channelid", &channel.id.0.to_string()); 347 | buffer.set_localvar("nick", &nick); 348 | 349 | let has_unread = cache 350 | .read() 351 | .read_state 352 | .get(&channel.id) 353 | .map(|rs| rs.last_message_id) 354 | != channel.last_message_id; 355 | 356 | if has_unread { 357 | buffer.set_hotlist(HotlistPriority::Private); 358 | } 359 | 360 | if switch_to { 361 | buffer.switch_to(); 362 | } 363 | let title = format!("DM with {}", channel.recipient.read().name); 364 | buffer.set_title(&title); 365 | 366 | load_dm_nicks(&buffer, &*channel); 367 | } 368 | 369 | pub fn create_buffer_from_group( 370 | cache: &CacheRwLock, 371 | weecord: &Discord, 372 | channel: Channel, 373 | nick: &str, 374 | ) { 375 | let channel = match channel.group() { 376 | Some(chan) => chan, 377 | None => return, 378 | }; 379 | let channel = channel.read(); 380 | 381 | let title = format!( 382 | "DM with {}", 383 | channel 384 | .recipients 385 | .values() 386 | .map(|u| u.read().name.to_owned()) 387 | .collect::>() 388 | .join(", ") 389 | ); 390 | 391 | let name_id = utils::buffer_id_for_channel(None, channel.channel_id); 392 | 393 | let buffer = weecord.buffer_manager.get_or_create_buffer(&name_id); 394 | 395 | buffer.set_short_name(&channel.name()); 396 | buffer.set_localvar("channelid", &channel.channel_id.0.to_string()); 397 | buffer.set_localvar("nick", &nick); 398 | buffer.set_title(&title); 399 | 400 | let has_unread = cache 401 | .read() 402 | .read_state 403 | .get(&channel.channel_id) 404 | .map(|rs| rs.last_message_id) 405 | != channel.last_message_id; 406 | 407 | if has_unread { 408 | buffer.set_hotlist(HotlistPriority::Private); 409 | } 410 | } 411 | 412 | pub fn create_pins_buffer(weecord: &Discord, channel: &Channel) { 413 | let buffer_name = format!("Pins.{}", channel.id().0); 414 | 415 | let buffer = weecord.buffer_manager.get_or_create_buffer(&buffer_name); 416 | buffer.switch_to(); 417 | 418 | buffer.set_title(&format!("Pinned messages in #{}", channel.name())); 419 | buffer.set_full_name(&format!("Pinned messages in ${}", channel.name())); 420 | buffer.set_short_name(&format!("#{} pins", channel.name())); 421 | utils::set_pins_for_channel(&buffer, channel.id()); 422 | } 423 | 424 | pub fn load_pin_buffer_history(buffer: &MessageManager) { 425 | let channel = match utils::pins_for_channel(&buffer) { 426 | Some(ch) => ch, 427 | None => return, 428 | }; 429 | 430 | buffer.set_history_loaded(); 431 | buffer.clear(); 432 | let buffer_name = buffer.get_name().to_string(); 433 | 434 | std::thread::spawn(move || { 435 | let ctx = match crate::discord::get_ctx() { 436 | Some(ctx) => ctx, 437 | _ => return, 438 | }; 439 | 440 | let pins = match channel.pins(ctx) { 441 | Ok(pins) => pins, 442 | Err(_) => return, 443 | }; 444 | 445 | on_main(move |weecord| { 446 | let ctx = match crate::discord::get_ctx() { 447 | Some(ctx) => ctx, 448 | _ => return, 449 | }; 450 | let buf = match weecord.buffer_manager.get_buffer(&buffer_name) { 451 | Some(buf) => buf, 452 | None => return, 453 | }; 454 | 455 | for pin in pins.iter().rev() { 456 | buf.add_message(&ctx.cache, pin, false); 457 | } 458 | }); 459 | }); 460 | } 461 | 462 | pub fn load_pin_buffer_history_for_id(id: ChannelId) { 463 | on_main(move |weecord| { 464 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&format!("Pins.{}", id)) { 465 | load_pin_buffer_history(&buffer) 466 | }; 467 | }) 468 | } 469 | 470 | pub fn load_history( 471 | buffer: &MessageManager, 472 | completion_sender: crossbeam_channel::Sender<()>, 473 | fetch_count: i32, 474 | ) { 475 | let channel = if let Some(channel) = buffer.channel_id() { 476 | channel 477 | } else { 478 | return; 479 | }; 480 | let guild = buffer.guild_id(); 481 | 482 | buffer.clear(); 483 | buffer.set_history_loaded(); 484 | 485 | let buffer_name = buffer.get_name().to_string(); 486 | 487 | std::thread::spawn(move || { 488 | let ctx = match crate::discord::get_ctx() { 489 | Some(ctx) => ctx, 490 | _ => return, 491 | }; 492 | 493 | if let Ok(msgs) = channel.messages(ctx, |retriever| retriever.limit(fetch_count as u64)) { 494 | on_main(move |weechat| { 495 | let ctx = match crate::discord::get_ctx() { 496 | Some(ctx) => ctx, 497 | _ => return, 498 | }; 499 | let mut unknown_users = HashSet::new(); 500 | let buf = match weechat.buffer_manager.get_buffer(&buffer_name) { 501 | Some(buf) => buf, 502 | None => return, 503 | }; 504 | 505 | if let Some(read_state) = ctx.cache.read().read_state.get(&channel) { 506 | let unread_in_page = msgs.iter().any(|m| m.id == read_state.last_message_id); 507 | 508 | if unread_in_page { 509 | let mut backlog = true; 510 | for msg in msgs.into_iter().rev() { 511 | unknown_users.extend(buf.add_message(&ctx.cache, &msg, false)); 512 | 513 | if backlog { 514 | buf.mark_read(); 515 | buf.clear_hotlist(); 516 | } 517 | if msg.id == read_state.last_message_id { 518 | backlog = false; 519 | } 520 | } 521 | } else { 522 | buf.mark_read(); 523 | buf.clear_hotlist(); 524 | for msg in msgs.into_iter().rev() { 525 | unknown_users.extend(buf.add_message(&ctx.cache, &msg, false)); 526 | } 527 | } 528 | } else { 529 | for msg in msgs.into_iter().rev() { 530 | unknown_users.extend(buf.add_message(&ctx.cache, &msg, false)); 531 | } 532 | } 533 | if let Some(guild) = guild { 534 | let msg = json::object! { 535 | "op" => OpCode::GetGuildMembers.num(), 536 | "d" => json::object! { 537 | "guild_id" => guild.0.to_string(), 538 | "user_ids" => (unknown_users.iter().map(|id| id.to_string())).collect::>(), 539 | "nonce" => channel.0.to_string(), 540 | } 541 | }; 542 | ctx.shard 543 | .websocket_message(gateway::Message::Text(msg.to_string())); 544 | } 545 | let _ = completion_sender.send(()); 546 | }); 547 | } 548 | }); 549 | } 550 | 551 | pub fn load_dm_nicks(buffer: &MessageManager, channel: &PrivateChannel) { 552 | let weechat = buffer.get_weechat(); 553 | let use_presence = crate::upgrade_plugin(&weechat).config.use_presence.value(); 554 | 555 | // If the user doesn't want the presence, there's no reason to open 556 | // the nicklist 557 | if use_presence { 558 | buffer.set_nicks_loaded(); 559 | buffer.enable_nicklist(); 560 | 561 | let ctx = match crate::discord::get_ctx() { 562 | Some(ctx) => ctx, 563 | _ => return, 564 | }; 565 | 566 | let recip = channel.recipient.read(); 567 | let cache = ctx.cache.read(); 568 | 569 | buffer.add_nick( 570 | NickArgs { 571 | name: &recip.name, 572 | color: &utils::nick_color(&weechat, &recip.name), 573 | prefix: &utils::get_user_status_prefix(&weechat, &cache, recip.id), 574 | ..Default::default() 575 | }, 576 | None, 577 | ); 578 | 579 | // TODO: Detect current user status properly 580 | buffer.add_nick( 581 | NickArgs { 582 | name: &cache.user.name, 583 | color: &utils::nick_color(&weechat, &cache.user.name), 584 | prefix: &utils::format_user_status_prefix( 585 | &weechat, 586 | Some(*crate::command::LAST_STATUS.lock()), 587 | ), 588 | ..Default::default() 589 | }, 590 | None, 591 | ); 592 | } 593 | } 594 | 595 | // TODO: Make this nicer somehow 596 | // TODO: Refactor this to use `?` 597 | pub fn load_nicks(buffer: &Buffer) { 598 | if buffer.nicks_loaded() { 599 | return; 600 | } 601 | 602 | let guild_id = if let Some(guild_id) = buffer.guild_id() { 603 | guild_id 604 | } else { 605 | return; 606 | }; 607 | 608 | let channel_id = if let Some(channel_id) = buffer.channel_id() { 609 | channel_id 610 | } else { 611 | return; 612 | }; 613 | 614 | buffer.set_nicks_loaded(); 615 | buffer.enable_nicklist(); 616 | 617 | let sealed_buffer = buffer.seal(); 618 | 619 | std::thread::spawn(move || { 620 | let ctx = match crate::discord::get_ctx() { 621 | Some(ctx) => ctx, 622 | _ => return, 623 | }; 624 | 625 | let guild = guild_id.to_guild_cached(ctx).expect("No guild cache item"); 626 | 627 | // TODO: What to do with more than 1000 members? 628 | // NOTE: using `guild.read().members` 403s and invalidates a users verification status 629 | let members: Vec<_> = guild.read().members.values().cloned().collect(); 630 | 631 | drop(guild); 632 | 633 | let () = on_main_blocking(move |weechat| { 634 | let ctx = match crate::discord::get_ctx() { 635 | Some(ctx) => ctx, 636 | _ => return, 637 | }; 638 | 639 | let use_presence = weechat.config.use_presence.value(); 640 | 641 | let buffer = sealed_buffer.unseal(&weechat); 642 | let guild = guild_id.to_guild_cached(ctx).expect("No guild cache item"); 643 | 644 | let has_crown = guild_has_crown(&guild.read()); 645 | 646 | for member in members { 647 | add_member_to_nicklist( 648 | weechat, 649 | &ctx, 650 | &buffer, 651 | channel_id, 652 | &guild, 653 | &member, 654 | use_presence, 655 | has_crown, 656 | ); 657 | } 658 | }); 659 | }); 660 | } 661 | 662 | fn add_member_to_nicklist( 663 | weechat: &Weechat, 664 | ctx: &Context, 665 | buffer: &Buffer, 666 | channel_id: ChannelId, 667 | guild: &Arc>, 668 | member: &Member, 669 | use_presence: bool, 670 | guild_has_crown: bool, 671 | ) { 672 | let user = member.user.read(); 673 | // the current user does not seem to usually have a presence, assume they are online 674 | let online = if use_presence { 675 | user_online(&*ctx.cache.read(), user.id) 676 | } else { 677 | false 678 | }; 679 | 680 | let member_perms = guild.read().user_permissions_in(channel_id, user.id); 681 | // A pretty accurate method of checking if a user is "in" a channel 682 | if !member_perms.read_message_history() || !member_perms.read_messages() { 683 | return; 684 | } 685 | 686 | let role_name; 687 | let role_color; 688 | // TODO: Change offline/online color somehow? 689 | if user.bot { 690 | role_name = BOT_GROUP_NAME.to_owned(); 691 | role_color = "gray".to_string(); 692 | } else if !online && use_presence { 693 | role_name = OFFLINE_GROUP_NAME.to_owned(); 694 | role_color = "grey".to_string(); 695 | } else if let Some((highest_hoisted, highest)) = utils::find_highest_roles(&ctx.cache, &member) 696 | { 697 | role_name = format!( 698 | "{}|{}", 699 | 99999 - highest_hoisted.position, 700 | highest_hoisted.name 701 | ); 702 | role_color = crate::utils::rgb_to_ansi(highest.colour).to_string(); 703 | } else { 704 | // Can't find a role, add user to generic bucket 705 | if use_presence { 706 | if online { 707 | role_name = ONLINE_GROUP_NAME.to_owned(); 708 | } else { 709 | role_name = OFFLINE_GROUP_NAME.to_owned(); 710 | } 711 | role_color = "grey".to_string(); 712 | } else { 713 | buffer.add_nick( 714 | weechat::NickArgs { 715 | name: member.display_name().as_ref(), 716 | color: &utils::nick_color(&weechat, member.display_name().as_ref()), 717 | ..Default::default() 718 | }, 719 | None, 720 | ); 721 | return; 722 | } 723 | } 724 | let group = match buffer.search_nicklist_group(&role_name) { 725 | Some(group) => group, 726 | None => buffer.add_group(&role_name, &role_color, true, None), 727 | }; 728 | 729 | // TODO: Only show crown if there are no roles 730 | let nicklist_name = if guild_has_crown && guild.read().owner_id == user.id { 731 | format!("{} {}♛", member.display_name(), weechat.color("214")) 732 | } else { 733 | member.display_name().into_owned() 734 | }; 735 | 736 | buffer.add_nick( 737 | weechat::NickArgs { 738 | name: nicklist_name.as_ref(), 739 | color: &utils::nick_color(&weechat, &nicklist_name), 740 | ..Default::default() 741 | }, 742 | Some(&group), 743 | ); 744 | } 745 | 746 | pub fn update_nick() { 747 | let ctx = match crate::discord::get_ctx() { 748 | Some(ctx) => ctx, 749 | _ => return, 750 | }; 751 | let current_user = ctx.cache.read().user.clone(); 752 | 753 | for guild in current_user.guilds(ctx).expect("Unable to fetch guilds") { 754 | // TODO: Colors? 755 | let nick = if let Ok(current_member) = guild.id.member(ctx, current_user.id) { 756 | format!("@{}", current_member.display_name()) 757 | } else { 758 | format!("@{}", current_user.name) 759 | }; 760 | 761 | let channels = guild.id.channels(ctx).expect("Unable to fetch channels"); 762 | on_main(move |weechat| { 763 | for channel_id in channels.keys() { 764 | let string_channel = utils::buffer_id_for_channel(Some(guild.id), *channel_id); 765 | let nick = nick.to_owned(); 766 | if let Some(buffer) = weechat.buffer_search("weecord", &string_channel) { 767 | buffer.set_localvar("nick", &nick); 768 | weechat.update_bar_item("input_prompt"); 769 | } 770 | } 771 | }) 772 | } 773 | } 774 | 775 | pub fn update_member_nick(old: &Option, new: &Member) { 776 | let old_nick = if let Some(old) = old.as_ref().map(Member::display_name) { 777 | old 778 | } else { 779 | // TODO: Rebuild entire nicklist? 780 | return; 781 | }; 782 | let new_nick = new.display_name(); 783 | let new = new.clone(); 784 | let guild_id = new.guild_id; 785 | 786 | if old_nick != new_nick { 787 | let old_nick = old_nick.to_owned().to_string(); 788 | let ctx = match crate::discord::get_ctx() { 789 | Some(ctx) => ctx, 790 | _ => return, 791 | }; 792 | 793 | let channels = guild_id.channels(ctx).expect("Unable to fetch channels"); 794 | 795 | on_main(move |weechat| { 796 | let ctx = match crate::discord::get_ctx() { 797 | Some(ctx) => ctx, 798 | _ => return, 799 | }; 800 | for channel_id in channels.keys() { 801 | let string_channel = utils::buffer_id_for_channel(Some(guild_id), *channel_id); 802 | if let Some(buffer) = weechat.buffer_search("weecord", &string_channel) { 803 | if let Some(nick) = buffer.search_nick(&old_nick, None) { 804 | nick.remove(); 805 | if let Some(guild) = guild_id.to_guild_cached(&ctx) { 806 | add_member_to_nicklist( 807 | weechat, 808 | &ctx, 809 | &buffer, 810 | *channel_id, 811 | &guild, 812 | &new, 813 | false, 814 | guild_has_crown(&guild.read()), 815 | ); 816 | } 817 | } 818 | } 819 | } 820 | }) 821 | } 822 | } 823 | 824 | fn guild_has_crown(guild: &Guild) -> bool { 825 | for role in guild.roles.values() { 826 | if role.hoist && role.permissions.administrator() { 827 | return false; 828 | } 829 | } 830 | true 831 | } 832 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | buffers, discord, on_main_blocking, plugin_print, upgrade_plugin, utils, 3 | utils::{BufferExt, ChannelExt, GuildOrChannel}, 4 | weechat_utils::MessageManager, 5 | Discord, 6 | }; 7 | use lazy_static::lazy_static; 8 | use parking_lot::Mutex; 9 | use serenity::model::{gateway::Activity, user::OnlineStatus}; 10 | use std::{borrow::Cow, collections::VecDeque, sync::Arc}; 11 | use weechat::{Buffer, CommandHook, ConfigOption, ReturnCode, Weechat}; 12 | 13 | lazy_static! { 14 | // Tracks the last set status for use in setting the current game presence 15 | pub static ref LAST_STATUS: Arc> = Arc::new(Mutex::new(OnlineStatus::Online)); 16 | } 17 | 18 | pub fn init(weechat: &Weechat) -> Vec> { 19 | let mut hooks = Vec::new(); 20 | hooks.push(weechat.hook_command( 21 | CMD_DESCRIPTION, 22 | |_, buffer, args| run_command(&buffer, &args.collect::>().join(" ")), 23 | None, 24 | )); 25 | hooks.push(weechat.hook_command( 26 | weechat::CommandDescription { 27 | name: "me", 28 | description: "Send an italicized message to Discord.", 29 | args: "", 30 | args_description: "", 31 | completion: "", 32 | }, 33 | |_, buffer, args| { 34 | run_command( 35 | &buffer, 36 | &("/discord me ".to_string() + &args.skip(1).collect::>().join(" ")), 37 | ) 38 | }, 39 | None, 40 | )); 41 | return hooks; 42 | } 43 | 44 | #[derive(Clone, Debug)] 45 | pub struct Args<'a> { 46 | pub base: &'a str, 47 | pub args: VecDeque<&'a str>, 48 | pub rest: &'a str, 49 | } 50 | 51 | impl<'a> Args<'a> { 52 | pub fn from_cmd(cmd: &'a str) -> Args<'a> { 53 | let mut args: VecDeque<_> = cmd.split(' ').skip(1).collect(); 54 | if args.is_empty() { 55 | return Args { 56 | base: "", 57 | args: VecDeque::new(), 58 | rest: "", 59 | }; 60 | } 61 | let base = args.remove(0).unwrap(); 62 | Args { 63 | base, 64 | args, 65 | rest: &cmd["/discord ".len() + base.len()..].trim(), 66 | } 67 | } 68 | } 69 | 70 | fn run_command(buffer: &Buffer, cmd: &str) { 71 | let weechat = buffer.get_weechat(); 72 | let weecord = upgrade_plugin(&weechat); 73 | 74 | let args = Args::from_cmd(cmd); 75 | 76 | if args.base.is_empty() { 77 | plugin_print("no action provided."); 78 | plugin_print("see /help discord for more information"); 79 | return; 80 | } 81 | 82 | match args.base { 83 | "connect" => weecord.connect(), 84 | "disconnect" => disconnect(weecord), 85 | "irc-mode" => irc_mode(weecord), 86 | "discord-mode" => discord_mode(weecord), 87 | "token" => token(weecord, &args), 88 | "autostart" => autostart(weecord), 89 | "noautostart" => noautostart(weecord), 90 | "query" => { 91 | crate::hook::handle_query(&args); 92 | }, 93 | "join" => { 94 | join(weecord, &args, true); 95 | }, 96 | "watch" => watch(weecord, &args), 97 | "nowatch" => nowatch(weecord, &args), 98 | "watched" => watched(weecord), 99 | "autojoin" => autojoin(weecord, &args, buffer), 100 | "noautojoin" => noautojoin(weecord, &args), 101 | "autojoined" => autojoined(weecord), 102 | "status" => status(&args), 103 | "pins" | "pinned" => pins(weecord, buffer), 104 | "game" => game(&args), 105 | "upload" => upload(&args, buffer), 106 | "me" | "tableflip" | "unflip" | "shrug" | "spoiler" => { 107 | discord_fmt(args.base, args.rest, buffer) 108 | }, 109 | "rehistory" => { 110 | let buffer_name = buffer.get_name().to_string(); 111 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&buffer_name) { 112 | rehistory(weecord, &args, &*buffer); 113 | } 114 | }, 115 | _ => { 116 | plugin_print("Unknown command"); 117 | }, 118 | }; 119 | } 120 | 121 | fn disconnect(_weechat: &Weechat) { 122 | let mut discord = crate::discord::DISCORD.lock(); 123 | if discord.is_some() { 124 | if let Some(discord) = discord.take() { 125 | discord.shutdown(); 126 | }; 127 | plugin_print("Disconnected"); 128 | } else { 129 | plugin_print("Already disconnected"); 130 | } 131 | } 132 | 133 | fn irc_mode(weechat: &Weechat) { 134 | if crate::utils::get_irc_mode(weechat) { 135 | plugin_print("irc-mode already enabled") 136 | } else { 137 | let weecord = crate::upgrade_plugin(weechat); 138 | let before = weecord.config.irc_mode.value(); 139 | let change = weecord.config.irc_mode.set(true); 140 | format_option_change("irc_mode", "true", Some(&before), change); 141 | plugin_print("irc-mode enabled") 142 | } 143 | } 144 | 145 | fn discord_mode(weechat: &Weechat) { 146 | if !crate::utils::get_irc_mode(weechat) { 147 | plugin_print("discord-mode already enabled") 148 | } else { 149 | let weecord = crate::upgrade_plugin(weechat); 150 | let before = weecord.config.irc_mode.value(); 151 | let change = weecord.config.irc_mode.set(false); 152 | format_option_change("irc_mode", "false", Some(&before), change); 153 | plugin_print("discord-mode enabled") 154 | } 155 | } 156 | 157 | fn token(weechat: &Weechat, args: &Args) { 158 | if args.args.is_empty() { 159 | plugin_print("token requires an argument"); 160 | } else { 161 | let weecord = crate::upgrade_plugin(weechat); 162 | let new_value = args.rest.trim_matches('"'); 163 | weecord.config.token.set(new_value); 164 | 165 | plugin_print("Set Discord token"); 166 | } 167 | } 168 | 169 | fn autostart(weechat: &Weechat) { 170 | crate::upgrade_plugin(weechat).config.autostart.set(true); 171 | plugin_print("Discord will now load on startup"); 172 | } 173 | 174 | fn noautostart(weechat: &Weechat) { 175 | crate::upgrade_plugin(weechat).config.autostart.set(false); 176 | plugin_print("Discord will not load on startup"); 177 | } 178 | 179 | pub(crate) fn join(_weechat: &Weechat, args: &Args, verbose: bool) -> ReturnCode { 180 | if args.args.is_empty() && verbose { 181 | plugin_print("join requires an guild name and optional channel name"); 182 | ReturnCode::Error 183 | } else { 184 | let mut args = args.args.iter(); 185 | let guild_name = match args.next() { 186 | Some(g) => g, 187 | None => return ReturnCode::Error, 188 | }; 189 | let channel_name = args.next(); 190 | 191 | let ctx = match discord::get_ctx() { 192 | Some(ctx) => ctx, 193 | _ => return ReturnCode::Error, 194 | }; 195 | 196 | if let Some(channel_name) = channel_name { 197 | if let Some((guild, channel)) = 198 | crate::utils::search_channel(&ctx.cache, guild_name, channel_name) 199 | { 200 | let guild = guild.read(); 201 | buffers::create_guild_buffer(guild.id, &guild.name); 202 | // TODO: Add correct nick handling 203 | buffers::create_buffer_from_channel( 204 | &ctx.cache, 205 | &guild.name, 206 | &channel.read(), 207 | &ctx.cache.read().user.name, 208 | false, 209 | ); 210 | return ReturnCode::OkEat; 211 | } 212 | } else if let Some(guild) = crate::utils::search_guild(&ctx.cache, guild_name) { 213 | let guild = guild.read(); 214 | let guild_id = guild.id; 215 | drop(guild); 216 | 217 | let channels = utils::flatten_guilds(&ctx, &[GuildOrChannel::Guild(guild_id)]); 218 | 219 | buffers::create_buffers_from_flat_items(&ctx, &ctx.cache.read().user, &channels); 220 | return ReturnCode::OkEat; 221 | } 222 | if verbose { 223 | plugin_print("Couldn't find channel"); 224 | return ReturnCode::OkEat; 225 | } 226 | ReturnCode::Error 227 | } 228 | } 229 | 230 | fn resolve_channel_id(guild_name: &str, channel_name: Option<&str>) -> Option { 231 | let ctx = match discord::get_ctx() { 232 | Some(ctx) => ctx, 233 | _ => return None, 234 | }; 235 | 236 | if let Some(channel_name) = channel_name { 237 | if let Some((guild, channel)) = 238 | crate::utils::search_channel(&ctx.cache, guild_name, channel_name) 239 | { 240 | Some(crate::utils::unique_id( 241 | Some(guild.read().id), 242 | channel.read().id, 243 | )) 244 | } else { 245 | plugin_print("Unable to find server and channel"); 246 | None 247 | } 248 | } else if let Some(guild) = crate::utils::search_guild(&ctx.cache, guild_name) { 249 | Some(crate::utils::unique_guild_id(guild.read().id)) 250 | } else { 251 | plugin_print("Unable to find server"); 252 | None 253 | } 254 | } 255 | 256 | fn add_item(items: Cow, new_item: String) -> String { 257 | let mut items: Vec<_> = items.split(',').filter(|i| !i.is_empty()).collect(); 258 | items.push(&new_item); 259 | items.sort_unstable(); 260 | items.dedup(); 261 | return items.join(","); 262 | } 263 | 264 | fn remove_item(items: Cow, old_item: String) -> String { 265 | let items: Vec<_> = items 266 | .split(',') 267 | .filter(|i| !i.is_empty() && i != &old_item.as_str()) 268 | .collect(); 269 | return items.join(","); 270 | } 271 | 272 | fn watch(weechat: &Weechat, args: &Args) { 273 | if args.args.is_empty() { 274 | plugin_print("watch requires a guild name and optional channel name"); 275 | return; 276 | } 277 | let mut args = args.args.iter().filter(|i| !i.is_empty()); 278 | let guild_name = match args.next() { 279 | Some(g) => g, 280 | None => return, 281 | }; 282 | let channel_name = args.next(); 283 | 284 | let new_channel_id = match resolve_channel_id(guild_name, channel_name.cloned()) { 285 | Some(cid) => cid, 286 | None => return, 287 | }; 288 | 289 | let weecord = crate::upgrade_plugin(weechat); 290 | let new_watched = add_item(weecord.config.watched_channels.value(), new_channel_id); 291 | let () = on_main_blocking(|weecord| { 292 | weecord.config.watched_channels.set(&new_watched); 293 | }); 294 | if let Some(channel_name) = channel_name { 295 | plugin_print(&format!("Now watching {} in {}", guild_name, channel_name)) 296 | } else { 297 | plugin_print(&format!("Now watching all of {}", guild_name)) 298 | } 299 | } 300 | 301 | fn nowatch(weechat: &Weechat, args: &Args) { 302 | if args.args.is_empty() { 303 | plugin_print("nowatch requires a guild name and optional channel name"); 304 | return; 305 | } 306 | let mut args = args.args.iter().filter(|i| !i.is_empty()); 307 | let guild_name = match args.next() { 308 | Some(g) => g, 309 | None => return, 310 | }; 311 | let channel_name = args.next(); 312 | 313 | let new_channel_id = match resolve_channel_id(guild_name, channel_name.cloned()) { 314 | Some(cid) => cid, 315 | None => return, 316 | }; 317 | 318 | let weecord = crate::upgrade_plugin(weechat); 319 | let new_watched = remove_item(weecord.config.watched_channels.value(), new_channel_id); 320 | let () = on_main_blocking(|weecord| { 321 | weecord.config.watched_channels.set(&new_watched); 322 | }); 323 | if let Some(channel_name) = channel_name { 324 | plugin_print(&format!( 325 | "No longer watching {} in {}", 326 | guild_name, channel_name 327 | )) 328 | } else { 329 | plugin_print(&format!("No longer watching all of {}", guild_name)) 330 | } 331 | } 332 | 333 | fn watched(weechat: &Weechat) { 334 | let mut channels = Vec::new(); 335 | let mut guilds = Vec::new(); 336 | 337 | let ctx = match discord::get_ctx() { 338 | Some(ctx) => ctx, 339 | _ => return, 340 | }; 341 | 342 | for watched_item in crate::upgrade_plugin(weechat).config.watched_channels() { 343 | match watched_item { 344 | utils::GuildOrChannel::Guild(guild) => guilds.push(guild), 345 | utils::GuildOrChannel::Channel(guild, channel) => channels.push((guild, channel)), 346 | } 347 | } 348 | 349 | if guilds.is_empty() && channels.is_empty() { 350 | weechat.print("There are no watched guilds or channels"); 351 | return; 352 | } 353 | 354 | weechat.print(""); 355 | 356 | weechat.print(&format!("Watched Servers: ({})", guilds.len())); 357 | for guild in guilds { 358 | if let Some(guild) = guild.to_guild_cached(ctx) { 359 | weechat.print(&format!(" {}", guild.read().name)); 360 | } 361 | } 362 | 363 | weechat.print(&format!("Watched Channels: ({})", channels.len())); 364 | for (guild, channel) in channels { 365 | if let Ok(channel) = channel.to_channel(ctx) { 366 | let channel_name = channel.name(); 367 | if let Some(guild) = guild { 368 | let guild_name = if let Some(guild) = guild.to_guild_cached(&ctx) { 369 | guild.read().name.to_owned() 370 | } else { 371 | guild.0.to_string() 372 | }; 373 | weechat.print(&format!(" {}: {}", guild_name, channel_name)); 374 | } else { 375 | weechat.print(&format!(" {}", channel_name)); 376 | } 377 | } else { 378 | weechat.print(&format!(" {:?} {:?}", guild, channel)); 379 | } 380 | } 381 | } 382 | 383 | fn autojoin(weechat: &Weechat, args: &Args, buffer: &Buffer) { 384 | if args.args.is_empty() { 385 | plugin_print("autojoin requires a guild name and optional channel name"); 386 | return; 387 | } 388 | let mut opts = args.args.iter().filter(|i| !i.is_empty()); 389 | let guild_name = match opts.next() { 390 | Some(g) => g, 391 | None => return, 392 | }; 393 | let channel_name = opts.next(); 394 | 395 | let new_channel_id = match resolve_channel_id(guild_name, channel_name.cloned()) { 396 | Some(cid) => cid, 397 | None => return, 398 | }; 399 | 400 | let weecord = crate::upgrade_plugin(weechat); 401 | let new_autojoined = add_item(weecord.config.autojoin_channels.value(), new_channel_id); 402 | weecord.config.autojoin_channels.set(&new_autojoined); 403 | 404 | if let Some(channel_name) = channel_name { 405 | plugin_print(&format!( 406 | "Now autojoining {} in {}", 407 | guild_name, channel_name 408 | )); 409 | run_command(buffer, &format!("/discord join {}", args.rest)); 410 | } else { 411 | plugin_print(&format!("Now autojoining all channels in {}", guild_name)) 412 | } 413 | } 414 | 415 | fn noautojoin(weechat: &Weechat, args: &Args) { 416 | if args.args.is_empty() { 417 | plugin_print("noautojoin requires a guild name and optional channel name"); 418 | return; 419 | } 420 | let mut opts = args.args.iter().filter(|i| !i.is_empty()); 421 | let guild_name = match opts.next() { 422 | Some(g) => g, 423 | None => return, 424 | }; 425 | let channel_name = opts.next(); 426 | 427 | let channel_id = match resolve_channel_id(guild_name, channel_name.cloned()) { 428 | Some(cid) => cid, 429 | None => return, 430 | }; 431 | 432 | let weecord = crate::upgrade_plugin(weechat); 433 | let new_autojoined = remove_item(weecord.config.autojoin_channels.value(), channel_id); 434 | weecord.config.autojoin_channels.set(&new_autojoined); 435 | 436 | if let Some(channel_name) = channel_name { 437 | plugin_print(&format!( 438 | "No longer autojoining {} in {}", 439 | guild_name, channel_name 440 | )); 441 | } else { 442 | plugin_print(&format!( 443 | "No longer autojoining all channels in {}", 444 | guild_name 445 | )) 446 | } 447 | } 448 | 449 | fn autojoined(weechat: &Weechat) { 450 | let mut channels = Vec::new(); 451 | let mut guilds = Vec::new(); 452 | 453 | let ctx = match discord::get_ctx() { 454 | Some(ctx) => ctx, 455 | _ => return, 456 | }; 457 | 458 | for autojoined_item in crate::upgrade_plugin(weechat).config.autojoin_channels() { 459 | match autojoined_item { 460 | utils::GuildOrChannel::Guild(guild) => guilds.push(guild), 461 | utils::GuildOrChannel::Channel(guild, channel) => channels.push((guild, channel)), 462 | } 463 | } 464 | 465 | if guilds.is_empty() && channels.is_empty() { 466 | weechat.print("There are no guilds or channels set to autojoin"); 467 | return; 468 | } 469 | 470 | weechat.print(""); 471 | 472 | weechat.print(&format!("Autojoin Servers: ({})", guilds.len())); 473 | for guild in guilds { 474 | if let Some(guild) = guild.to_guild_cached(ctx) { 475 | weechat.print(&format!(" {}", guild.read().name)); 476 | } 477 | } 478 | 479 | weechat.print(&format!("Autojoin Channels: ({})", channels.len())); 480 | for (guild, channel) in channels { 481 | if let Ok(channel) = channel.to_channel(ctx) { 482 | let channel_name = channel.name(); 483 | if let Some(guild) = guild { 484 | let guild_name = if let Some(guild) = guild.to_guild_cached(&ctx) { 485 | guild.read().name.to_owned() 486 | } else { 487 | guild.0.to_string() 488 | }; 489 | weechat.print(&format!(" {}: {}", guild_name, channel_name)); 490 | } else { 491 | weechat.print(&format!(" {}", channel_name)); 492 | } 493 | } else { 494 | weechat.print(&format!(" {:?} {:?}", guild, channel)); 495 | } 496 | } 497 | } 498 | 499 | fn status(args: &Args) { 500 | let ctx = match crate::discord::get_ctx() { 501 | Some(ctx) => ctx, 502 | _ => return, 503 | }; 504 | let status_str = if args.args.is_empty() { 505 | "online" 506 | } else { 507 | args.args.get(0).unwrap() 508 | }; 509 | 510 | let status = match status_str.to_lowercase().as_str() { 511 | "online" => OnlineStatus::Online, 512 | "offline" | "invisible" => OnlineStatus::Invisible, 513 | "idle" => OnlineStatus::Idle, 514 | "dnd" => OnlineStatus::DoNotDisturb, 515 | _ => { 516 | plugin_print(&format!("Unknown status \"{}\"", status_str)); 517 | return; 518 | }, 519 | }; 520 | ctx.set_presence(None, status); 521 | *LAST_STATUS.lock() = status; 522 | plugin_print(&format!("Status set to {} {:#?}", status_str, status)); 523 | } 524 | 525 | fn pins(weechat: &Discord, buffer: &Buffer) { 526 | let channel = buffer.channel_id(); 527 | 528 | let channel_id = match channel { 529 | Some(ch) => ch, 530 | None => return, 531 | }; 532 | 533 | let ctx = match crate::discord::get_ctx() { 534 | Some(ctx) => ctx, 535 | _ => return, 536 | }; 537 | 538 | let channel = match channel_id.to_channel_cached(ctx) { 539 | Some(ch) => ch, 540 | None => return, 541 | }; 542 | 543 | buffers::create_pins_buffer(weechat, &channel); 544 | buffers::load_pin_buffer_history_for_id(channel.id()); 545 | } 546 | 547 | fn game(args: &Args) { 548 | let ctx = match crate::discord::get_ctx() { 549 | Some(ctx) => ctx, 550 | _ => return, 551 | }; 552 | 553 | let activity = if args.args.is_empty() { 554 | None 555 | } else if args.args.len() == 1 { 556 | Some(Activity::playing(args.args.get(0).unwrap())) 557 | } else { 558 | let activity_type = args.args.get(0).unwrap(); 559 | let activity = &args.rest[activity_type.len() + 1..]; 560 | 561 | Some(match *activity_type { 562 | "playing" | "play" => Activity::playing(activity), 563 | "listening" => Activity::listening(activity), 564 | "watching" | "watch" => Activity::watching(activity), 565 | _ => { 566 | plugin_print(&format!("Unknown activity type \"{}\"", activity_type)); 567 | return; 568 | }, 569 | }) 570 | }; 571 | 572 | ctx.set_presence(activity, *LAST_STATUS.lock()); 573 | } 574 | 575 | fn upload(args: &Args, buffer: &Buffer) { 576 | if args.args.is_empty() { 577 | plugin_print("upload requires an argument"); 578 | } else { 579 | let mut file = args.rest.to_owned(); 580 | // TODO: Find a better way to expand paths 581 | if file.starts_with("~/") { 582 | let rest: String = file.chars().skip(2).collect(); 583 | let dir = match dirs::home_dir() { 584 | Some(dir) => dir.to_string_lossy().into_owned(), 585 | None => ".".to_owned(), 586 | }; 587 | file = format!("{}/{}", dir, rest); 588 | } 589 | let full = match std::fs::canonicalize(file) { 590 | Ok(f) => f.to_string_lossy().into_owned(), 591 | Err(e) => { 592 | plugin_print(&format!("Unable to resolve file path: {}", e)); 593 | return; 594 | }, 595 | }; 596 | let full = full.as_str(); 597 | // TODO: Check perms and file size 598 | let channel = if let Some(channel) = buffer.channel_id() { 599 | channel 600 | } else { 601 | return; 602 | }; 603 | let ctx = match crate::discord::get_ctx() { 604 | Some(ctx) => ctx, 605 | _ => return, 606 | }; 607 | match channel.send_files(ctx, vec![full], |m| m) { 608 | Ok(_) => plugin_print("File uploaded successfully"), 609 | Err(e) => { 610 | if let serenity::Error::Model(serenity::model::ModelError::MessageTooLong(_)) = e { 611 | plugin_print("File too large to upload"); 612 | } 613 | }, 614 | }; 615 | } 616 | } 617 | 618 | // rust-lang/rust#52662 would let this api be improved by accepting option types 619 | fn format_option_change( 620 | name: &str, 621 | value: &str, 622 | before: Option<&T>, 623 | change: weechat::OptionChanged, 624 | ) { 625 | use weechat::OptionChanged::*; 626 | let msg = match (change, before) { 627 | (Changed, Some(before)) => format!( 628 | "option {} successfully changed from {} to {}", 629 | name, before, value 630 | ), 631 | (Changed, None) | (Unchanged, None) => { 632 | format!("option {} successfully set to {}", name, value) 633 | }, 634 | (Unchanged, Some(before)) => format!("option {} already contained {}", name, before), 635 | (NotFound, _) => format!("option {} not found", name), 636 | (Error, Some(before)) => format!( 637 | "error when setting option {} to {} (was {})", 638 | name, value, before 639 | ), 640 | (Error, _) => format!("error when setting option {} to {}", name, value), 641 | }; 642 | 643 | plugin_print(&msg); 644 | } 645 | 646 | fn discord_fmt(cmd: &str, msg: &str, buffer: &Buffer) { 647 | let msg = match cmd { 648 | "me" => format!("_{}_", msg), 649 | "tableflip" => format!("{} (╯°□°)╯︵ ┻━┻", msg), 650 | "unflip" => format!("{} ┬─┬ ノ( ゜-゜ノ)", msg), 651 | "shrug" => format!("{} ¯\\_(ツ)_/¯", msg), 652 | "spoiler" => format!("||{}||", msg), 653 | _ => unreachable!(), 654 | }; 655 | 656 | let channel = if let Some(channel) = buffer.channel_id() { 657 | channel 658 | } else { 659 | return; 660 | }; 661 | 662 | let ctx = match crate::discord::get_ctx() { 663 | Some(ctx) => ctx, 664 | _ => return, 665 | }; 666 | let _ = channel.send_message(&ctx.http, |m| m.content(msg)); 667 | } 668 | 669 | fn rehistory(weecord: &Discord, args: &Args, buffer: &MessageManager) { 670 | buffer.clear(); 671 | let default_fetch_count = weecord.config.message_fetch_count.value(); 672 | let count = args 673 | .args 674 | .front() 675 | .and_then(|c| c.parse::().ok()) 676 | .unwrap_or(default_fetch_count); 677 | buffers::load_history(buffer, crossbeam_channel::unbounded().0, count); 678 | } 679 | 680 | const CMD_DESCRIPTION: weechat::CommandDescription = weechat::CommandDescription { 681 | name: "discord", 682 | description: "\ 683 | Discord from the comfort of your favorite command-line IRC client! 684 | Source code available at https://github.com/terminal-discord/weechat-discord 685 | Originally by https://github.com/khyperia/weechat-discord", 686 | args: " 687 | connect 688 | disconnect 689 | join 690 | query 691 | watch 692 | autojoin 693 | watched 694 | autojoined 695 | pins 696 | irc-mode 697 | discord-mode 698 | autostart 699 | noautostart 700 | token 701 | upload 702 | me 703 | tableflip 704 | unflip 705 | shrug 706 | spoiler 707 | rehistory", 708 | args_description: " 709 | connect: sign in to discord and open chat buffers 710 | disconnect: sign out of Discord 711 | join: join a channel in irc mode by providing guild name and channel name 712 | query: open a dm with a user (for when there are no discord buffers open) 713 | irc-mode: enable irc-mode, meaning that weecord will not load all channels like the official client 714 | discord-mode: enable discord-mode, meaning all available channels and guilds will be added to the buflist 715 | watch: Automatically open a buffer when a message is received in a guild or channel 716 | autojoin: Automatically open a channel or entire guild when discord connects 717 | watched: List watched guilds and channels 718 | autojoined: List autojoined guilds and channels 719 | pins: Show a list of pinned messages for the current channel 720 | autostart: automatically sign into discord on start 721 | noautostart: disable autostart 722 | status: set your Discord online status 723 | token: set Discord login token 724 | rehistory: reload the history in the current buffer 725 | upload: upload a file to the current channel 726 | 727 | Examples: 728 | /discord token 123456789ABCDEF 729 | /discord connect 730 | /discord autostart 731 | /discord disconnect 732 | /discord upload file.txt 733 | ", 734 | completion: 735 | "connect || \ 736 | disconnect || \ 737 | query %(weecord_dm_completion) || \ 738 | watch %(weecord_guild_completion) %(weecord_channel_completion) || \ 739 | nowatch %(weecord_guild_completion) %(weecord_channel_completion) || \ 740 | watched || \ 741 | autojoined || \ 742 | autojoin %(weecord_guild_completion) %(weecord_channel_completion) || \ 743 | noautojoin %(weecord_guild_completion) %(weecord_channel_completion) || \ 744 | irc-mode || \ 745 | discord-mode || \ 746 | pins || \ 747 | token || \ 748 | autostart || \ 749 | noautostart || \ 750 | status online|offline|invisible|idle|dnd || \ 751 | game playing|listening|watching || \ 752 | upload %(filename) || \ 753 | me || \ 754 | tableflip || \ 755 | unflip || \ 756 | shrug || \ 757 | spoiler || \ 758 | rehistory || \ 759 | join %(weecord_guild_completion) %(weecord_channel_completion)", 760 | }; 761 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::{utils, utils::GuildOrChannel}; 2 | use weechat::{ 3 | BooleanOption, ConfigOption, ConfigSectionInfo, IntegerOption, StringOption, Weechat, 4 | }; 5 | 6 | pub struct Config { 7 | pub token: StringOption, 8 | pub watched_channels: StringOption, 9 | pub autojoin_channels: StringOption, 10 | pub autostart: BooleanOption, 11 | pub use_presence: BooleanOption, 12 | pub send_typing_events: BooleanOption, 13 | pub irc_mode: BooleanOption, 14 | pub message_fetch_count: IntegerOption, 15 | pub user_typing_list_max: IntegerOption, 16 | pub user_typing_list_expanded: BooleanOption, 17 | pub config: weechat::Config<()>, 18 | } 19 | 20 | pub fn init(weechat: &Weechat) -> Config { 21 | let mut config = weechat.config_new("weecord", None, None); 22 | 23 | let section_info: ConfigSectionInfo<()> = ConfigSectionInfo { 24 | name: "main", 25 | ..Default::default() 26 | }; 27 | 28 | let section = config.new_section(section_info); 29 | 30 | let token = section.new_string_option( 31 | "token", 32 | "Discord auth token. Supports secure data", 33 | "", 34 | "", 35 | false, 36 | None, 37 | None::<()>, 38 | ); 39 | 40 | let watched_channels = section.new_string_option( 41 | "watched_channels", 42 | "List of channels to open when a message is received", 43 | "", 44 | "", 45 | false, 46 | None, 47 | None::<()>, 48 | ); 49 | 50 | let autojoin_channels = section.new_string_option( 51 | "autojoin_channels", 52 | "List of channels to automatically open on connecting (irc mode only)", 53 | "", 54 | "", 55 | false, 56 | None, 57 | None::<()>, 58 | ); 59 | 60 | let autostart = section.new_boolean_option( 61 | "autostart", 62 | "Automatically connect to Discord when weechat starts", 63 | false, 64 | false, 65 | false, 66 | None, 67 | None::<()>, 68 | ); 69 | 70 | let use_presence = section.new_boolean_option( 71 | "use_presence", 72 | "Show the presence of other users in the nicklist", 73 | false, 74 | false, 75 | false, 76 | None, 77 | None::<()>, 78 | ); 79 | 80 | let send_typing_events = section.new_boolean_option( 81 | "send_typing_events", 82 | "Send typing events to the channel", 83 | false, 84 | false, 85 | false, 86 | None, 87 | None::<()>, 88 | ); 89 | 90 | let irc_mode = section.new_boolean_option( 91 | "irc_mode", 92 | r#"Enable "IRC-Mode" where only the channels you choose will be automatically joined"#, 93 | false, 94 | false, 95 | false, 96 | None, 97 | None::<()>, 98 | ); 99 | 100 | let message_fetch_count = section.new_integer_option( 101 | "message_load_count", 102 | "How many messages will be fetched when a buffer is loaded", 103 | "", 104 | 0, 105 | 100, 106 | "25", 107 | "25", 108 | false, 109 | None, 110 | None::<()>, 111 | ); 112 | 113 | let user_typing_list_max = section.new_integer_option( 114 | "user_typing_list_max", 115 | "How many users will be displayed at most in the typing indicator", 116 | "", 117 | 0, 118 | 100, 119 | "3", 120 | "3", 121 | false, 122 | None, 123 | None::<()>, 124 | ); 125 | 126 | let user_typing_list_expanded = section.new_boolean_option( 127 | "user_typing_list_expanded", 128 | "Format the typing list more like the electron client", 129 | false, 130 | false, 131 | false, 132 | None, 133 | None::<()>, 134 | ); 135 | 136 | config.read(); 137 | 138 | Config { 139 | token, 140 | watched_channels, 141 | autojoin_channels, 142 | autostart, 143 | use_presence, 144 | send_typing_events, 145 | irc_mode, 146 | message_fetch_count, 147 | user_typing_list_max, 148 | user_typing_list_expanded, 149 | config, 150 | } 151 | } 152 | 153 | impl Config { 154 | pub fn autojoin_channels(&self) -> Vec { 155 | self.autojoin_channels 156 | .value() 157 | .split(',') 158 | .filter(|i| !i.is_empty()) 159 | .filter_map(utils::parse_id) 160 | .collect() 161 | } 162 | 163 | pub fn watched_channels(&self) -> Vec { 164 | self.watched_channels 165 | .value() 166 | .split(',') 167 | .filter(|i| !i.is_empty()) 168 | .filter_map(utils::parse_id) 169 | .collect() 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/discord/client.rs: -------------------------------------------------------------------------------- 1 | use super::event_handler::Handler; 2 | use crate::Discord; 3 | use serenity::{client::bridge::gateway::ShardManager, model::gateway::Ready, prelude::*}; 4 | use std::{ 5 | sync::{mpsc, Arc}, 6 | thread, 7 | }; 8 | 9 | pub struct DiscordClient { 10 | shard_manager: Arc>, 11 | } 12 | 13 | impl DiscordClient { 14 | pub fn start( 15 | weecord: &Discord, 16 | token: &str, 17 | ) -> Result<(DiscordClient, mpsc::Receiver), serenity::Error> { 18 | let (tx, rx) = mpsc::channel(); 19 | let handler = Handler::new(weecord, Arc::new(Mutex::new(tx))); 20 | 21 | let mut client = Client::new(token, handler)?; 22 | 23 | let shard_manager = client.shard_manager.clone(); 24 | thread::spawn(move || { 25 | if let Err(e) = client.start_shards(1) { 26 | crate::on_main(move |weecord| { 27 | weecord.print(&format!( 28 | "discord: An error occurred connecting to discord: {}", 29 | e 30 | )); 31 | }); 32 | } 33 | }); 34 | Ok((DiscordClient { shard_manager }, rx)) 35 | } 36 | 37 | pub fn shutdown(&self) { 38 | self.shard_manager.lock().shutdown_all(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/discord/event_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | buffers, discord, on_main, on_main_blocking, utils, weechat_utils::MessageManager, Discord, 3 | }; 4 | use lazy_static::lazy_static; 5 | use serenity::{ 6 | cache::CacheRwLock, 7 | model::{gateway::Ready, prelude::*}, 8 | prelude::*, 9 | }; 10 | use std::{ 11 | collections::HashMap, 12 | sync::{mpsc::Sender, Arc}, 13 | thread, 14 | time::{Duration, SystemTime, UNIX_EPOCH}, 15 | }; 16 | 17 | const MAX_TYPING_EVENTS: usize = 50; 18 | 19 | #[derive(Debug, PartialEq, Eq, Ord)] 20 | pub struct TypingEntry { 21 | pub channel_id: ChannelId, 22 | pub guild_id: Option, 23 | pub user: UserId, 24 | pub user_name: String, 25 | pub time: u64, 26 | } 27 | 28 | impl PartialOrd for TypingEntry { 29 | fn partial_cmp(&self, other: &Self) -> Option { 30 | self.time.partial_cmp(&other.time) 31 | } 32 | } 33 | 34 | pub struct TypingTracker { 35 | pub entries: Vec, 36 | } 37 | 38 | impl TypingTracker { 39 | /// Remove any expired entries 40 | pub fn sweep(&mut self) { 41 | let now = SystemTime::now(); 42 | let timestamp_now = now 43 | .duration_since(UNIX_EPOCH) 44 | .expect("Time went backwards") 45 | .as_secs() as u64; 46 | 47 | // If the entry is more than 10 seconds old, remove it 48 | // TODO: Use binary heap or other structure for better performance? 49 | self.entries.retain(|e| timestamp_now - e.time < 10) 50 | } 51 | } 52 | 53 | lazy_static! { 54 | pub static ref TYPING_EVENTS: Arc> = Arc::new(Mutex::new(TypingTracker { 55 | entries: Vec::new(), 56 | })); 57 | } 58 | 59 | pub struct Handler { 60 | sender: Arc>>, 61 | watched_channels: Vec, 62 | } 63 | 64 | impl Handler { 65 | pub fn new(weecord: &Discord, sender: Arc>>) -> Handler { 66 | let watched_channels = weecord.config.watched_channels(); 67 | 68 | Handler { 69 | sender, 70 | watched_channels, 71 | } 72 | } 73 | } 74 | 75 | impl EventHandler for Handler { 76 | fn channel_create(&self, _ctx: Context, channel: Arc>) { 77 | let channel = channel.read(); 78 | print_guild_status_message( 79 | channel.guild_id, 80 | &format!( 81 | "New {} channel `{}` created", 82 | channel.kind.name(), 83 | channel.name() 84 | ), 85 | ); 86 | } 87 | 88 | fn channel_delete(&self, _ctx: Context, channel: Arc>) { 89 | let channel = channel.read(); 90 | print_guild_status_message( 91 | channel.guild_id, 92 | &format!("Channel `{}` deleted", channel.name()), 93 | ); 94 | } 95 | 96 | fn channel_pins_update(&self, _ctx: Context, pin: ChannelPinsUpdateEvent) { 97 | buffers::load_pin_buffer_history_for_id(pin.channel_id); 98 | } 99 | 100 | fn channel_update(&self, ctx: Context, old: Option, new: Channel) { 101 | // TODO: Notify more events? 102 | // * Groups: user learve/join 103 | // * guild channel: ? 104 | match new { 105 | Channel::Category(new) => { 106 | // TODO: old doesn't ever seem to be available 107 | if let Some(old) = old.and_then(Channel::category) { 108 | let new = new.read(); 109 | let old = old.read(); 110 | 111 | let guild_id = new 112 | .id 113 | .to_channel_cached(&ctx) 114 | .and_then(Channel::guild) 115 | .map(|ch| ch.read().guild_id); 116 | 117 | if let Some(guild_id) = guild_id { 118 | if new.name != old.name { 119 | print_guild_status_message( 120 | guild_id, 121 | &format!("Category `{}` renamed to `{}`", old.name, new.name), 122 | ); 123 | } 124 | } 125 | } 126 | }, 127 | Channel::Guild(new) => { 128 | if let Some(old) = old.and_then(Channel::guild) { 129 | let new = new.read(); 130 | let old = old.read(); 131 | 132 | if new.name != old.name { 133 | print_guild_status_message( 134 | new.guild_id, 135 | &format!("Category `{}` renamed to `{}`", old.name, new.name), 136 | ); 137 | } 138 | } 139 | }, 140 | _ => {}, 141 | } 142 | } 143 | 144 | fn guild_member_update(&self, ctx: Context, old: Option, new: Member) { 145 | thread::spawn(move || { 146 | buffers::update_member_nick(&old, &new); 147 | if ctx.cache.read().user.id == new.user_id() { 148 | buffers::update_nick(); 149 | } 150 | }); 151 | } 152 | 153 | fn guild_members_chunk( 154 | &self, 155 | ctx: Context, 156 | guild_id: GuildId, 157 | _offline_members: HashMap, 158 | nonce: Option, 159 | ) { 160 | on_main(move |weecord| { 161 | if let Some(channel_id) = nonce { 162 | if let Ok(channel_id) = channel_id.parse::().map(|id| ChannelId(id)) { 163 | if let Some(buffer) = weecord 164 | .buffer_manager 165 | .get_buffer(&utils::buffer_id_for_channel(Some(guild_id), channel_id)) 166 | { 167 | buffer.redraw_buffer(&ctx.cache); 168 | } 169 | } 170 | } 171 | }); 172 | } 173 | 174 | fn message(&self, ctx: Context, msg: Message) { 175 | let string_channel = utils::buffer_id_for_channel(msg.guild_id, msg.channel_id); 176 | let () = on_main_blocking(move |weecord| { 177 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&string_channel) { 178 | print_message(&ctx.cache, &msg, &buffer); 179 | } else { 180 | match msg.channel_id.to_channel(&ctx) { 181 | chan @ Ok(Channel::Private(_)) => { 182 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&string_channel) { 183 | print_message(&ctx.cache, &msg, &buffer); 184 | } else { 185 | buffers::create_buffer_from_dm( 186 | &ctx.cache, 187 | &weecord, 188 | chan.unwrap(), 189 | &ctx.cache.read().user.name, 190 | false, 191 | ); 192 | } 193 | }, 194 | chan @ Ok(Channel::Group(_)) => { 195 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&string_channel) { 196 | print_message(&ctx.cache, &msg, &buffer); 197 | } else { 198 | buffers::create_buffer_from_group( 199 | &ctx.cache, 200 | &weecord, 201 | chan.unwrap(), 202 | &ctx.cache.read().user.name, 203 | ); 204 | } 205 | }, 206 | Ok(Channel::Guild(channel)) => { 207 | // Check that the channel is on the watch list 208 | let channel = channel.read(); 209 | 210 | for watched in &self.watched_channels { 211 | use utils::GuildOrChannel::*; 212 | let add = match watched { 213 | Channel(_, channel_id) => *channel_id == channel.id, 214 | Guild(guild_id) => *guild_id == channel.guild_id, 215 | }; 216 | if add { 217 | let guild = match channel.guild_id.to_guild_cached(&ctx.cache) { 218 | Some(guild) => guild, 219 | None => return, 220 | }; 221 | 222 | let current_user = ctx.cache.read().user.clone(); 223 | let guild = guild.read(); 224 | 225 | // TODO: Colors? 226 | let nick = if let Ok(current_member) = 227 | guild.id.member(&ctx, current_user.id) 228 | { 229 | format!("@{}", current_member.display_name()) 230 | } else { 231 | format!("@{}", current_user.name) 232 | }; 233 | 234 | buffers::create_guild_buffer(guild.id, &guild.name); 235 | // TODO: Muting 236 | buffers::create_buffer_from_channel( 237 | &ctx.cache, 238 | &guild.name, 239 | &channel, 240 | &nick, 241 | false, 242 | ); 243 | break; 244 | } 245 | } 246 | }, 247 | _ => {}, 248 | } 249 | } 250 | }); 251 | } 252 | 253 | fn message_delete(&self, ctx: Context, channel_id: ChannelId, deleted_message_id: MessageId) { 254 | delete_message(&ctx, channel_id, deleted_message_id) 255 | } 256 | 257 | fn message_delete_bulk( 258 | &self, 259 | ctx: Context, 260 | channel_id: ChannelId, 261 | deleted_messages_ids: Vec, 262 | ) { 263 | for message_id in deleted_messages_ids { 264 | delete_message(&ctx, channel_id, message_id) 265 | } 266 | } 267 | 268 | fn message_update( 269 | &self, 270 | ctx: Context, 271 | _old_if_available: Option, 272 | _new: Option, 273 | event: MessageUpdateEvent, 274 | ) { 275 | let (guild_id, channel_id, message_id) = match ctx.cache.read().channel(&event.channel_id) { 276 | Some(Channel::Guild(channel)) => { 277 | let channel = channel.read(); 278 | (Some(channel.guild_id), channel.id, event.id) 279 | }, 280 | Some(Channel::Group(channel)) => (None, channel.read().channel_id, event.id), 281 | Some(Channel::Private(channel)) => (None, channel.read().id, event.id), 282 | _ => return, 283 | }; 284 | 285 | let buffer_name = utils::buffer_id_for_channel(guild_id, channel_id); 286 | 287 | thread::spawn(move || { 288 | on_main(move |weecord| { 289 | let ctx = match crate::discord::get_ctx() { 290 | Some(ctx) => ctx, 291 | _ => return, 292 | }; 293 | let msg = match channel_id 294 | .messages(ctx, |retriever| retriever.limit(1).around(message_id)) 295 | .ok() 296 | .and_then(|mut msgs| msgs.pop()) 297 | { 298 | Some(msgs) => msgs, 299 | None => return, 300 | }; 301 | 302 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&buffer_name) { 303 | buffer.replace_message(&ctx.cache, &message_id, &msg); 304 | } 305 | }); 306 | }); 307 | } 308 | 309 | fn reaction_add(&self, ctx: Context, reaction: Reaction) { 310 | reaction_update(ctx, reaction) 311 | } 312 | 313 | fn reaction_remove(&self, ctx: Context, reaction: Reaction) { 314 | reaction_update(ctx, reaction) 315 | } 316 | 317 | fn ready(&self, ctx: Context, ready: Ready) { 318 | // Cache seems not to have all fields properly populated 319 | 320 | ctx.shard 321 | .chunk_guilds(ready.guilds.iter().map(GuildStatus::id), None, None); 322 | { 323 | let mut ctx_lock = ctx.cache.write(); 324 | for (&id, channel) in &ready.private_channels { 325 | if let Some(pc) = channel.clone().private() { 326 | ctx_lock.private_channels.insert(id, pc); 327 | } 328 | } 329 | for guild in &ready.guilds { 330 | if let GuildStatus::OnlineGuild(guild) = guild { 331 | for (id, pres) in guild.presences.clone() { 332 | ctx_lock.presences.insert(id, pres); 333 | } 334 | 335 | // TODO: Why are channels not populated by serenity? 336 | for (id, chan) in guild.channels.clone() { 337 | ctx_lock.channels.insert(id, chan); 338 | } 339 | } 340 | } 341 | } 342 | if let Some(presence) = ctx.cache.read().presences.get(&ready.user.id) { 343 | *crate::command::LAST_STATUS.lock() = presence.status; 344 | } 345 | 346 | unsafe { 347 | crate::discord::CONTEXT = Some(ctx); 348 | } 349 | let _ = self.sender.lock().send(ready); 350 | } 351 | 352 | fn typing_start(&self, ctx: Context, event: TypingStartEvent) { 353 | // TODO: Do we want to fetch the user if it isn't cached? (check performance) 354 | let current_user_id = ctx.cache.read().user.id; 355 | 356 | if let Some(user) = event.user_id.to_user_cached(&ctx.cache) { 357 | let user = user.read(); 358 | if user.id == current_user_id { 359 | return; 360 | } 361 | // TODO: Resolve guild nick names 362 | let mut typing_events = TYPING_EVENTS.lock(); 363 | typing_events.entries.push(TypingEntry { 364 | channel_id: event.channel_id, 365 | guild_id: event.guild_id, 366 | user: event.user_id, 367 | user_name: user.name.clone(), 368 | time: event.timestamp, 369 | }); 370 | 371 | typing_events.sweep(); 372 | if typing_events.entries.len() > MAX_TYPING_EVENTS { 373 | typing_events.entries.pop(); 374 | } 375 | 376 | crate::on_main(|weechat| { 377 | weechat.update_bar_item("discord_typing"); 378 | }); 379 | 380 | thread::Builder::new() 381 | .name("Typing indicator updater".into()) 382 | .spawn(|| { 383 | // Wait a few seconds, then sweep the list and update the bar item 384 | thread::sleep(Duration::from_secs(10)); 385 | 386 | let mut typing_events = TYPING_EVENTS.lock(); 387 | typing_events.sweep(); 388 | crate::on_main(|weechat| { 389 | weechat.update_bar_item("discord_typing"); 390 | }); 391 | }) 392 | .expect("Unable to name thread"); 393 | } 394 | } 395 | 396 | fn user_update(&self, _ctx: Context, _old: CurrentUser, _new: CurrentUser) { 397 | thread::spawn(|| { 398 | // TODO: Update nicklist (and/or just rework all nick stuff) 399 | buffers::update_nick(); 400 | }); 401 | } 402 | } 403 | 404 | fn delete_message(ctx: &Context, channel_id: ChannelId, deleted_message_id: MessageId) { 405 | if let Some(channel) = ctx.cache.read().channels.get(&channel_id) { 406 | let guild_id = channel.read().guild_id; 407 | let buffer_name = utils::buffer_id_for_channel(Some(guild_id), channel_id); 408 | 409 | on_main(move |weecord| { 410 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&buffer_name) { 411 | let ctx = match discord::get_ctx() { 412 | Some(ctx) => ctx, 413 | _ => return, 414 | }; 415 | 416 | buffer.delete_message(&ctx.cache, &deleted_message_id); 417 | } 418 | }); 419 | } 420 | } 421 | 422 | fn reaction_update(ctx: Context, reaction: Reaction) { 423 | let guild_id = match ctx.cache.read().channel(&reaction.channel_id) { 424 | Some(Channel::Guild(channel)) => { 425 | let channel = channel.read(); 426 | Some(channel.guild_id) 427 | }, 428 | _ => return, 429 | }; 430 | let buffer_name = utils::buffer_id_for_channel(guild_id, reaction.channel_id); 431 | thread::spawn(move || { 432 | on_main(move |weecord| { 433 | if let Some(buffer) = weecord.buffer_manager.get_buffer(&buffer_name) { 434 | let ctx = match crate::discord::get_ctx() { 435 | Some(ctx) => ctx, 436 | _ => return, 437 | }; 438 | let msg = match reaction 439 | .channel_id 440 | .messages(ctx, |retriever| { 441 | retriever.limit(1).around(reaction.message_id) 442 | }) 443 | .ok() 444 | .and_then(|mut msgs| msgs.pop()) 445 | { 446 | Some(msgs) => msgs, 447 | None => return, 448 | }; 449 | buffer.replace_message(&ctx.cache, &reaction.message_id, &msg); 450 | } 451 | }); 452 | }); 453 | } 454 | 455 | fn print_message(cache: &CacheRwLock, msg: &Message, buffer: &MessageManager) { 456 | let muted = utils::buffer_is_muted(&buffer); 457 | let notify = !msg.is_own(cache) && !muted; 458 | buffer.add_message(cache, &msg, notify); 459 | } 460 | 461 | fn print_guild_status_message(guild_id: GuildId, msg: &str) { 462 | let buffer_id = utils::buffer_id_for_guild(guild_id); 463 | 464 | let msg = msg.to_owned(); 465 | on_main(move |weechat| { 466 | if let Some(buffer) = weechat.buffer_search("weecord", &buffer_id) { 467 | let prefix = weechat.get_prefix("network").to_owned(); 468 | buffer.print(&(prefix + "\t" + msg.as_ref())); 469 | } 470 | }) 471 | } 472 | -------------------------------------------------------------------------------- /src/discord/formatting.rs: -------------------------------------------------------------------------------- 1 | use parsing::{self, MarkdownNode}; 2 | use std::{rc::Rc, sync::RwLock}; 3 | use weechat::Weechat; 4 | 5 | pub fn discord_to_weechat(weechat: &Weechat, msg: &str) -> String { 6 | let ast = parsing::parse_markdown(msg); 7 | 8 | let mut out = String::new(); 9 | for node in &ast.0 { 10 | out.push_str(&discord_to_weechat_reducer( 11 | &weechat, 12 | &*node.read().unwrap(), 13 | )) 14 | } 15 | out 16 | } 17 | 18 | fn collect_styles(weechat: &Weechat, styles: &[Rc>]) -> String { 19 | styles 20 | .iter() 21 | .map(|s| discord_to_weechat_reducer(&weechat, &*s.read().unwrap())) 22 | .collect::>() 23 | .join("") 24 | } 25 | 26 | // TODO: Spoilers, code syntax highlighting? 27 | // TODO: if the whole line is wrapped in *, render as CTCP ACTION rather than 28 | // as fully italicized message. 29 | fn discord_to_weechat_reducer(weechat: &Weechat, node: &MarkdownNode) -> String { 30 | use MarkdownNode::*; 31 | match node { 32 | Bold(styles) => format!( 33 | "{}{}{}", 34 | weechat.color("bold"), 35 | collect_styles(weechat, styles), 36 | weechat.color("-bold") 37 | ), 38 | Italic(styles) => format!( 39 | "{}{}{}", 40 | weechat.color("italic"), 41 | collect_styles(weechat, styles), 42 | weechat.color("-italic") 43 | ), 44 | Underline(styles) => format!( 45 | "{}{}{}", 46 | weechat.color("underline"), 47 | collect_styles(weechat, styles), 48 | weechat.color("-underline") 49 | ), 50 | Strikethrough(styles) => format!( 51 | "{}~~{}~~{}", 52 | weechat.color("red"), 53 | collect_styles(weechat, styles), 54 | weechat.color("-red") 55 | ), 56 | Spoiler(styles) => format!( 57 | "{}||{}||{}", 58 | weechat.color("italic"), 59 | collect_styles(weechat, styles), 60 | weechat.color("-italic") 61 | ), 62 | Text(string) => string.to_owned(), 63 | InlineCode(string) => format!( 64 | "{}{}{}", 65 | weechat.color("*8"), 66 | string, 67 | weechat.color("reset") 68 | ), 69 | Code(language, text) => { 70 | let (fmt, reset) = (weechat.color("*8"), weechat.color("reset")); 71 | 72 | format!( 73 | "```{}\n{}\n```", 74 | language, 75 | text.lines() 76 | .map(|l| format!("{}{}{}", fmt, l, reset)) 77 | .collect::>() 78 | .join("\n"), 79 | ) 80 | }, 81 | BlockQuote(styles) => format_block_quote(collect_styles(weechat, styles).lines()), 82 | SingleBlockQuote(styles) => format_block_quote( 83 | collect_styles(weechat, styles) 84 | .lines() 85 | .map(strip_leading_bracket), 86 | ), 87 | } 88 | } 89 | 90 | fn strip_leading_bracket(line: &str) -> &str { 91 | &line[line.find("> ").map(|x| x + 2).unwrap_or(0)..] 92 | } 93 | 94 | fn format_block_quote<'a>(lines: impl Iterator) -> String { 95 | lines.fold(String::new(), |acc, x| format!("{}▎{}\n", acc, x)) 96 | } 97 | -------------------------------------------------------------------------------- /src/discord/mod.rs: -------------------------------------------------------------------------------- 1 | use self::client::DiscordClient; 2 | use crate::Discord; 3 | use lazy_static::lazy_static; 4 | use serenity::{client::Context, prelude::Mutex}; 5 | use std::{sync::Arc, thread}; 6 | 7 | mod client; 8 | mod event_handler; 9 | pub mod formatting; 10 | 11 | pub use event_handler::TYPING_EVENTS; 12 | 13 | pub static mut CONTEXT: Option = None; 14 | 15 | pub fn get_ctx() -> Option<&'static Context> { 16 | unsafe { CONTEXT.as_ref() } 17 | } 18 | 19 | lazy_static! { 20 | pub(crate) static ref DISCORD: Arc>> = Arc::new(Mutex::new(None)); 21 | } 22 | 23 | pub fn init(weecord: &Discord, token: &str, irc_mode: bool) { 24 | let (discord_client, events) = match DiscordClient::start(weecord, token) { 25 | Ok(d) => d, 26 | Err(e) => { 27 | // Cannot use plugin_print because we haven't finished init 28 | weecord.print(&format!( 29 | "discord: An error occurred connecting to discord: {}", 30 | e 31 | )); 32 | return; 33 | }, 34 | }; 35 | 36 | thread::spawn(move || { 37 | if let Ok(ready) = events.recv() { 38 | crate::plugin_print("Discord connected"); 39 | if irc_mode { 40 | crate::buffers::create_autojoin_buffers(&ready); 41 | } else { 42 | crate::buffers::create_buffers(&ready); 43 | } 44 | } 45 | }); 46 | 47 | *DISCORD.lock() = Some(discord_client); 48 | } 49 | -------------------------------------------------------------------------------- /src/hook.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | buffers::load_pin_buffer_history, 3 | command::Args, 4 | discord, on_main, plugin_print, utils, 5 | utils::{BufferExt, ChannelExt}, 6 | }; 7 | use crossbeam_channel::unbounded; 8 | use lazy_static::lazy_static; 9 | use serenity::{ 10 | client::bridge::gateway, 11 | model::{channel::ReactionType, prelude::*}, 12 | prelude::*, 13 | }; 14 | use std::{ 15 | iter::FromIterator, 16 | sync::Arc, 17 | thread, 18 | time::{Duration, SystemTime, UNIX_EPOCH}, 19 | }; 20 | use weechat::{Buffer, CompletionPosition, ConfigOption, ReturnCode, Weechat}; 21 | 22 | lazy_static! { 23 | static ref LAST_TYPING_TIMESTAMP: Arc> = Arc::new(Mutex::new(0)); 24 | } 25 | 26 | pub struct HookHandles { 27 | _buffer_switch_handle: weechat::SignalHook<()>, 28 | _buffer_typing_handle: weechat::SignalHook<()>, 29 | _command_handles: Vec>, 30 | _query_handle: weechat::CommandRunHook<()>, 31 | _nick_handle: weechat::CommandRunHook<()>, 32 | _join_handle: weechat::CommandRunHook<()>, 33 | _guild_completion_handle: weechat::CompletionHook<()>, 34 | _channel_completion_handle: weechat::CompletionHook<()>, 35 | _dm_completion_handle: weechat::CompletionHook<()>, 36 | _nick_completion_handle: weechat::CompletionHook<()>, 37 | _role_completion_handle: weechat::CompletionHook<()>, 38 | } 39 | 40 | pub fn init(weechat: &Weechat) -> HookHandles { 41 | let _command_handles = crate::command::init(weechat); 42 | 43 | let _buffer_switch_handle = weechat.hook_signal( 44 | "buffer_switch", 45 | |_, _, value| handle_buffer_switch(value), 46 | None, 47 | ); 48 | 49 | let _buffer_typing_handle = weechat.hook_signal( 50 | "input_text_changed", 51 | |_, weechat, value| handle_buffer_typing(weechat, value), 52 | None, 53 | ); 54 | 55 | let _query_handle = weechat.hook_command_run( 56 | "/query", 57 | |_, ref buffer, ref command| { 58 | if buffer.guild_id().is_none() { 59 | return ReturnCode::Error; 60 | }; 61 | 62 | handle_query(&Args::from_cmd( 63 | &command.replace("/query ", "/discord query "), 64 | )) 65 | }, 66 | None, 67 | ); 68 | 69 | let _nick_handle = weechat.hook_command_run( 70 | "/nick", 71 | |_, ref buffer, ref command| handle_nick(buffer, command), 72 | None, 73 | ); 74 | 75 | let _join_handle = weechat.hook_command_run( 76 | "/join", 77 | |_, ref buffer, ref command| handle_join(buffer, command), 78 | None, 79 | ); 80 | 81 | let _guild_completion_handle = weechat.hook_completion( 82 | "weecord_guild_completion", 83 | "Completion for discord guilds", 84 | |_, ref buffer, _, completions| handle_guild_completion(buffer, completions), 85 | None, 86 | ); 87 | 88 | let _channel_completion_handle = weechat.hook_completion( 89 | "weecord_channel_completion", 90 | "Completion for discord channels", 91 | |_, ref buffer, _, completions| handle_channel_completion(buffer, completions), 92 | None, 93 | ); 94 | 95 | let _dm_completion_handle = weechat.hook_completion( 96 | "weecord_dm_completion", 97 | "Completion for Discord private channels", 98 | |_, ref buffer, _, completions| handle_dm_completion(buffer, completions), 99 | None, 100 | ); 101 | 102 | let _nick_completion_handle = weechat.hook_completion( 103 | "nicks", 104 | "Completion for users in a buffer", 105 | |_, ref buffer, _, completions| handle_nick_completion(buffer, completions), 106 | None, 107 | ); 108 | 109 | let _role_completion_handle = weechat.hook_completion( 110 | "weecord_role", 111 | "Completion for Discord channel roles", 112 | |_, ref buffer, _, completions| handle_role_completion(buffer, completions), 113 | None, 114 | ); 115 | 116 | HookHandles { 117 | _buffer_switch_handle, 118 | _buffer_typing_handle, 119 | _command_handles, 120 | _query_handle, 121 | _nick_handle, 122 | _join_handle, 123 | _guild_completion_handle, 124 | _channel_completion_handle, 125 | _dm_completion_handle, 126 | _nick_completion_handle, 127 | _role_completion_handle, 128 | } 129 | } 130 | 131 | pub fn buffer_input(buffer: Buffer, text: &str) { 132 | let text = if text.is_empty() { 133 | return; 134 | } else { 135 | text 136 | }; 137 | let channel = buffer.channel_id(); 138 | let guild = buffer.guild_id(); 139 | 140 | if let Some(channel) = channel { 141 | let ctx = match crate::discord::get_ctx() { 142 | Some(ctx) => ctx, 143 | _ => return, 144 | }; 145 | 146 | if let Some(edit) = parsing::parse_line_edit(text) { 147 | let weechat = buffer.get_weechat(); 148 | match edit { 149 | parsing::LineEdit::Delete { line } => { 150 | if let Err(e) = crate::utils::get_users_nth_message(&ctx, channel, line) 151 | .map(|msg| channel.delete_message(&ctx.http, msg.id)) 152 | { 153 | buffer.print(&format!( 154 | "{}\tAn error occurred deleting a message: {}", 155 | weechat.get_prefix("network"), 156 | e 157 | )); 158 | } 159 | }, 160 | parsing::LineEdit::Sub { 161 | line, 162 | old, 163 | new, 164 | options, 165 | } => { 166 | // TODO: Clean this up, (try block)? 167 | if let Err(e) = 168 | crate::utils::get_users_nth_message(&ctx, channel, line).map(|mut msg| { 169 | let orig = msg.content.clone(); 170 | msg.edit(ctx, |e| { 171 | if options.map(|o| o.contains('g')).unwrap_or_default() { 172 | e.content(orig.replace(old, new)) 173 | } else { 174 | e.content(orig.replacen(old, new, 1)) 175 | } 176 | }) 177 | }) 178 | { 179 | buffer.print(&format!( 180 | "{}\tAn error occurred editing a message: {}", 181 | weechat.get_prefix("network"), 182 | e 183 | )); 184 | } 185 | }, 186 | } 187 | return; 188 | } 189 | if let Some(reaction) = parsing::parse_reaction(text) { 190 | if let Ok(msgs) = 191 | channel.messages(ctx, |retriever| retriever.limit(reaction.line as u64)) 192 | { 193 | for (i, msg) in msgs.iter().enumerate() { 194 | if i + 1 == reaction.line { 195 | if reaction.add { 196 | let _ = 197 | msg.react(ctx, ReactionType::Unicode(reaction.unicode.to_string())); 198 | } else { 199 | let _ = channel.delete_reaction( 200 | &ctx, 201 | msg.id, 202 | None, 203 | ReactionType::Unicode(reaction.unicode.to_string()), 204 | ); 205 | } 206 | } 207 | } 208 | } 209 | return; 210 | } 211 | let text = utils::create_mentions(&ctx.cache, guild, text); 212 | let text = utils::expand_guild_emojis(&ctx.cache, guild, &text); 213 | if let Err(e) = channel.say(ctx, text) { 214 | let weechat = buffer.get_weechat(); 215 | buffer.print(&format!( 216 | "{}\tUnable to send message to {}: {:#?}", 217 | weechat.get_prefix("network"), 218 | channel.0, 219 | e 220 | )); 221 | } 222 | } 223 | } 224 | 225 | fn handle_buffer_switch(data: weechat::SignalHookValue) -> ReturnCode { 226 | if let weechat::SignalHookValue::Pointer(buffer_ptr) = data { 227 | let buffer = unsafe { crate::utils::buffer_from_ptr(buffer_ptr) }; 228 | let weechat = buffer.get_weechat(); 229 | let weecord = crate::upgrade_plugin(&weechat); 230 | let buffer = match weecord 231 | .buffer_manager 232 | .get_buffer(buffer.get_name().as_ref()) 233 | { 234 | Some(buffer) => buffer, 235 | None => return ReturnCode::Ok, 236 | }; 237 | 238 | // Wait until messages have been loaded to acknowledge them 239 | let (tx, rx) = unbounded(); 240 | if !buffer.history_loaded() { 241 | let pinned_channel_id = utils::pins_for_channel(&buffer); 242 | 243 | if pinned_channel_id.is_some() { 244 | load_pin_buffer_history(&buffer); 245 | return ReturnCode::Ok; 246 | } 247 | 248 | let fetch_count = weecord.config.message_fetch_count.value(); 249 | 250 | crate::buffers::load_history(&buffer, tx, fetch_count); 251 | } 252 | 253 | if !buffer.nicks_loaded() { 254 | crate::buffers::load_nicks(&buffer); 255 | } 256 | 257 | let channel_id = buffer.channel_id(); 258 | 259 | thread::spawn(move || { 260 | if rx.recv().is_err() { 261 | return; 262 | } 263 | let ctx = match discord::get_ctx() { 264 | Some(s) => s, 265 | None => return, 266 | }; 267 | if let Some(channel) = channel_id.and_then(|id| id.to_channel_cached(&ctx)) { 268 | if let Some(guild_channel) = channel.clone().guild() { 269 | let guild_id = guild_channel.read().guild_id; 270 | use std::collections::{HashMap, HashSet}; 271 | lazy_static! { 272 | static ref CHANNELS: Arc>>> = 273 | Arc::new(Mutex::new(HashMap::new())); 274 | } 275 | 276 | let mut channels = CHANNELS.lock(); 277 | let send = if let Some(guild_channels) = channels.get_mut(&guild_id) { 278 | guild_channels.insert(channel.id()) 279 | } else { 280 | channels 281 | .insert(guild_id, HashSet::from_iter(vec![channel.id()].into_iter())); 282 | true 283 | }; 284 | if send { 285 | let channels = channels.get(&guild_id).unwrap(); 286 | let channels_obj: HashMap>> = HashMap::from_iter( 287 | channels 288 | .iter() 289 | .map(|ch| (format!("{}", ch.0), vec![vec![0, 99]])), 290 | ); 291 | 292 | let msg = json::object! { 293 | "op" => 14, 294 | "d" => json::object! { 295 | "guild_id" => format!("{}", guild_id.0), 296 | "typing" => true, 297 | "activities" => true, 298 | "channels" => channels_obj, 299 | } 300 | }; 301 | ctx.shard 302 | .websocket_message(gateway::Message::Text(msg.to_string())); 303 | } 304 | } 305 | 306 | if let Some(rs) = ctx.cache.read().read_state.get(&channel.id()) { 307 | if let Some(last_message_id) = channel.last_message() { 308 | if rs.last_message_id != last_message_id { 309 | let _ = channel.id().ack_message(&ctx, last_message_id); 310 | } 311 | } 312 | } 313 | } 314 | }); 315 | } 316 | ReturnCode::Ok 317 | } 318 | 319 | fn handle_buffer_typing(weechat: &Weechat, data: weechat::SignalHookValue) -> ReturnCode { 320 | if let weechat::SignalHookValue::Pointer(buffer_ptr) = data { 321 | let buffer = unsafe { crate::utils::buffer_from_ptr(buffer_ptr) }; 322 | if let Some(channel_id) = buffer.channel_id() { 323 | if crate::upgrade_plugin(weechat) 324 | .config 325 | .send_typing_events 326 | .value() 327 | { 328 | if buffer.input().starts_with('/') { 329 | return ReturnCode::Ok; 330 | } 331 | // TODO: Wait for user to type for 3 seconds 332 | let now = SystemTime::now(); 333 | let timestamp_now = now 334 | .duration_since(UNIX_EPOCH) 335 | .expect("Time went backwards") 336 | .as_secs() as u64; 337 | 338 | if *LAST_TYPING_TIMESTAMP.lock() + 9 < timestamp_now { 339 | *LAST_TYPING_TIMESTAMP.lock() = timestamp_now; 340 | 341 | std::thread::spawn(move || { 342 | let ctx = match discord::get_ctx() { 343 | Some(s) => s, 344 | None => return, 345 | }; 346 | let _ = channel_id.broadcast_typing(&ctx.http); 347 | }); 348 | } 349 | } 350 | } 351 | } 352 | ReturnCode::Ok 353 | } 354 | 355 | fn handle_channel_completion(buffer: &Buffer, completion: weechat::Completion) -> ReturnCode { 356 | // Get the previous argument with should be the guild name 357 | // TODO: Generalize this? 358 | let input = buffer.input(); 359 | let x = input.split(' ').collect::>(); 360 | let input = if x.len() < 2 { 361 | None 362 | } else { 363 | Some(x[x.len() - 2].to_owned()) 364 | }; 365 | 366 | let input = match input { 367 | Some(i) => i, 368 | None => return ReturnCode::Ok, 369 | }; 370 | 371 | // Match mangled name to the real name 372 | let ctx = match discord::get_ctx() { 373 | Some(s) => s, 374 | None => return ReturnCode::Ok, 375 | }; 376 | 377 | for guild in ctx.cache.read().guilds.values() { 378 | let guild = guild.read(); 379 | if parsing::weechat_arg_strip(&guild.name).to_lowercase() == input.to_lowercase() { 380 | for channel in guild.channels.values() { 381 | let channel = channel.read(); 382 | // Skip non text channels 383 | use serenity::model::channel::ChannelType::*; 384 | match channel.kind { 385 | Text | Private | Group | News => {}, 386 | _ => continue, 387 | } 388 | let permissions = guild.user_permissions_in(channel.id, ctx.cache.read().user.id); 389 | if !permissions.read_message_history() || !permissions.read_messages() { 390 | continue; 391 | } 392 | completion.add(&parsing::weechat_arg_strip(&channel.name)) 393 | } 394 | return ReturnCode::Ok; 395 | } 396 | } 397 | ReturnCode::Ok 398 | } 399 | 400 | fn handle_guild_completion(_buffer: &Buffer, completion: weechat::Completion) -> ReturnCode { 401 | let ctx = match discord::get_ctx() { 402 | Some(s) => s, 403 | None => return ReturnCode::Ok, 404 | }; 405 | for guild in ctx.cache.read().guilds.values() { 406 | let name = parsing::weechat_arg_strip(&guild.read().name); 407 | completion.add(&name); 408 | } 409 | ReturnCode::Ok 410 | } 411 | 412 | fn handle_dm_completion(_buffer: &Buffer, completion: weechat::Completion) -> ReturnCode { 413 | let ctx = match discord::get_ctx() { 414 | Some(s) => s, 415 | None => return ReturnCode::Ok, 416 | }; 417 | for dm in ctx.cache.read().private_channels.values() { 418 | completion.add(&dm.read().recipient.read().name); 419 | } 420 | ReturnCode::Ok 421 | } 422 | 423 | fn handle_nick_completion(buffer: &Buffer, completion: weechat::Completion) -> ReturnCode { 424 | let ctx = match discord::get_ctx() { 425 | Some(s) => s, 426 | None => return ReturnCode::Ok, 427 | }; 428 | 429 | let channel_id = buffer.channel_id(); 430 | 431 | if let Some(Channel::Guild(channel)) = channel_id.and_then(|c| c.to_channel(ctx).ok()) { 432 | let channel = channel.read(); 433 | 434 | if let Ok(members) = channel.members(&ctx.cache) { 435 | for member in members { 436 | completion.add_with_options( 437 | &format!("@{}", member.distinct()), 438 | false, 439 | CompletionPosition::Sorted, 440 | ); 441 | } 442 | } 443 | } 444 | 445 | ReturnCode::Ok 446 | } 447 | 448 | fn handle_role_completion(buffer: &Buffer, completion: weechat::Completion) -> ReturnCode { 449 | let ctx = match discord::get_ctx() { 450 | Some(s) => s, 451 | None => return ReturnCode::Ok, 452 | }; 453 | 454 | let guild = buffer.guild_id(); 455 | 456 | if let Some(guild) = guild { 457 | if let Some(guild) = guild.to_guild_cached(&ctx.cache) { 458 | let roles = &guild.read().roles; 459 | for role in roles.values() { 460 | completion.add(&format!("@{}", role.name)); 461 | } 462 | } 463 | } 464 | 465 | ReturnCode::Ok 466 | } 467 | 468 | // TODO: Make this faster 469 | // TODO: Handle command options 470 | pub fn handle_query(args: &Args) -> ReturnCode { 471 | let mut owned_args = args.clone(); 472 | 473 | let mut noswitch = false; 474 | if let Some(&arg) = owned_args.args.front() { 475 | if arg == "-noswitch" { 476 | noswitch = true; 477 | owned_args.args.pop_front(); 478 | } 479 | } 480 | if let Some(&arg) = owned_args.args.front() { 481 | if arg == "-server" { 482 | plugin_print("The server option is not yet supported"); 483 | return ReturnCode::Error; 484 | } 485 | } 486 | 487 | if owned_args.args.is_empty() { 488 | plugin_print("query requires a username"); 489 | return ReturnCode::Error; 490 | } 491 | 492 | let target = match owned_args.args.pop_front() { 493 | Some(target) => target.to_owned(), 494 | None => return ReturnCode::Ok, 495 | }; 496 | 497 | thread::spawn(move || { 498 | let ctx = match crate::discord::get_ctx() { 499 | Some(ctx) => ctx, 500 | _ => return, 501 | }; 502 | let current_user = &ctx.cache.read().user; 503 | 504 | let mut found_members: Vec = Vec::new(); 505 | for private_channel in ctx.cache.read().private_channels.values() { 506 | if private_channel 507 | .read() 508 | .name() 509 | .to_lowercase() 510 | .contains(&target.to_lowercase()) 511 | { 512 | found_members.push(private_channel.read().recipient.read().clone()) 513 | } 514 | } 515 | 516 | if found_members.is_empty() { 517 | let guilds = current_user.guilds(ctx).expect("Unable to fetch guilds"); 518 | for guild in &guilds { 519 | if let Some(guild) = guild.id.to_guild_cached(ctx) { 520 | let guild = guild.read().clone(); 521 | for m in guild.members_containing(&target.to_lowercase(), false, true) { 522 | found_members.push(m.user.read().clone()); 523 | } 524 | } 525 | } 526 | } 527 | found_members.dedup_by_key(|mem| mem.id); 528 | 529 | let current_user_name = current_user.name.clone(); 530 | 531 | if let Some(target) = found_members.get(0) { 532 | if let Ok(chan) = target.create_dm_channel(ctx) { 533 | on_main(move |weecord| { 534 | let ctx = match crate::discord::get_ctx() { 535 | Some(ctx) => ctx, 536 | _ => return, 537 | }; 538 | crate::buffers::create_buffer_from_dm( 539 | &ctx.cache, 540 | &weecord, 541 | Channel::Private(Arc::new(RwLock::new(chan))), 542 | ¤t_user_name, 543 | !noswitch, 544 | ); 545 | }); 546 | return; 547 | } 548 | } 549 | 550 | plugin_print(&format!("Could not find user {:?}", target)); 551 | }); 552 | ReturnCode::OkEat 553 | } 554 | 555 | // TODO: Handle command options 556 | fn handle_nick(buffer: &Buffer, command: &str) -> ReturnCode { 557 | let guild = if let Some(id) = buffer.guild_id() { 558 | id 559 | } else { 560 | return ReturnCode::Ok; 561 | }; 562 | 563 | let guilds; 564 | let mut substr; 565 | { 566 | let ctx = match crate::discord::get_ctx() { 567 | Some(ctx) => ctx, 568 | _ => return ReturnCode::Error, 569 | }; 570 | substr = command["/nick".len()..].trim().to_owned(); 571 | let mut split = substr.split(' '); 572 | let all = split.next() == Some("-all"); 573 | if all { 574 | substr = substr["-all".len()..].trim().to_owned(); 575 | } 576 | guilds = if all { 577 | let current_user = &ctx.cache.read().user; 578 | 579 | // TODO: Error handling 580 | current_user 581 | .guilds(ctx) 582 | .unwrap_or_default() 583 | .iter() 584 | .map(|g| g.id) 585 | .collect() 586 | } else { 587 | vec![guild] 588 | }; 589 | } 590 | 591 | thread::spawn(move || { 592 | { 593 | let ctx = match crate::discord::get_ctx() { 594 | Some(ctx) => ctx, 595 | _ => return, 596 | }; 597 | let should_sleep = guilds.len() > 1; 598 | for guild in guilds { 599 | let new_nick = if substr.is_empty() { 600 | None 601 | } else { 602 | Some(substr.as_str()) 603 | }; 604 | let _ = guild.edit_nickname(ctx, new_nick); 605 | // Make it less spammy 606 | if should_sleep { 607 | thread::sleep(Duration::from_secs(1)); 608 | } 609 | } 610 | } 611 | }); 612 | ReturnCode::OkEat 613 | } 614 | 615 | fn handle_join(buffer: &Buffer, command: &str) -> ReturnCode { 616 | let verbose = buffer.guild_id().is_some(); 617 | 618 | crate::command::join( 619 | &buffer.get_weechat(), 620 | &crate::command::Args::from_cmd(&format!("/discord {}", &command[1..])), 621 | verbose, 622 | ) 623 | } 624 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::let_unit_value)] 2 | 3 | mod bar_items; 4 | mod buffers; 5 | mod command; 6 | mod config; 7 | mod discord; 8 | mod hook; 9 | mod sync; 10 | mod utils; 11 | mod weechat_utils; 12 | 13 | use crate::weechat_utils::BufferManager; 14 | pub use sync::{on_main, on_main_blocking, upgrade_plugin}; 15 | 16 | use std::borrow::Cow; 17 | use weechat::{weechat_plugin, ArgsWeechat, ConfigOption, Weechat, WeechatPlugin, WeechatResult}; 18 | 19 | pub struct Discord { 20 | weechat: Weechat, 21 | config: config::Config, 22 | buffer_manager: BufferManager, 23 | _sync_handle: sync::SyncHandle, 24 | _hook_handles: hook::HookHandles, 25 | _bar_handles: bar_items::BarHandles, 26 | } 27 | 28 | impl WeechatPlugin for Discord { 29 | // Note: We cannot use on_main (or plugin_print) 30 | fn init(weechat: Weechat, args: ArgsWeechat) -> WeechatResult { 31 | let args: Vec<_> = args.collect(); 32 | 33 | #[cfg(feature = "logging")] 34 | { 35 | use flexi_logger::Logger; 36 | 37 | Logger::with_env() 38 | .log_to_file() 39 | .basename("weecord") 40 | .start() 41 | .unwrap_or_else(|e| panic!("Logger initialization failed with {}", e)) 42 | }; 43 | 44 | let _sync_handle = sync::init(&weechat); 45 | let _hook_handles = hook::init(&weechat); 46 | let _bar_handles = bar_items::init(&weechat); 47 | let config = config::init(&weechat); 48 | let buffer_manager = buffers::init(&weechat); 49 | 50 | let autostart = config.autostart.value(); 51 | 52 | let weecord = Discord { 53 | weechat, 54 | config, 55 | buffer_manager, 56 | _sync_handle, 57 | _hook_handles, 58 | _bar_handles, 59 | }; 60 | 61 | if !args.contains(&"-a".to_owned()) && autostart { 62 | weecord.connect(); 63 | } 64 | 65 | Ok(weecord) 66 | } 67 | } 68 | 69 | impl Discord { 70 | fn connect(&self) { 71 | if crate::discord::DISCORD.lock().is_some() { 72 | plugin_print("Already connected"); 73 | return; 74 | } 75 | 76 | let token = self.config.token.value().into_owned(); 77 | 78 | let token = if token.starts_with("${sec.data") { 79 | self.eval_string_expression(&token).map(Cow::into_owned) 80 | } else { 81 | Some(token) 82 | }; 83 | if let Some(t) = token { 84 | if !t.is_empty() { 85 | discord::init(&self, &t, self.config.irc_mode.value()); 86 | } else { 87 | self.print("Error: weecord.main.token is not set. To set it, run:"); 88 | self.print("/discord token 123456789ABCDEF"); 89 | } 90 | } else { 91 | self.print("Error: failed to evaluate token option, expected valid ${sec.data...}"); 92 | } 93 | } 94 | } 95 | 96 | impl Drop for Discord { 97 | fn drop(&mut self) { 98 | // TODO: Why is the config file not saved on quit? 99 | self.config.config.write() 100 | } 101 | } 102 | 103 | impl std::ops::Deref for Discord { 104 | type Target = Weechat; 105 | 106 | fn deref(&self) -> &Self::Target { 107 | &self.weechat 108 | } 109 | } 110 | 111 | weechat_plugin!( 112 | Discord, 113 | name: "weecord", 114 | author: "Noskcaj19", 115 | description: "Discord integration for weechat", 116 | version: "0.2.0", 117 | license: "MIT" 118 | ); 119 | 120 | pub fn plugin_print(msg: &str) { 121 | let msg = msg.to_owned(); 122 | on_main(move |weechat| weechat.print(&format!("discord: {}", msg))) 123 | } 124 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | use crate::Discord; 2 | use crossbeam_channel::{unbounded, Sender}; 3 | use lazy_static::lazy_static; 4 | use parking_lot::Mutex; 5 | use std::{ 6 | any::Any, cell::RefCell, collections::VecDeque, mem::transmute, sync::Arc, thread, 7 | time::Duration, 8 | }; 9 | use weechat::Weechat; 10 | 11 | /// Created upon sync initialization, must not be dropped while the plugin is running 12 | pub struct SyncHandle(weechat::TimerHook<()>); 13 | 14 | enum Job { 15 | Nonblocking(Box), 16 | Blocking( 17 | Box Box + Send>, 18 | Sender>, 19 | ), 20 | } 21 | 22 | lazy_static! { 23 | static ref JOB_QUEUE: Mutex>> = Mutex::new(RefCell::new(VecDeque::new())); 24 | static ref MAIN_THREAD: Arc>> = Arc::new(Mutex::new(None)); 25 | } 26 | 27 | /// Initialize thread synchronization, this function must be called on the main thread 28 | pub fn init(weechat: &weechat::Weechat) -> SyncHandle { 29 | *MAIN_THREAD.lock() = Some(thread::current().id()); 30 | 31 | // TODO: Dynamic delay 32 | SyncHandle(weechat.hook_timer(Duration::from_millis(25), 0, 0, |_, _, _| tick(), None)) 33 | } 34 | 35 | pub fn on_main(cb: F) { 36 | if std::thread::current().id() == MAIN_THREAD.lock().unwrap() { 37 | // already on the main thread, run closure now 38 | cb(unsafe { &crate::__PLUGIN.as_ref().unwrap() }); 39 | } else { 40 | // queue closure for later 41 | JOB_QUEUE 42 | .lock() 43 | .borrow_mut() 44 | .push_back(Job::Nonblocking(Box::new(cb))); 45 | } 46 | } 47 | 48 | pub fn on_main_blocking R + Send, ER: 'static + Send>(cb: F) -> ER { 49 | let cb = unsafe { 50 | // This should be ok because the lifetime does not actually 51 | // have to be valid for 'static, just until the function returns 52 | transmute::< 53 | Box R + Send>, 54 | Box ER + Send>, 55 | >(Box::new(cb)) 56 | }; 57 | 58 | if std::thread::current().id() == MAIN_THREAD.lock().unwrap() { 59 | cb(unsafe { &crate::__PLUGIN.as_ref().unwrap() }) 60 | } else { 61 | let (tx, rx) = unbounded(); 62 | let job = Job::Blocking(Box::new(move |data| Box::new(cb(data))), tx); 63 | JOB_QUEUE.lock().borrow_mut().push_back(job); 64 | 65 | let rcv: Box = rx.recv().expect("rx can't fail"); 66 | *rcv.downcast::().expect("downcast can't fail") 67 | } 68 | } 69 | 70 | fn tick() { 71 | match JOB_QUEUE.lock().borrow_mut().pop_front() { 72 | Some(Job::Nonblocking(cb)) => { 73 | (cb)(unsafe { &crate::__PLUGIN.as_ref().unwrap() }); 74 | }, 75 | Some(Job::Blocking(cb, tx)) => { 76 | let result = (cb)(unsafe { &crate::__PLUGIN.as_ref().unwrap() }); 77 | let _ = tx.send(result); 78 | }, 79 | None => {}, 80 | } 81 | } 82 | 83 | #[must_use] 84 | pub fn upgrade_plugin(weechat: &Weechat) -> &Discord { 85 | let _ = weechat; 86 | unsafe { crate::__PLUGIN.as_ref().unwrap() } 87 | } 88 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::weechat_utils::MessageManager; 2 | use indexmap::IndexMap; 3 | use lazy_static::lazy_static; 4 | use regex::Regex; 5 | use serenity::{ 6 | cache::{Cache, CacheRwLock}, 7 | model::{id::ChannelId, prelude::*}, 8 | prelude::*, 9 | }; 10 | use std::{borrow::Cow, sync::Arc}; 11 | use weechat::{Buffer, ConfigOption, Weechat}; 12 | 13 | #[derive(Debug, Clone, Copy)] 14 | pub enum GuildOrChannel { 15 | Guild(GuildId), 16 | Channel(Option, ChannelId), 17 | } 18 | 19 | impl PartialEq for GuildOrChannel { 20 | fn eq(&self, other: &GuildId) -> bool { 21 | match self { 22 | GuildOrChannel::Guild(this_id) => this_id == other, 23 | GuildOrChannel::Channel(_, _) => false, 24 | } 25 | } 26 | } 27 | 28 | impl PartialEq for GuildOrChannel { 29 | fn eq(&self, other: &ChannelId) -> bool { 30 | match self { 31 | GuildOrChannel::Guild(_) => false, 32 | GuildOrChannel::Channel(_, this_id) => this_id == other, 33 | } 34 | } 35 | } 36 | 37 | pub fn rgb_to_ansi(color: serenity::utils::Colour) -> u8 { 38 | let r = (u16::from(color.r()) * 5 / 255) as u8; 39 | let g = (u16::from(color.g()) * 5 / 255) as u8; 40 | let b = (u16::from(color.b()) * 5 / 255) as u8; 41 | 16 + 36 * r + 6 * g + b 42 | } 43 | 44 | pub fn status_is_online(status: OnlineStatus) -> bool { 45 | use OnlineStatus::*; 46 | match status { 47 | Online | Idle | DoNotDisturb => true, 48 | Offline | Invisible => false, 49 | __Nonexhaustive => unreachable!(), 50 | } 51 | } 52 | 53 | pub fn get_user_status_prefix(weechat: &Weechat, cache: &Cache, user: UserId) -> String { 54 | let presence = cache.presences.get(&user); 55 | 56 | format_user_status_prefix(weechat, presence.map(|p| p.status)) 57 | } 58 | 59 | pub fn format_user_status_prefix(weechat: &Weechat, status: Option) -> String { 60 | let prefix_color = match status { 61 | Some(OnlineStatus::DoNotDisturb) => "red", 62 | Some(OnlineStatus::Idle) => "178", 63 | Some(OnlineStatus::Invisible) | Some(OnlineStatus::Offline) => { 64 | "weechat.color.nicklist_away" 65 | }, 66 | Some(OnlineStatus::Online) => "green", 67 | _ => "", 68 | }; 69 | 70 | format!( 71 | "{}•{} ", 72 | weechat.color(prefix_color), 73 | weechat.color("Reset"), 74 | ) 75 | } 76 | 77 | pub fn nick_color(weechat: &Weechat, nick: &String) -> String { 78 | weechat 79 | .info_get("nick_color_name", nick) 80 | .unwrap_or_else(|| { 81 | weechat 82 | .info_get("irc_nick_color_name", nick) 83 | .unwrap_or_else(|| Cow::from("reset")) 84 | }) 85 | .to_string() 86 | } 87 | 88 | pub fn format_nick_color(weechat: &Weechat, nick: &String) -> String { 89 | let color_name = nick_color(weechat, nick); 90 | let prefix = weechat.color(&color_name); 91 | let suffix = weechat.color("reset"); 92 | return prefix.to_string() + nick + &suffix.to_string(); 93 | } 94 | 95 | pub fn colorize_string(weechat: &Weechat, color: &str, string: &str) -> String { 96 | if string.is_empty() { 97 | string.to_owned() 98 | } else { 99 | format!( 100 | "{}{}{}", 101 | weechat.color(color), 102 | string, 103 | weechat.color("reset") 104 | ) 105 | } 106 | } 107 | 108 | pub trait ChannelExt { 109 | fn name(&self) -> String; 110 | fn last_message(&self) -> Option; 111 | } 112 | 113 | impl ChannelExt for Channel { 114 | fn name(&self) -> String { 115 | use Channel::*; 116 | match self { 117 | Guild(channel) => channel.read().name().to_string(), 118 | Group(channel) => match channel.read().name() { 119 | Cow::Borrowed(name) => name.to_string(), 120 | Cow::Owned(name) => name, 121 | }, 122 | Category(category) => category.read().name().to_string(), 123 | Private(channel) => channel.read().name(), 124 | __Nonexhaustive => unreachable!(), 125 | } 126 | } 127 | 128 | fn last_message(&self) -> Option { 129 | use Channel::*; 130 | match self { 131 | Guild(channel) => channel.read().last_message_id, 132 | Group(channel) => channel.read().last_message_id, 133 | Category(_) => None, 134 | Private(channel) => channel.read().last_message_id, 135 | __Nonexhaustive => unreachable!(), 136 | } 137 | } 138 | } 139 | 140 | pub trait BufferExt { 141 | fn channel_id(&self) -> Option; 142 | fn guild_id(&self) -> Option; 143 | 144 | fn history_loaded(&self) -> bool; 145 | fn set_history_loaded(&self); 146 | 147 | fn nicks_loaded(&self) -> bool; 148 | fn set_nicks_loaded(&self); 149 | } 150 | 151 | impl BufferExt for Buffer { 152 | fn channel_id(&self) -> Option { 153 | self.get_localvar("channelid") 154 | .and_then(|ch| ch.parse::().ok()) 155 | .map(Into::into) 156 | } 157 | 158 | fn guild_id(&self) -> Option { 159 | self.get_localvar("guildid") 160 | .and_then(|ch| ch.parse::().ok()) 161 | .map(Into::into) 162 | } 163 | 164 | fn history_loaded(&self) -> bool { 165 | self.get_localvar("loaded_history").is_some() 166 | } 167 | 168 | fn set_history_loaded(&self) { 169 | self.set_localvar("loaded_history", "true"); 170 | } 171 | 172 | fn nicks_loaded(&self) -> bool { 173 | self.get_localvar("loaded_nicks").is_some() 174 | } 175 | 176 | fn set_nicks_loaded(&self) { 177 | self.set_localvar("loaded_nicks", "true"); 178 | } 179 | } 180 | 181 | pub fn pins_for_channel(buffer: &MessageManager) -> Option { 182 | buffer 183 | .get_localvar("pins_for_channel") 184 | .and_then(|id| id.parse().ok()) 185 | .map(ChannelId) 186 | } 187 | 188 | pub fn set_pins_for_channel(buffer: &MessageManager, channel: ChannelId) { 189 | buffer.set_localvar("pins_for_channel", &channel.0.to_string()); 190 | } 191 | 192 | /// Find the highest hoisted role (used for the user group) and the highest role (used for user coloring) 193 | pub fn find_highest_roles(cache: &CacheRwLock, member: &Member) -> Option<(Role, Role)> { 194 | let mut roles = member.roles(cache)?; 195 | roles.sort(); 196 | let highest = roles.last(); 197 | 198 | let highest_hoisted = roles.iter().filter(|role| role.hoist).collect::>(); 199 | let highest_hoisted = highest_hoisted.last().cloned(); 200 | Some((highest_hoisted?.clone(), highest?.clone())) 201 | } 202 | 203 | pub fn unique_id(guild: Option, channel: ChannelId) -> String { 204 | if let Some(guild) = guild { 205 | format!("G{:?}C{}", guild.0, channel.0) 206 | } else { 207 | format!("C{}", channel.0) 208 | } 209 | } 210 | 211 | pub fn unique_guild_id(guild: GuildId) -> String { 212 | format!("G{}", guild) 213 | } 214 | 215 | pub fn parse_id(id: &str) -> Option { 216 | // id has channel part 217 | if let Some(c_start) = id.find('C') { 218 | if id.starts_with('C') { 219 | let channel_id = id[1..].parse().ok()?; 220 | Some(GuildOrChannel::Channel(None, channel_id)) 221 | } else { 222 | let guild_id = id[1..c_start].parse().ok()?; 223 | let channel_id = id[c_start + 1..].parse().ok()?; 224 | Some(GuildOrChannel::Channel(Some(GuildId(guild_id)), channel_id)) 225 | } 226 | } else { 227 | // id is only a guild 228 | let guild_id = id[1..].parse().ok()?; 229 | Some(GuildOrChannel::Guild(GuildId(guild_id))) 230 | } 231 | } 232 | 233 | pub fn get_irc_mode(weechat: &weechat::Weechat) -> bool { 234 | crate::upgrade_plugin(weechat).config.irc_mode.value() 235 | } 236 | 237 | pub fn buffer_id_for_guild(id: GuildId) -> String { 238 | format!("{}", id.0) 239 | } 240 | 241 | pub fn buffer_id_for_channel(guild_id: Option, channel_id: ChannelId) -> String { 242 | if let Some(guild_id) = guild_id { 243 | format!("{}.{}", guild_id, channel_id.0) 244 | } else { 245 | format!("Private.{}", channel_id.0) 246 | } 247 | } 248 | 249 | pub unsafe fn buffer_from_ptr(buffer_ptr: *mut std::ffi::c_void) -> Buffer { 250 | Buffer::from_ptr( 251 | crate::__PLUGIN.as_mut().unwrap().weechat.as_ptr(), 252 | buffer_ptr as *mut _, 253 | ) 254 | } 255 | 256 | pub fn buffer_is_muted(buffer: &Buffer) -> bool { 257 | if let Some(muted) = buffer.get_localvar("muted") { 258 | muted == "1" 259 | } else { 260 | false 261 | } 262 | } 263 | 264 | pub fn search_channel( 265 | cache: &CacheRwLock, 266 | guild_name: &str, 267 | channel_name: &str, 268 | ) -> Option<(Arc>, Arc>)> { 269 | if let Some(raw_guild) = search_guild(cache, guild_name) { 270 | let guild = raw_guild.read(); 271 | for channel in guild.channels.values() { 272 | let channel_lock = channel.read(); 273 | if parsing::weechat_arg_strip(&channel_lock.name).to_lowercase() 274 | == channel_name.to_lowercase() 275 | || channel_lock.id.0.to_string() == channel_name 276 | { 277 | // Skip non text channels 278 | use serenity::model::channel::ChannelType::*; 279 | match channel_lock.kind { 280 | Text | Private | Group | News => {}, 281 | _ => continue, 282 | } 283 | return Some((raw_guild.clone(), channel.clone())); 284 | } 285 | } 286 | } 287 | None 288 | } 289 | 290 | pub fn search_guild(cache: &CacheRwLock, guild_name: &str) -> Option>> { 291 | for guild in cache.read().guilds.values() { 292 | let guild_lock = guild.read(); 293 | if parsing::weechat_arg_strip(&guild_lock.name).to_lowercase() == guild_name.to_lowercase() 294 | || guild_lock.id.0.to_string() == guild_name 295 | { 296 | return Some(guild.clone()); 297 | } 298 | } 299 | None 300 | } 301 | 302 | /// Take a slice of `GuildOrChannel`'s and flatten it into a map of channels 303 | pub fn flatten_guilds( 304 | ctx: &Context, 305 | items: &[GuildOrChannel], 306 | ) -> IndexMap, Vec> { 307 | let mut channels: IndexMap, Vec> = IndexMap::new(); 308 | // flatten guilds into channels 309 | for item in items { 310 | match item { 311 | GuildOrChannel::Guild(guild_id) => { 312 | let guild_channels = guild_id.channels(ctx).unwrap_or_default(); 313 | let mut guild_channels = guild_channels.values().collect::>(); 314 | guild_channels.sort_by_key(|g| g.position); 315 | channels 316 | .entry(Some(*guild_id)) 317 | .or_default() 318 | .extend(guild_channels.iter().map(|ch| ch.id)); 319 | }, 320 | GuildOrChannel::Channel(guild, channel) => { 321 | channels.entry(*guild).or_default().push(*channel); 322 | }, 323 | } 324 | } 325 | 326 | channels 327 | } 328 | 329 | pub fn get_users_nth_message( 330 | ctx: &Context, 331 | channel: ChannelId, 332 | n: usize, 333 | ) -> serenity::Result { 334 | if n > 100 { 335 | return Err(serenity::Error::ExceededLimit( 336 | "Cannot fetch more than 100 items".into(), 337 | n as u32, 338 | )); 339 | } 340 | let user = ctx.cache.read().user.id; 341 | // TODO: Page if needed 342 | channel 343 | .messages(&ctx.http, |retriever| retriever.limit(50)) 344 | .and_then(|msgs| { 345 | msgs.iter() 346 | .filter(|msg| msg.author.id == user) 347 | .nth(n - 1) 348 | .cloned() 349 | .ok_or(serenity::Error::Model( 350 | serenity::model::ModelError::ItemMissing, 351 | )) 352 | }) 353 | } 354 | 355 | // TODO: Role mentions 356 | /// Parse user input and replace mentions with Discords internal representation 357 | /// 358 | /// This is not in `parsing` because it depends on `serenity` 359 | pub fn create_mentions(cache: &CacheRwLock, guild_id: Option, input: &str) -> String { 360 | let mut out = String::from(input); 361 | 362 | lazy_static! { 363 | static ref CHANNEL_MENTION: Regex = Regex::new(r"#([a-z_-]+)").unwrap(); 364 | static ref USER_MENTION: Regex = Regex::new(r"@(.{0,32}?)#(\d{2,4})").unwrap(); 365 | static ref ROLE_MENTION: Regex = Regex::new(r"@([^\s]{1,32})").unwrap(); 366 | } 367 | 368 | let channel_mentions = CHANNEL_MENTION.captures_iter(input); 369 | for channel_match in channel_mentions { 370 | let channel_name = channel_match.get(1).unwrap().as_str(); 371 | 372 | // TODO: Remove duplication 373 | if let Some(guild) = guild_id.and_then(|g| g.to_guild_cached(cache)) { 374 | for (id, chan) in &guild.read().channels { 375 | if chan.read().name() == channel_name { 376 | out = out.replace(channel_match.get(0).unwrap().as_str(), &id.mention()); 377 | } 378 | } 379 | } else { 380 | for (id, chan) in &cache.read().channels { 381 | if chan.read().name() == channel_name { 382 | out = out.replace(channel_match.get(0).unwrap().as_str(), &id.mention()); 383 | } 384 | } 385 | }; 386 | } 387 | 388 | let user_mentions = USER_MENTION.captures_iter(input); 389 | // TODO: Support nick names 390 | for user_match in user_mentions { 391 | let user_name = user_match.get(1).unwrap().as_str(); 392 | 393 | if let Some(guild) = guild_id.and_then(|g| g.to_guild_cached(cache)) { 394 | for (id, member) in &guild.read().members { 395 | if let Some(nick) = &member.nick { 396 | if nick == user_name { 397 | out = out.replace(user_match.get(0).unwrap().as_str(), &id.mention()); 398 | continue; 399 | } 400 | } 401 | 402 | if member.user.read().name == user_name { 403 | out = out.replace(user_match.get(0).unwrap().as_str(), &id.mention()); 404 | } 405 | } 406 | } 407 | for (id, user) in &cache.read().users { 408 | if user.read().name == user_name { 409 | out = out.replace(user_match.get(0).unwrap().as_str(), &id.mention()); 410 | } 411 | } 412 | } 413 | 414 | let role_mentions = ROLE_MENTION.captures_iter(input); 415 | for role_match in role_mentions { 416 | let role_name = role_match.get(1).unwrap().as_str(); 417 | 418 | if let Some(guild) = guild_id.and_then(|g| g.to_guild_cached(cache)) { 419 | if let Some(role) = guild 420 | .read() 421 | .roles 422 | .values() 423 | .find(|role| role.name == role_name) 424 | { 425 | if !role.mentionable { 426 | continue; 427 | } 428 | out = out.replace(role_match.get(0).unwrap().as_str(), &role.mention()); 429 | } 430 | } 431 | } 432 | 433 | out 434 | } 435 | 436 | pub fn expand_guild_emojis(cache: &CacheRwLock, guild_id: Option, input: &str) -> String { 437 | let mut out = String::from(input); 438 | lazy_static! { 439 | static ref EMOJI_SYNTAX: Regex = Regex::new(r"(.?):(\w+):").unwrap(); 440 | } 441 | 442 | let emojis = EMOJI_SYNTAX.captures_iter(input); 443 | if let Some(guild) = guild_id.and_then(|id| id.to_guild_cached(cache)) { 444 | let guild = guild.read(); 445 | for emoji_match in emojis { 446 | if let Some(prefix) = emoji_match.get(1) { 447 | if prefix.as_str() == "\\" { 448 | continue; 449 | } 450 | } 451 | if let Some(emoji_match) = emoji_match.get(2) { 452 | let emoji_name = emoji_match.as_str(); 453 | if let Some(guild_emoji) = 454 | guild.emojis.values().find(|emoji| emoji.name == emoji_name) 455 | { 456 | out = out.replace(&format!(":{}:", emoji_name), &guild_emoji.mention()); 457 | } 458 | } 459 | } 460 | } 461 | out 462 | } 463 | 464 | /// Remove the guild id from global emojis 465 | pub fn clean_emojis(input: &str) -> String { 466 | let mut out = String::from(input); 467 | 468 | lazy_static! { 469 | static ref GLOBAL_EMOJI: Regex = Regex::new(r"<:(.*?):(\d*?)>").unwrap(); 470 | } 471 | 472 | let global_emoji = GLOBAL_EMOJI.captures_iter(input); 473 | for emoji_match in global_emoji { 474 | let emoji_name = emoji_match.get(1).unwrap().as_str(); 475 | 476 | out = out.replace( 477 | emoji_match.get(0).unwrap().as_str(), 478 | &format!(":{}:", emoji_name), 479 | ); 480 | } 481 | 482 | out 483 | } 484 | -------------------------------------------------------------------------------- /src/weechat_utils/buffer_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::weechat_utils::message_manager::MessageManager; 2 | use std::{cell::RefCell, collections::HashMap, sync::Arc}; 3 | use weechat::Weechat; 4 | 5 | /// Manages all buffers for the plugin 6 | pub struct BufferManager { 7 | weechat: Weechat, 8 | buffers: RefCell>>, 9 | } 10 | 11 | impl BufferManager { 12 | pub(crate) fn new(weechat: Weechat) -> BufferManager { 13 | BufferManager { 14 | weechat, 15 | buffers: RefCell::new(HashMap::new()), 16 | } 17 | } 18 | 19 | pub fn get_buffer(&self, name: &str) -> Option> { 20 | if let Some(buffer) = self.buffers.borrow().get(name) { 21 | return Some(Arc::clone(buffer)); 22 | } 23 | 24 | if let Some(buffer) = self.weechat.buffer_search("weecord", name) { 25 | let msg_manager = MessageManager::new(buffer); 26 | self.buffers 27 | .borrow_mut() 28 | .insert(name.into(), Arc::new(msg_manager)); 29 | Some(Arc::clone(self.buffers.borrow().get(name).unwrap())) 30 | } else { 31 | None 32 | } 33 | } 34 | 35 | pub fn get_or_create_buffer(&self, name: &str) -> Arc { 36 | if let Some(buffer) = self.buffers.borrow().get(name) { 37 | return Arc::clone(buffer); 38 | } 39 | 40 | if let Some(buffer) = self.weechat.buffer_search("weecord", name) { 41 | let msg_manager = MessageManager::new(buffer); 42 | self.buffers 43 | .borrow_mut() 44 | .insert(name.into(), Arc::new(msg_manager)); 45 | Arc::clone(self.buffers.borrow().get(name).unwrap()) 46 | } else { 47 | let msg_manager = MessageManager::new(self.weechat.buffer_new::<(), ()>( 48 | name, 49 | Some(|_, b, i| crate::hook::buffer_input(b, &i)), 50 | None, 51 | None, 52 | None, 53 | )); 54 | self.buffers 55 | .borrow_mut() 56 | .insert(name.into(), Arc::new(msg_manager)); 57 | Arc::clone(self.buffers.borrow().get(name).unwrap()) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/weechat_utils/message_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::BufferExt; 2 | use serenity::{ 3 | cache::CacheRwLock, 4 | model::{ 5 | channel::{Message, MessageType}, 6 | id::{MessageId, UserId}, 7 | }, 8 | }; 9 | use std::{cell::RefCell, ops::Deref, sync::Arc}; 10 | use weechat::Buffer; 11 | 12 | /// MessageRenderer wraps a weechat buffer and facilitates editing the buffer and drawing the 13 | /// messages 14 | pub struct MessageManager { 15 | buffer: Buffer, 16 | messages: Arc>>, 17 | } 18 | 19 | impl MessageManager { 20 | /// Create a new MessageManager from a buffer 21 | pub fn new(buffer: Buffer) -> MessageManager { 22 | MessageManager { 23 | buffer, 24 | messages: Arc::new(RefCell::new(Vec::new())), 25 | } 26 | } 27 | 28 | /// Format and print message to the buffer 29 | fn print_msg(&self, cache: &CacheRwLock, msg: &Message, notify: bool) -> Vec { 30 | let weechat = self.buffer.get_weechat(); 31 | let maybe_guild = self.buffer.guild_id(); 32 | let (prefix, content, unknown_users) = 33 | formatting_utils::render_msg(cache, &weechat, msg, maybe_guild); 34 | self.buffer.print_tags_dated( 35 | msg.timestamp.timestamp(), 36 | &formatting_utils::msg_tags(cache, msg, notify).join(","), 37 | &format!("{}\t{}", prefix, content), 38 | ); 39 | unknown_users 40 | } 41 | 42 | /// Clear the buffer and reprint all messages 43 | pub fn redraw_buffer(&self, cache: &CacheRwLock) { 44 | self.buffer.clear(); 45 | for message in self.messages.borrow().iter() { 46 | self.print_msg(cache, &message, false); 47 | } 48 | } 49 | 50 | /// Removes all content from the buffer 51 | pub fn clear(&self) { 52 | self.messages.borrow_mut().clear(); 53 | self.buffer.clear(); 54 | } 55 | 56 | /// Add a message to the end of a buffer (chronologically) 57 | pub fn add_message(&self, cache: &CacheRwLock, msg: &Message, notify: bool) -> Vec { 58 | let mut msg = msg.clone(); 59 | if msg.referenced_message.is_some() && msg.message_reference.is_some() { 60 | msg.kind = MessageType::InlineReply; 61 | } 62 | let unknown_users = self.print_msg(cache, &msg, notify); 63 | self.messages.borrow_mut().push(msg); 64 | unknown_users 65 | } 66 | 67 | // Overwrite a previously printed message, has no effect if the message does not exist 68 | pub fn replace_message( 69 | &self, 70 | cache: &CacheRwLock, 71 | id: &MessageId, 72 | msg: &Message, 73 | ) -> Vec { 74 | if let Some(old_msg) = self 75 | .messages 76 | .borrow_mut() 77 | .iter_mut() 78 | .find(|it| &it.id == id) 79 | { 80 | *old_msg = msg.clone(); 81 | } 82 | // Using hdata to edit the line might be more efficient Would still use redrawing as a fall 83 | // back in the event that the edit has a different amount of lines 84 | self.redraw_buffer(cache); 85 | let (_, _, unknown_users) = formatting_utils::render_msg( 86 | cache, 87 | &self.buffer.get_weechat(), 88 | msg, 89 | self.buffer.guild_id(), 90 | ); 91 | unknown_users 92 | } 93 | 94 | /// Delete a previously printed message, has no effect if the message does not exist 95 | pub fn delete_message(&self, cache: &CacheRwLock, id: &MessageId) -> Vec { 96 | let index = self.messages.borrow().iter().position(|it| &it.id == id); 97 | let mut unknown_users = Vec::new(); 98 | if let Some(index) = index { 99 | let msg = self.messages.borrow_mut().remove(index); 100 | unknown_users = formatting_utils::render_msg( 101 | cache, 102 | &self.buffer.get_weechat(), 103 | &msg, 104 | self.buffer.guild_id(), 105 | ) 106 | .2; 107 | }; 108 | // Performance, see comment above 109 | self.redraw_buffer(cache); 110 | unknown_users 111 | } 112 | } 113 | 114 | impl Deref for MessageManager { 115 | type Target = Buffer; 116 | 117 | fn deref(&self) -> &Self::Target { 118 | &self.buffer 119 | } 120 | } 121 | 122 | mod formatting_utils { 123 | use crate::{ 124 | discord::formatting, 125 | utils::{colorize_string, format_nick_color}, 126 | }; 127 | use serenity::{ 128 | cache::CacheRwLock, 129 | model::{ 130 | channel::{Channel, Message}, 131 | id::{GuildId, UserId}, 132 | }, 133 | }; 134 | use std::str::FromStr; 135 | use weechat::{ConfigOption, Weechat}; 136 | 137 | pub fn msg_tags(cache: &CacheRwLock, msg: &Message, notify: bool) -> Vec { 138 | let is_private = if let Some(channel) = msg.channel(cache) { 139 | if let Channel::Private(_) = channel { 140 | true 141 | } else { 142 | false 143 | } 144 | } else { 145 | false 146 | }; 147 | 148 | let self_mentioned = msg.mentions_user_id(cache.read().user.id); 149 | 150 | let mut tags = Vec::new(); 151 | if notify { 152 | if self_mentioned { 153 | tags.push("notify_highlight"); 154 | } else if is_private { 155 | tags.push("notify_private"); 156 | } else { 157 | tags.push("notify_message"); 158 | }; 159 | } else { 160 | tags.push("notify_none"); 161 | } 162 | 163 | tags.into_iter().map(ToString::to_string).collect() 164 | } 165 | 166 | pub fn render_msg( 167 | cache: &CacheRwLock, 168 | weechat: &Weechat, 169 | msg: &Message, 170 | guild: Option, 171 | ) -> (String, String, Vec) { 172 | let opts = serenity::utils::ContentSafeOptions::new() 173 | .clean_here(false) 174 | .clean_everyone(false) 175 | .clean_user(false); 176 | 177 | let mut msg_content = serenity::utils::content_safe(&cache, &msg.content, &opts); 178 | msg_content = crate::utils::clean_emojis(&msg_content); 179 | let unknown_users = clean_users(cache, &mut msg_content, true, guild); 180 | 181 | if msg.edited_timestamp.is_some() { 182 | let edited_text = weechat.color("8").into_owned() 183 | + " (edited)" 184 | + &weechat.color("reset").into_owned(); 185 | msg_content.push_str(&edited_text); 186 | } 187 | 188 | for attachement in &msg.attachments { 189 | if !msg_content.is_empty() { 190 | msg_content.push('\n'); 191 | } 192 | msg_content.push_str(&attachement.proxy_url); 193 | } 194 | 195 | for embed in &msg.embeds { 196 | if !msg_content.is_empty() { 197 | msg_content.push('\n'); 198 | } 199 | if let Some(ref author) = embed.author { 200 | msg_content.push('▎'); 201 | msg_content.push_str(&format!( 202 | "{}{}{}", 203 | weechat.color("bold"), 204 | author.name, 205 | weechat.color("reset"), 206 | )); 207 | if let Some(url) = &author.url { 208 | msg_content.push_str(&format!(" ({})", url)); 209 | } 210 | msg_content.push('\n'); 211 | } 212 | if let Some(ref title) = embed.title { 213 | msg_content.push_str( 214 | &title 215 | .lines() 216 | .fold(String::new(), |acc, x| format!("{}▎{}\n", acc, x)), 217 | ); 218 | msg_content.push('\n'); 219 | } 220 | if let Some(ref description) = embed.description { 221 | msg_content.push_str( 222 | &description 223 | .lines() 224 | .fold(String::new(), |acc, x| format!("{}▎{}\n", acc, x)), 225 | ); 226 | msg_content.push('\n'); 227 | } 228 | for field in &embed.fields { 229 | msg_content.push_str(&field.name); 230 | msg_content.push_str( 231 | &field 232 | .value 233 | .lines() 234 | .fold(String::new(), |acc, x| format!("{}▎{}\n", acc, x)), 235 | ); 236 | msg_content.push('\n'); 237 | } 238 | if let Some(ref footer) = embed.footer { 239 | msg_content.push_str( 240 | &footer 241 | .text 242 | .lines() 243 | .fold(String::new(), |acc, x| format!("{}▎{}\n", acc, x)), 244 | ); 245 | msg_content.push('\n'); 246 | } 247 | } 248 | 249 | if msg.reactions.len() > 0 { 250 | msg_content.push('\n'); 251 | } 252 | 253 | use serenity::model::channel::ReactionType; 254 | for reaction in &msg.reactions { 255 | match &reaction.reaction_type { 256 | ReactionType::Custom { name, .. } => name.clone(), 257 | ReactionType::Unicode(s) => Some(s.clone()), 258 | _ => None, 259 | } 260 | .map(|reaction_string| { 261 | msg_content.push_str(&format!("[{} {}] ", reaction_string, reaction.count).as_str()) 262 | }); 263 | } 264 | 265 | if msg.reactions.len() > 0 { 266 | msg_content.push('\n'); 267 | } 268 | 269 | let mut prefix = String::new(); 270 | 271 | if let Some(nick_prefix) = weechat.get_string_option("weechat.look.nick_prefix") { 272 | if let Some(color) = weechat.get_string_option("weechat.color.chat_nick_prefix") { 273 | prefix.push_str(&colorize_string( 274 | weechat, 275 | &color.value(), 276 | &nick_prefix.value(), 277 | )) 278 | } 279 | } 280 | 281 | let author = format_nick_color(weechat, &author_display_name(cache, &msg, guild)); 282 | prefix.push_str(&author); 283 | 284 | if let Some(nick_suffix) = weechat.get_string_option("weechat.look.nick_suffix") { 285 | if let Some(color) = weechat.get_string_option("weechat.color.chat_nick_suffix") { 286 | prefix.push_str(&colorize_string( 287 | weechat, 288 | &color.value(), 289 | &nick_suffix.value(), 290 | )) 291 | } 292 | } 293 | 294 | use serenity::model::channel::MessageType::*; 295 | match msg.kind { 296 | Regular => ( 297 | prefix, 298 | formatting::discord_to_weechat(weechat, &msg_content), 299 | unknown_users, 300 | ), 301 | InlineReply => match msg.referenced_message.as_ref() { 302 | Some(ref_msg) => { 303 | let (ref_prefix, ref_msg_content, mut ref_unknown_users) = 304 | render_msg(cache, weechat, &ref_msg, guild); 305 | ref_unknown_users.extend(unknown_users); 306 | ref_unknown_users.sort(); 307 | ref_unknown_users.dedup(); 308 | let ref_msg_content = fold_lines(ref_msg_content.lines(), "▎"); 309 | ( 310 | prefix, 311 | format!("{}:\n{}{}", ref_prefix, ref_msg_content, msg_content), 312 | ref_unknown_users, 313 | ) 314 | }, 315 | None => ( 316 | prefix, 317 | format!("\n{}", msg_content), 318 | unknown_users, 319 | ), 320 | }, 321 | _ => { 322 | let (prefix, body) = match msg.kind { 323 | GroupRecipientAddition | MemberJoin => { 324 | ("join", format!("{} joined the group.", author)) 325 | }, 326 | GroupRecipientRemoval => ("quit", format!("{} left the group.", author)), 327 | GroupNameUpdate => ( 328 | "network", 329 | format!("{} changed the channel name: {}.", author, msg.content), 330 | ), 331 | GroupCallCreation => ("network", format!("{} started a call.", author)), 332 | GroupIconUpdate => ("network", format!("{} changed the channel icon.", author)), 333 | PinsAdd => ( 334 | "network", 335 | format!("{} pinned a message to this channel", author), 336 | ), 337 | NitroBoost => ( 338 | "network", 339 | format!("{} boosted this channel with nitro", author), 340 | ), 341 | NitroTier1 => ( 342 | "network", 343 | "This channel has achieved nitro level 1".to_string(), 344 | ), 345 | NitroTier2 => ( 346 | "network", 347 | "This channel has achieved nitro level 2".to_string(), 348 | ), 349 | NitroTier3 => ( 350 | "network", 351 | "This channel has achieved nitro level 3".to_string(), 352 | ), 353 | Regular | InlineReply | __Nonexhaustive => unreachable!(), 354 | }; 355 | (weechat.get_prefix(prefix).into_owned(), body, unknown_users) 356 | }, 357 | } 358 | } 359 | 360 | pub fn author_display_name( 361 | cache: &CacheRwLock, 362 | msg: &Message, 363 | guild: Option, 364 | ) -> String { 365 | let display_name = guild.and_then(|id| { 366 | cache 367 | .read() 368 | .member(id, msg.author.id) 369 | .map(|member| member.display_name().to_string()) 370 | }); 371 | display_name.unwrap_or_else(|| msg.author.name.to_owned()) 372 | } 373 | 374 | /// Convert raw mentions into human readable form, returning all ids that were not converted 375 | /// Extracted from serenity and modified 376 | fn clean_users( 377 | cache: &CacheRwLock, 378 | s: &mut String, 379 | show_discriminator: bool, 380 | guild: Option, 381 | ) -> Vec { 382 | let mut unknown_users = Vec::new(); 383 | let mut progress = 0; 384 | 385 | while let Some(mut mention_start) = s[progress..].find("<@") { 386 | mention_start += progress; 387 | 388 | if let Some(mut mention_end) = s[mention_start..].find('>') { 389 | mention_end += mention_start; 390 | mention_start += "<@".len(); 391 | 392 | let has_exclamation = if s[mention_start..] 393 | .as_bytes() 394 | .get(0) 395 | .map_or(false, |c| *c == b'!') 396 | { 397 | mention_start += "!".len(); 398 | 399 | true 400 | } else { 401 | false 402 | }; 403 | 404 | if let Ok(id) = UserId::from_str(&s[mention_start..mention_end]) { 405 | let replacement = if let Some(guild) = guild { 406 | if let Some(guild) = cache.read().guild(&guild) { 407 | if let Some(member) = guild.read().members.get(&id) { 408 | if show_discriminator { 409 | Some(format!("@{}", member.distinct())) 410 | } else { 411 | Some(format!("@{}", member.display_name())) 412 | } 413 | } else { 414 | unknown_users.push(id); 415 | None 416 | } 417 | } else { 418 | unknown_users.push(id); 419 | None 420 | } 421 | } else { 422 | let user = cache.read().users.get(&id).cloned(); 423 | 424 | if let Some(user) = user { 425 | let user = user.read(); 426 | 427 | if show_discriminator { 428 | Some(format!("@{}#{:04}", user.name, user.discriminator)) 429 | } else { 430 | Some(format!("@{}", user.name)) 431 | } 432 | } else { 433 | unknown_users.push(id); 434 | None 435 | } 436 | }; 437 | 438 | let code_start = if has_exclamation { "<@!" } else { "<@" }; 439 | let to_replace = format!("{}{}>", code_start, &s[mention_start..mention_end]); 440 | 441 | if let Some(replacement) = replacement { 442 | *s = s.replace(&to_replace, &replacement); 443 | } else { 444 | progress = mention_end; 445 | } 446 | } else { 447 | let id = &s[mention_start..mention_end].to_string(); 448 | 449 | if !id.is_empty() && id.as_bytes().iter().all(u8::is_ascii_digit) { 450 | let code_start = if has_exclamation { "<@!" } else { "<@" }; 451 | let to_replace = format!("{}{}>", code_start, id); 452 | 453 | *s = s.replace(&to_replace, &"@invalid-user"); 454 | } else { 455 | progress = mention_end; 456 | } 457 | } 458 | } else { 459 | break; 460 | } 461 | } 462 | unknown_users 463 | } 464 | 465 | pub fn fold_lines<'a>(lines: impl Iterator, sep: &'a str) -> String { 466 | lines.fold(String::new(), |acc, x| format!("{}{}{}\n", acc, sep, x)) 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /src/weechat_utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod buffer_manager; 2 | pub use buffer_manager::BufferManager; 3 | mod message_manager; 4 | pub use message_manager::MessageManager; 5 | --------------------------------------------------------------------------------