diff options
Diffstat (limited to '.weechat')
37 files changed, 9790 insertions, 0 deletions
diff --git a/.weechat/alias.conf b/.weechat/alias.conf new file mode 100644 index 0000000..d993aa0 --- /dev/null +++ b/.weechat/alias.conf @@ -0,0 +1,48 @@ +# +# weechat -- alias.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[cmd] +AAWAY = "allserv /away" +AME = "allchan /me" +AMSG = "allchan /msg *" +ANICK = "allserv /nick" +BEEP = "print -beep" +BYE = "quit" +C = "buffer clear" +CHAT = "dcc chat" +CL = "buffer clear" +CLOSE = "buffer close" +EXIT = "quit" +IG = "ignore" +J = "join" +K = "kick" +KB = "kickban" +LEAVE = "part" +M = "msg" +MSGBUF = "command -buffer $1 * /input send $2-" +MUB = "unban *" +N = "names" +Q = "query" +REDRAW = "window refresh" +SAY = "msg *" +SIGNOFF = "quit" +T = "topic" +UB = "unban" +UMODE = "mode $nick" +V = "command core version" +W = "who" +WC = "window merge" +WI = "whois" +WII = "whois $1 $1" +WW = "whowas" + +[completion] +MSGBUF = "%(buffers_plugins_names)" diff --git a/.weechat/aspell.conf b/.weechat/aspell.conf new file mode 100644 index 0000000..27b16b8 --- /dev/null +++ b/.weechat/aspell.conf @@ -0,0 +1,33 @@ +# +# weechat -- aspell.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[color] +misspelled = lightred +suggestion = default +suggestion_delimiter_dict = cyan +suggestion_delimiter_word = cyan + +[check] +commands = "ame,amsg,away,command,cycle,kick,kickban,me,msg,notice,part,query,quit,topic" +default_dict = "" +during_search = off +enabled = off +real_time = off +suggestions = -1 +word_min_length = 2 + +[dict] + +[look] +suggestion_delimiter_dict = " / " +suggestion_delimiter_word = "," + +[option] diff --git a/.weechat/buffers.conf b/.weechat/buffers.conf new file mode 100644 index 0000000..1775010 --- /dev/null +++ b/.weechat/buffers.conf @@ -0,0 +1,80 @@ +# +# weechat -- buffers.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[color] +current_bg = red +current_fg = lightcyan +default_bg = default +default_fg = default +hotlist_highlight_bg = default +hotlist_highlight_fg = magenta +hotlist_low_bg = default +hotlist_low_fg = white +hotlist_message_bg = default +hotlist_message_fg = yellow +hotlist_private_bg = default +hotlist_private_fg = lightgreen +none_channel_bg = default +none_channel_fg = default +number = lightgreen +number_char = lightgreen +prefix_bufname = default +queries_default_bg = default +queries_default_fg = default +queries_highlight_bg = default +queries_highlight_fg = default +queries_message_bg = default +queries_message_fg = default +suffix_bufname = default +whitelist_default_bg = default +whitelist_default_fg = default +whitelist_highlight_bg = default +whitelist_highlight_fg = default +whitelist_low_bg = default +whitelist_low_fg = default +whitelist_message_bg = default +whitelist_message_fg = default +whitelist_private_bg = default +whitelist_private_fg = default + +[look] +core_to_front = off +detach = 0 +detach_buffer_immediately = "" +detach_buffer_immediately_level = 2 +detach_display_window_number = off +detach_displayed_buffers = on +detach_free_content = off +detach_query = off +hide_merged_buffers = none +hotlist_counter = off +immune_detach_buffers = "" +indenting = off +indenting_amount = 2 +indenting_number = on +jump_prev_next_visited_buffer = off +mark_inactive = off +mouse_move_buffer = on +mouse_wheel = on +name_crop_suffix = "+" +name_size_max = 0 +number_char = "." +prefix = off +prefix_bufname = "" +prefix_empty = on +prefix_for_query = "" +short_names = on +show_lag = off +show_number = on +sort = number +suffix_bufname = "" +toggle_bar = on +whitelist_buffers = "" diff --git a/.weechat/buflist.conf b/.weechat/buflist.conf new file mode 100644 index 0000000..d285f89 --- /dev/null +++ b/.weechat/buflist.conf @@ -0,0 +1,39 @@ +# +# weechat -- buflist.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +add_newline = on +auto_scroll = 50 +display_conditions = "${buffer.hidden}==0" +enabled = on +mouse_jump_visited_buffer = off +mouse_move_buffer = on +mouse_wheel = on +nick_prefix = off +nick_prefix_empty = on +signals_refresh = "" +sort = "number,-active" + +[format] +buffer = "${format_number}${indent}${format_nick_prefix}${color_hotlist}${format_name}" +buffer_current = "${color:,blue}${format_buffer}" +hotlist = " ${color:green}(${hotlist}${color:green})" +hotlist_highlight = "${color:magenta}" +hotlist_low = "${color:white}" +hotlist_message = "${color:brown}" +hotlist_none = "${color:default}" +hotlist_private = "${color:green}" +hotlist_separator = "${color:default}," +indent = " " +lag = " ${color:green}[${color:brown}${lag}${color:green}]" +name = "${name}" +nick_prefix = "${color_nick_prefix}${nick_prefix}" +number = "${color:green}${number}${if:${number_displayed}?.: }" diff --git a/.weechat/charset.conf b/.weechat/charset.conf new file mode 100644 index 0000000..00d304c --- /dev/null +++ b/.weechat/charset.conf @@ -0,0 +1,18 @@ +# +# weechat -- charset.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[default] +decode = "iso-8859-1" +encode = "" + +[decode] + +[encode] diff --git a/.weechat/exec.conf b/.weechat/exec.conf new file mode 100644 index 0000000..db02a5b --- /dev/null +++ b/.weechat/exec.conf @@ -0,0 +1,19 @@ +# +# weechat -- exec.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[command] +default_options = "" +purge_delay = 0 +shell = "sh" + +[color] +flag_finished = lightred +flag_running = lightgreen diff --git a/.weechat/fifo.conf b/.weechat/fifo.conf new file mode 100644 index 0000000..904985d --- /dev/null +++ b/.weechat/fifo.conf @@ -0,0 +1,14 @@ +# +# weechat -- fifo.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[file] +enabled = on +path = "%h/weechat_fifo" diff --git a/.weechat/fset.conf b/.weechat/fset.conf new file mode 100644 index 0000000..668266c --- /dev/null +++ b/.weechat/fset.conf @@ -0,0 +1,95 @@ +# +# weechat -- fset.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +auto_unmark = off +condition_catch_set = "${count} >= 1" +export_help_default = on +format_number = 1 +marked_string = "*" +scroll_horizontal = 10 +show_plugins_desc = off +sort = "~name" +unmarked_string = " " +use_color_value = off +use_keys = on +use_mute = off + +[format] +export_help = "# ${description2}" +export_option = "/set ${name} ${quoted_value}" +export_option_null = "/unset ${name}" +option1 = "" +option2 = "${marked} ${name} ${type} ${value2}${newline} ${empty_name} ${_default_value}${color:darkgray} -- ${min}..${max}${newline} ${empty_name} ${description}" + +[color] +default_value = default +default_value_selected = white +description = default +description_selected = white +file = default +file_changed = brown +file_changed_selected = yellow +file_selected = white +help_default_value = white +help_description = default +help_name = white +help_quotes = darkgray +help_values = default +index = cyan +index_selected = lightcyan +line_marked_bg1 = default +line_marked_bg2 = default +line_selected_bg1 = blue +line_selected_bg2 = red +marked = brown +marked_selected = yellow +max = default +max_selected = white +min = default +min_selected = white +name = default +name_changed = brown +name_changed_selected = yellow +name_selected = white +option = default +option_changed = brown +option_changed_selected = yellow +option_selected = white +parent_name = default +parent_name_selected = white +parent_value = cyan +parent_value_selected = lightcyan +quotes = darkgray +quotes_changed = default +quotes_changed_selected = white +quotes_selected = default +section = default +section_changed = brown +section_changed_selected = yellow +section_selected = white +string_values = default +string_values_selected = white +title_count_options = cyan +title_current_option = lightcyan +title_filter = yellow +title_marked_options = lightgreen +title_sort = white +type = green +type_selected = lightgreen +unmarked = default +unmarked_selected = white +value = cyan +value_changed = brown +value_changed_selected = yellow +value_selected = lightcyan +value_undef = magenta +value_undef_selected = lightmagenta diff --git a/.weechat/guile.conf b/.weechat/guile.conf new file mode 100644 index 0000000..07a4910 --- /dev/null +++ b/.weechat/guile.conf @@ -0,0 +1,14 @@ +# +# weechat -- guile.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/irc.conf b/.weechat/irc.conf new file mode 100644 index 0000000..0fc8fcb --- /dev/null +++ b/.weechat/irc.conf @@ -0,0 +1,195 @@ +# +# weechat -- irc.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +buffer_open_before_autojoin = on +buffer_open_before_join = off +buffer_switch_autojoin = on +buffer_switch_join = on +color_nicks_in_names = off +color_nicks_in_nicklist = off +color_nicks_in_server_messages = on +color_pv_nick_like_channel = on +ctcp_time_format = "%a, %d %b %Y %T %z" +display_away = local +display_ctcp_blocked = on +display_ctcp_reply = on +display_ctcp_unknown = on +display_host_join = on +display_host_join_local = on +display_host_quit = on +display_join_message = "329,332,333,366" +display_old_topic = on +display_pv_away_once = on +display_pv_back = on +highlight_channel = "$nick" +highlight_pv = "$nick" +highlight_server = "$nick" +highlight_tags_restrict = "irc_privmsg,irc_notice" +item_channel_modes_hide_args = "k" +item_display_server = buffer_plugin +item_nick_modes = on +item_nick_prefix = on +join_auto_add_chantype = off +msgbuffer_fallback = current +new_channel_position = none +new_pv_position = none +nick_completion_smart = speakers +nick_mode = prefix +nick_mode_empty = off +nicks_hide_password = "nickserv" +notice_as_pv = auto +notice_welcome_redirect = on +notice_welcome_tags = "" +notify_tags_ison = "notify_message" +notify_tags_whois = "notify_message" +part_closes_buffer = off +pv_buffer = independent +pv_tags = "notify_private" +raw_messages = 256 +server_buffer = merge_with_core +smart_filter = on +smart_filter_chghost = on +smart_filter_delay = 5 +smart_filter_join = on +smart_filter_join_unmask = 30 +smart_filter_mode = "+" +smart_filter_nick = on +smart_filter_quit = on +temporary_servers = off +topic_strip_colors = off + +[color] +input_nick = lightcyan +item_channel_modes = default +item_lag_counting = default +item_lag_finished = yellow +item_nick_modes = default +message_chghost = brown +message_join = green +message_quit = red +mirc_remap = "1,-1:darkgray" +nick_prefixes = "y:lightred;q:lightred;a:lightcyan;o:lightgreen;h:lightmagenta;v:yellow;*:lightblue" +notice = green +reason_quit = default +topic_current = default +topic_new = white +topic_old = default + +[network] +autoreconnect_delay_growing = 2 +autoreconnect_delay_max = 600 +ban_mask_default = "*!$ident@$host" +channel_encode = off +colors_receive = on +colors_send = on +lag_check = 60 +lag_max = 1800 +lag_min_show = 500 +lag_reconnect = 300 +lag_refresh_interval = 1 +notify_check_ison = 1 +notify_check_whois = 5 +sasl_fail_unavailable = on +send_unknown_commands = off +whois_double_nick = off + +[msgbuffer] + +[ctcp] + +[ignore] + +[server_default] +addresses = "" +anti_flood_prio_high = 2 +anti_flood_prio_low = 2 +autoconnect = off +autojoin = "" +autoreconnect = on +autoreconnect_delay = 10 +autorejoin = off +autorejoin_delay = 30 +away_check = 0 +away_check_max_nicks = 25 +capabilities = "" +command = "" +command_delay = 0 +connection_timeout = 60 +ipv6 = on +local_hostname = "" +msg_kick = "" +msg_part = "WeeChat ${info:version}" +msg_quit = "WeeChat ${info:version}" +nicks = "vgg,vgg1,vgg2,vgg3,vgg4" +nicks_alternate = on +notify = "" +password = "" +proxy = "" +realname = "" +sasl_fail = continue +sasl_key = "" +sasl_mechanism = plain +sasl_password = "" +sasl_timeout = 15 +sasl_username = "" +split_msg_max_length = 512 +ssl = off +ssl_cert = "" +ssl_dhkey_size = 2048 +ssl_fingerprint = "" +ssl_priorities = "NORMAL:-VERS-SSL3.0" +ssl_verify = on +usermode = "" +username = "vgg" + +[server] +freenode.addresses = "chat.freenode.net/7000" +freenode.proxy +freenode.ipv6 +freenode.ssl = on +freenode.ssl_cert +freenode.ssl_priorities +freenode.ssl_dhkey_size +freenode.ssl_fingerprint +freenode.ssl_verify +freenode.password +freenode.capabilities +freenode.sasl_mechanism +freenode.sasl_username +freenode.sasl_password +freenode.sasl_key +freenode.sasl_timeout +freenode.sasl_fail +freenode.autoconnect = on +freenode.autoreconnect +freenode.autoreconnect_delay +freenode.nicks +freenode.nicks_alternate +freenode.username +freenode.realname +freenode.local_hostname +freenode.usermode +freenode.command +freenode.command_delay +freenode.autojoin +freenode.autorejoin +freenode.autorejoin_delay +freenode.connection_timeout +freenode.anti_flood_prio_high +freenode.anti_flood_prio_low +freenode.away_check +freenode.away_check_max_nicks +freenode.msg_kick +freenode.msg_part +freenode.msg_quit +freenode.notify +freenode.split_msg_max_length diff --git a/.weechat/logger.conf b/.weechat/logger.conf new file mode 100644 index 0000000..b7b5efc --- /dev/null +++ b/.weechat/logger.conf @@ -0,0 +1,35 @@ +# +# weechat -- logger.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +backlog = 20 +backlog_conditions = "" + +[color] +backlog_end = default +backlog_line = default + +[file] +auto_log = on +flush_delay = 120 +fsync = off +info_lines = off +mask = "$plugin.$name.weechatlog" +name_lower_case = on +nick_prefix = "" +nick_suffix = "" +path = "%h/logs/" +replacement_char = "_" +time_format = "%Y-%m-%d %H:%M:%S" + +[level] + +[mask] diff --git a/.weechat/lua.conf b/.weechat/lua.conf new file mode 100644 index 0000000..fa4966b --- /dev/null +++ b/.weechat/lua.conf @@ -0,0 +1,14 @@ +# +# weechat -- lua.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/perl.conf b/.weechat/perl.conf new file mode 100644 index 0000000..31924b9 --- /dev/null +++ b/.weechat/perl.conf @@ -0,0 +1,14 @@ +# +# weechat -- perl.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/perl/autoload/xterm_paste.pl b/.weechat/perl/autoload/xterm_paste.pl new file mode 120000 index 0000000..7350abe --- /dev/null +++ b/.weechat/perl/autoload/xterm_paste.pl @@ -0,0 +1 @@ +../xterm_paste.pl
\ No newline at end of file diff --git a/.weechat/perl/xterm_paste.pl b/.weechat/perl/xterm_paste.pl new file mode 100644 index 0000000..4d51812 --- /dev/null +++ b/.weechat/perl/xterm_paste.pl @@ -0,0 +1,227 @@ +use strict; use warnings; +$INC{'Encode/ConfigLocal.pm'}=1; +require Encode; + +# xterm_paste.pl is written by Nei <anti.teamidiot.de> +# and licensed under the under GNU General Public License v3 +# or any later version + +our $XTERM_COMPATIBLE = 'rxvt-uni'; + +use MIME::Base64; + +use constant SCRIPT_NAME => 'xterm_paste'; +weechat::register(SCRIPT_NAME, 'Nei <anti.teamidiot.de>', '0.1', 'GPL3', 'Bind Xterm paste to command', 'stop_paste', '') || return; +sub SCRIPT_FILE() { + my $infolistptr = weechat::infolist_get('perl_script', '', SCRIPT_NAME); + my $filename = weechat::infolist_string($infolistptr, 'filename') if weechat::infolist_next($infolistptr); + weechat::infolist_free($infolistptr); + return $filename unless @_; +} + +{ +package Nlib; +# this is a weechat perl library +use strict; use warnings; + +## i2h -- copy weechat infolist content into perl hash +## $infolist - name of the infolist in weechat +## $ptr - pointer argument (infolist dependend) +## @args - arguments to the infolist (list dependend) +## $fields - string of ref type "fields" if only certain keys are needed (optional) +## returns perl list with perl hashes for each infolist entry +sub i2h { + my %i2htm = (i => 'integer', s => 'string', p => 'pointer', b => 'buffer', t => 'time'); + local *weechat::infolist_buffer = sub { '(not implemented)' }; + my ($infolist, $ptr, @args) = @_; + $ptr ||= ""; + my $fields = ref $args[-1] eq 'fields' ? ${ pop @args } : undef; + my $infptr = weechat::infolist_get($infolist, $ptr, do { local $" = ','; "@args" }); + my @infolist; + while (weechat::infolist_next($infptr)) { + my @fields = map { + my ($t, $v) = split ':', $_, 2; + bless \$v, $i2htm{$t}; + } + split ',', + ($fields || weechat::infolist_fields($infptr)); + push @infolist, +{ do { + my (%list, %local, @local); + map { + my $fn = 'weechat::infolist_'.ref $_; + my $r = do { no strict 'refs'; &$fn($infptr, $$_) }; + if ($$_ =~ /^localvar_name_(\d+)$/) { + $local[$1] = $r; + () + } + elsif ($$_ =~ /^(localvar)_value_(\d+)$/) { + $local{$local[$2]} = $r; + $1 => \%local + } + elsif ($$_ =~ /(.*?)((?:_\d+)+)$/) { + my ($key, $idx) = ($1, $2); + my @idx = split '_', $idx; shift @idx; + my $target = \$list{$key}; + for my $x (@idx) { + my $o = 1; + if ($key eq 'key' or $key eq 'key_command') { + $o = 0; + } + if ($x-$o < 0) { + local $" = '|'; + weechat::print('',"list error: $target/$$_/$key/$x/$idx/@idx(@_)"); + $o = 0; + } + $target = \$$target->[$x-$o] + } + $$target = $r; + + my $code = qq{ + local \$[=1; + \$list{"\Q$key\E"}$idx = \$r + }; + $key => $list{$key} + } + else { + $$_ => $r + } + } @fields + } }; + } + weechat::infolist_free($infptr); + !wantarray && @infolist ? \@infolist : @infolist +} + +## hook_dynamic -- weechat::hook something and store hook reference +## $hook_call - hook type (e.g. modifier) +## $what - event type to hook (depends on $hook_call) +## $sub - subroutine name to install +## @params - parameters +sub hook_dynamic { + my ($hook_call, $what, $sub, @params) = @_; + my $caller_package = (caller)[0]; + eval qq{ + package $caller_package; + no strict 'vars'; + \$DYNAMIC_HOOKS{\$what}{\$sub} = + weechat::hook_$hook_call(\$what, \$sub, \@params) + unless exists \$DYNAMIC_HOOKS{\$what} && + exists \$DYNAMIC_HOOKS{\$what}{\$sub}; + }; + die $@ if $@; +} + +## unhook_dynamic -- weechat::unhook something where hook reference has been stored with hook_dynamic +## $what - event type that was hooked +## $sub - subroutine name that was installed +sub unhook_dynamic { + my ($what, $sub) = @_; + my $caller_package = (caller)[0]; + eval qq{ + package $caller_package; + no strict 'vars'; + weechat::unhook(\$DYNAMIC_HOOKS{\$what}{\$sub}) + if exists \$DYNAMIC_HOOKS{\$what} && + exists \$DYNAMIC_HOOKS{\$what}{\$sub}; + delete \$DYNAMIC_HOOKS{\$what}{\$sub}; + delete \$DYNAMIC_HOOKS{\$what} unless \%{\$DYNAMIC_HOOKS{\$what}}; + }; + die $@ if $@; +} + +1 +} + +weechat::hook_command(SCRIPT_NAME, 'get xterm clipboard', '', '', '', 'paste_cmd', ''); + +our $PASTE_REPLY = ''; +our $ORIG_KEY_CMD = undef; +our $PASTE_TIMEOUT; + +sub request_clip { + my ($stor) = @_; + $stor = '' unless $stor; + my $xterm_osc = "\e]52;$stor;?\a"; + my $compatible_terms = join '|', map { split /[,;]/ } split ' ', + $XTERM_COMPATIBLE; + print STDERR $xterm_osc if $ENV{'TERM'} =~ /^xterm|$compatible_terms/; + if ($ENV{'TMUX'}) { + chomp(my @tmux_clients = `tmux lsc`); + my $active_term; + my $last_time = 0; + for (@tmux_clients) { + my ($path, $rest) = split ':', $_; + next unless $rest =~ / (?:xterm|$compatible_terms)/; + my $atime = -A $path; + if ($last_time >= $atime) { + $last_time = $atime; + $active_term = $path; + } + } + if ($active_term) { + open my $pty, '>>', $active_term; + print $pty $xterm_osc; + } + } +} + +sub paste_cmd { + my (undef, undef, $args) = @_; + if ($args =~ /accept/) { insert_paste() } + else { get_paste() } +} + +sub get_paste { + $PASTE_TIMEOUT = weechat::hook_timer(1000, 0, 1, 'paste_input_stop', ''); + weechat::hook_signal_send('input_flow_free', weechat::WEECHAT_HOOK_SIGNAL_INT, 1); + Nlib::hook_dynamic('modifier', 'input_text_content', 'paste_evt2', ''); + ($ORIG_KEY_CMD) = map { $_->{command} } grep { $_->{key} eq 'ctrl-G' } + Nlib::i2h('key'); + weechat::command('', "/mute /key bind ctrl-G /@{[SCRIPT_NAME]} accept"); + request_clip(); + weechat::WEECHAT_RC_OK +} + +sub insert_paste { + my $paste; + ($paste, $PASTE_REPLY) = ($PASTE_REPLY, ''); + paste_input_stop(); + $paste =~ s/\]?52;.*;$// || + $paste =~ s/.*;//; + my $decode = decode_base64($paste); $decode =~ s/\n/\\x0a/g; + weechat::command(weechat::current_buffer(), "/input insert $decode"); + weechat::WEECHAT_RC_OK +} + +sub paste_input_stop { + my $leftover; + ($leftover, $PASTE_REPLY) = ($PASTE_REPLY, ''); + if ($PASTE_TIMEOUT) { + weechat::unhook($PASTE_TIMEOUT); + $PASTE_TIMEOUT = undef; + } + if ($ORIG_KEY_CMD) { weechat::command('', "/mute /key bind ctrl-G $ORIG_KEY_CMD"); } + else { weechat::command('', '/mute /key reset ctrl-G'); } + $ORIG_KEY_CMD = undef; + Nlib::unhook_dynamic('input_text_content', 'paste_evt2'); + if (length $leftover) { + $leftover =~ s/\\/\\\\/g; + weechat::command(weechat::current_buffer(), "/input insert $leftover"); + } + weechat::hook_signal_send('input_flow_free', weechat::WEECHAT_HOOK_SIGNAL_INT, 0); + weechat::WEECHAT_RC_OK +} + +sub paste_evt2 { + Encode::_utf8_on($_[3]); + my $buf = weechat::current_buffer(); + my $npos = weechat::buffer_get_integer($buf, 'input_pos')-1; + $PASTE_REPLY .= substr $_[3], $npos, 1, ''; + weechat::buffer_set($buf, 'input_pos', $npos); + $_[3] +} + +sub stop_paste { + paste_input_stop() if $PASTE_TIMEOUT; + weechat::WEECHAT_RC_OK +} diff --git a/.weechat/plugins.conf b/.weechat/plugins.conf new file mode 100644 index 0000000..eb17bb2 --- /dev/null +++ b/.weechat/plugins.conf @@ -0,0 +1,183 @@ +# +# weechat -- plugins.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[var] +python.notify_send.desktop_entry = "weechat" +python.notify_send.ellipsis = "[..]" +python.notify_send.escape_html = "on" +python.notify_send.hide_messages_in_buffers_that_match = "" +python.notify_send.icon = "/usr/share/icons/hicolor/32x32/apps/weechat.png" +python.notify_send.ignore_buffers = "" +python.notify_send.ignore_buffers_starting_with = "" +python.notify_send.ignore_messages_tagged_with = "notify_none,irc_join,irc_quit,irc_part,irc_status,irc_nick_back,irc_401,irc_402" +python.notify_send.ignore_nicks = "" +python.notify_send.ignore_nicks_starting_with = "" +python.notify_send.max_length = "72" +python.notify_send.min_notification_delay = "500" +python.notify_send.nick_separator = ": " +python.notify_send.notify_for_current_buffer = "on" +python.notify_send.notify_on_all_messages_in_buffers = "" +python.notify_send.notify_on_all_messages_in_buffers_that_match = "" +python.notify_send.notify_on_filtered_messages = "off" +python.notify_send.notify_on_highlights = "on" +python.notify_send.notify_on_messages_that_match = "" +python.notify_send.notify_on_privmsgs = "on" +python.notify_send.notify_when_away = "on" +python.notify_send.timeout = "5000" +python.notify_send.transient = "on" +python.notify_send.urgency = "normal" +python.slack.auto_open_threads = "true" +python.slack.background_load_all_history = "false" +python.slack.channel_name_typing_indicator = "true" +python.slack.color_buflist_muted_channels = "darkgray" +python.slack.color_deleted = "red" +python.slack.color_edited_suffix = "095" +python.slack.color_reaction_suffix = "darkgray" +python.slack.color_reaction_suffix_added_by_you = "blue" +python.slack.color_thread_suffix = "lightcyan" +python.slack.color_typing_notice = "yellow" +python.slack.colorize_private_chats = "false" +python.slack.debug_level = "3" +python.slack.debug_mode = "false" +python.slack.distracting_channels = "" +python.slack.external_user_suffix = "*" +python.slack.files_download_location = "" +python.slack.group_name_prefix = "&" +python.slack.map_underline_to = "_" +python.slack.migrated = "true" +python.slack.muted_channels_activity = "personal_highlights" +python.slack.never_away = "false" +python.slack.notify_usergroup_handle_updated = "false" +python.slack.record_events = "false" +python.slack.render_bold_as = "bold" +python.slack.render_emoji_as_string = "false" +python.slack.render_italic_as = "italic" +python.slack.send_typing_notice = "true" +python.slack.server_aliases = "" +python.slack.shared_name_prefix = "%" +python.slack.short_buffer_names = "false" +python.slack.show_buflist_presence = "true" +python.slack.show_reaction_nicks = "false" +python.slack.slack_api_token = "xoxp-464648095525-545395379730-589284192565-16dee0b63af5e422a2d05bec94ebc76e" +python.slack.slack_timeout = "20000" +python.slack.switch_buffer_on_join = "true" +python.slack.thread_messages_in_channel = "false" +python.slack.unfurl_auto_link_display = "both" +python.slack.unfurl_ignore_alt_text = "false" +python.slack.unhide_buffers_with_activity = "false" +python.slack.use_full_names = "false" +python.vimode.copy_clipboard_cmd = "xclip -selection c" +python.vimode.imap_esc = "" +python.vimode.imap_esc_timeout = "1000" +python.vimode.line_number_prefix = "" +python.vimode.line_number_suffix = " " +python.vimode.mode_indicator_cmd_color = "white" +python.vimode.mode_indicator_cmd_color_bg = "cyan" +python.vimode.mode_indicator_insert_color = "white" +python.vimode.mode_indicator_insert_color_bg = "blue" +python.vimode.mode_indicator_normal_color = "white" +python.vimode.mode_indicator_normal_color_bg = "gray" +python.vimode.mode_indicator_prefix = "" +python.vimode.mode_indicator_replace_color = "white" +python.vimode.mode_indicator_replace_color_bg = "red" +python.vimode.mode_indicator_search_color = "white" +python.vimode.mode_indicator_search_color_bg = "magenta" +python.vimode.mode_indicator_suffix = "" +python.vimode.no_warn = "off" +python.vimode.paste_clipboard_cmd = "xclip -selection c -o" +python.vimode.search_vim = "off" +python.vimode.user_mappings = "" + +[desc] +python.notify_send.desktop_entry = "Name of the desktop entry for WeeChat. Default: weechat." +python.notify_send.ellipsis = "Ellipsis to be used for notifications that are too long. Default: [..]." +python.notify_send.escape_html = "Escapes the '<', '>', and '&' characters in notification messages. Default: on." +python.notify_send.hide_messages_in_buffers_that_match = "A comma-separated list of regex patterns for names of buffers from which you want to receive notifications without messages. Default: ""." +python.notify_send.icon = "Path to an icon to be shown in notifications. Default: /usr/share/icons/hicolor/32x32/apps/weechat.png." +python.notify_send.ignore_buffers = "A comma-separated list of buffers from which no notifications should be shown. Default: ""." +python.notify_send.ignore_buffers_starting_with = "A comma-separated list of buffer prefixes from which no notifications should be shown. Default: ""." +python.notify_send.ignore_messages_tagged_with = "A comma-separated list of message tags for which no notifications should be shown. Default: notify_none,irc_join,irc_quit,irc_part,irc_status,irc_nick_back,irc_401,irc_402." +python.notify_send.ignore_nicks = "A comma-separated list of nicks from which no notifications should be shown. Default: ""." +python.notify_send.ignore_nicks_starting_with = "A comma-separated list of nick prefixes from which no notifications should be shown. Default: ""." +python.notify_send.max_length = "Maximal length of a notification (0 means no limit). Default: 72." +python.notify_send.min_notification_delay = "A minimal delay between successive notifications from the same buffer (in milliseconds; set to 0 to show all notifications). Default: 500." +python.notify_send.nick_separator = "A separator between a nick and a message. Default: : ." +python.notify_send.notify_for_current_buffer = "Send also notifications for the currently active buffer. Default: on." +python.notify_send.notify_on_all_messages_in_buffers = "A comma-separated list of buffers for which you want to receive notifications on all messages that appear in them. Default: ""." +python.notify_send.notify_on_all_messages_in_buffers_that_match = "A comma-separated list of regex patterns of buffers for which you want to receive notifications on all messages that appear in them. Default: ""." +python.notify_send.notify_on_filtered_messages = "Send notifications also on filtered (hidden) messages. Default: off." +python.notify_send.notify_on_highlights = "Send notifications on highlights. Default: on." +python.notify_send.notify_on_messages_that_match = "A comma-separated list of regex patterns that you want to receive notifications on when message matches. Default: ""." +python.notify_send.notify_on_privmsgs = "Send notifications on private messages. Default: on." +python.notify_send.notify_when_away = "Send also notifications when away. Default: on." +python.notify_send.timeout = "Time after which the notification disappears (in milliseconds; set to 0 to disable). Default: 5000." +python.notify_send.transient = "When a notification expires or is dismissed, remove it from the notification bar. Default: on." +python.notify_send.urgency = "Urgency (low, normal, critical). Default: normal." +python.slack.auto_open_threads = "Automatically open threads when mentioned or inresponse to own messages." +python.slack.background_load_all_history = "Load history for each channel in the background as soon as it opens, rather than waiting for the user to look at it." +python.slack.channel_name_typing_indicator = "Change the prefix of a channel from # to > when someone is typing in it. Note that this will (temporarily) affect the sort order if you sort buffers by name rather than by number." +python.slack.color_buflist_muted_channels = "Color to use for muted channels in the buflist" +python.slack.color_deleted = "Color to use for deleted messages and files." +python.slack.color_edited_suffix = "Color to use for (edited) suffix on messages that have been edited." +python.slack.color_reaction_suffix = "Color to use for the [:wave:(@user)] suffix on messages that have reactions attached to them." +python.slack.color_reaction_suffix_added_by_you = "Color to use for reactions that you have added." +python.slack.color_thread_suffix = "Color to use for the [thread: XXX] suffix on messages that have threads attached to them. The special value "multiple" can be used to use a different color for each thread." +python.slack.color_typing_notice = "Color to use for the typing notice." +python.slack.colorize_private_chats = "Whether to use nick-colors in DM windows." +python.slack.debug_level = "Show only this level of debug info (or higher) when debug_mode is on. Lower levels -> more messages." +python.slack.debug_mode = "Open a dedicated buffer for debug messages and start logging to it. How verbose the logging is depends on log_level." +python.slack.distracting_channels = "List of channels to hide." +python.slack.external_user_suffix = "The suffix appended to nicks to indicate external users." +python.slack.files_download_location = "If set, file attachments will be automatically downloaded to this location. "%h" will be replaced by WeeChat home, "~/.weechat" by default." +python.slack.group_name_prefix = "The prefix of buffer names for groups (private channels)." +python.slack.map_underline_to = "When sending underlined text to slack, use this formatting character for it. The default ("_") sends it as italics. Use "*" to send bold instead." +python.slack.muted_channels_activity = "Control which activity you see from muted channels, either none, personal_highlights, all_highlights or all. none: Don't show any activity. personal_highlights: Only show personal highlights, i.e. not @channel and @here. all_highlights: Show all highlights, but not other messages. all: Show all activity, like other channels." +python.slack.never_away = "Poke Slack every five minutes so that it never marks you "away"." +python.slack.notify_usergroup_handle_updated = "Control if you want to see notification when a usergroup's handle has changed, either true or false." +python.slack.record_events = "Log all traffic from Slack to disk as JSON." +python.slack.render_bold_as = "When receiving bold text from Slack, render it as this in weechat." +python.slack.render_emoji_as_string = "Render emojis as :emoji_name: instead of emoji characters. Enable this if your terminal doesn't support emojis, or set to 'both' if you want to see both renderings. Note that even though this is disabled by default, you need to place https://github.com/wee-slack/wee-slack/blob/master/weemoji.json in your weechat directory to enable rendering emojis as emoji characters." +python.slack.render_italic_as = "When receiving bold text from Slack, render it as this in weechat. If your terminal lacks italic support, consider using "underline" instead." +python.slack.send_typing_notice = "Alert Slack users when you are typing a message in the input bar (Requires reload)" +python.slack.server_aliases = "A comma separated list of `subdomain:alias` pairs. The alias will be used instead of the actual name of the slack (in buffer names, logging, etc). E.g `work:no_fun_allowed` would make your work slack show up as `no_fun_allowed` rather than `work.slack.com`." +python.slack.shared_name_prefix = "The prefix of buffer names for shared channels." +python.slack.short_buffer_names = "Use `foo.#channel` rather than `foo.slack.com.#channel` as the internal name for Slack buffers." +python.slack.show_buflist_presence = "Display a `+` character in the buffer list for present users." +python.slack.show_reaction_nicks = "Display the name of the reacting user(s) alongside each reactji." +python.slack.slack_api_token = "List of Slack API tokens, one per Slack instance you want to connect to. See the README for details on how to get these." +python.slack.slack_timeout = "How long (ms) to wait when communicating with Slack." +python.slack.switch_buffer_on_join = "When /joining a channel, automatically switch to it as well." +python.slack.thread_messages_in_channel = "When enabled shows thread messages in the parent channel." +python.slack.unfurl_auto_link_display = "When displaying ("unfurling") links to channels/users/etc, determine what is displayed when the text matches the url without the protocol. This happens when Slack automatically creates links, e.g. from words separated by dots or email addresses. Set it to "text" to only display the text written by the user, "url" to only display the url or "both" (the default) to display both." +python.slack.unfurl_ignore_alt_text = "When displaying ("unfurling") links to channels/users/etc, ignore the "alt text" present in the message and instead use the canonical name of the thing being linked to." +python.slack.unhide_buffers_with_activity = "When activity occurs on a buffer, unhide it even if it was previously hidden (whether by the user or by the distracting_channels setting)." +python.slack.use_full_names = "Use full names as the nicks for all users. When this is false (the default), display names will be used if set, with a fallback to the full name if display name is not set." +python.vimode.copy_clipboard_cmd = "command used to copy to clipboard; must read input from stdin (default: "xclip -selection c")" +python.vimode.imap_esc = "use alternate mapping to enter Normal mode while in Insert mode; having it set to 'jk' is similar to `:imap jk <Esc>` in vim (default: "")" +python.vimode.imap_esc_timeout = "time in ms to wait for the imap_esc sequence to complete (default: "1000")" +python.vimode.line_number_prefix = "prefix for line numbers (default: "")" +python.vimode.line_number_suffix = "suffix for line numbers (default: " ")" +python.vimode.mode_indicator_cmd_color = "color for mode indicator in Command mode (default: "white")" +python.vimode.mode_indicator_cmd_color_bg = "background color for mode indicator in Command mode (default: "cyan")" +python.vimode.mode_indicator_insert_color = "color for mode indicator in Insert mode (default: "white")" +python.vimode.mode_indicator_insert_color_bg = "background color for mode indicator in Insert mode (default: "blue")" +python.vimode.mode_indicator_normal_color = "color for mode indicator in Normal mode (default: "white")" +python.vimode.mode_indicator_normal_color_bg = "background color for mode indicator in Normal mode (default: "gray")" +python.vimode.mode_indicator_prefix = "prefix for the bar item mode_indicator (default: "")" +python.vimode.mode_indicator_replace_color = "color for mode indicator in Replace mode (default: "white")" +python.vimode.mode_indicator_replace_color_bg = "background color for mode indicator in Replace mode (default: "red")" +python.vimode.mode_indicator_search_color = "color for mode indicator in Search mode (default: "white")" +python.vimode.mode_indicator_search_color_bg = "background color for mode indicator in Search mode (default: "magenta")" +python.vimode.mode_indicator_suffix = "suffix for the bar item mode_indicator (default: "")" +python.vimode.no_warn = "don't warn about problematic keybindings and tmux/screen (default: "off")" +python.vimode.paste_clipboard_cmd = "command used to paste clipboard; must output content to stdout (default: "xclip -selection c -o")" +python.vimode.search_vim = "allow n/N usage after searching (requires an extra <Enter> to return to normal mode) (default: "off")" +python.vimode.user_mappings = "see the `:nmap` command in the README for more info; please do not modify this field manually unless you know what you're doing (default: "")" diff --git a/.weechat/python.conf b/.weechat/python.conf new file mode 100644 index 0000000..187b778 --- /dev/null +++ b/.weechat/python.conf @@ -0,0 +1,14 @@ +# +# weechat -- python.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/python/autoload/notify_send.py b/.weechat/python/autoload/notify_send.py new file mode 120000 index 0000000..deeb9a7 --- /dev/null +++ b/.weechat/python/autoload/notify_send.py @@ -0,0 +1 @@ +../notify_send.py
\ No newline at end of file diff --git a/.weechat/python/autoload/vimode.py b/.weechat/python/autoload/vimode.py new file mode 120000 index 0000000..c6303a5 --- /dev/null +++ b/.weechat/python/autoload/vimode.py @@ -0,0 +1 @@ +../vimode.py
\ No newline at end of file diff --git a/.weechat/python/autoload/wee_slack.py b/.weechat/python/autoload/wee_slack.py new file mode 120000 index 0000000..a77c3bb --- /dev/null +++ b/.weechat/python/autoload/wee_slack.py @@ -0,0 +1 @@ +../wee_slack.py
\ No newline at end of file diff --git a/.weechat/python/notify_send.py b/.weechat/python/notify_send.py new file mode 100644 index 0000000..30b6c48 --- /dev/null +++ b/.weechat/python/notify_send.py @@ -0,0 +1,755 @@ +# -*- coding: utf-8 -*- +# +# Project: weechat-notify-send +# Homepage: https://github.com/s3rvac/weechat-notify-send +# Description: Sends highlight and message notifications through notify-send. +# Requires libnotify. +# License: MIT (see below) +# +# Copyright (c) 2015 by Petr Zemek <s3rvac@gmail.com> and contributors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from __future__ import print_function + +import os +import re +import subprocess +import sys +import time + + +# Ensure that we are running under WeeChat. +try: + import weechat +except ImportError: + sys.exit('This script has to run under WeeChat (https://weechat.org/).') + + +# Name of the script. +SCRIPT_NAME = 'notify_send' + +# Author of the script. +SCRIPT_AUTHOR = 's3rvac' + +# Version of the script. +SCRIPT_VERSION = '0.9 (dev)' + +# License under which the script is distributed. +SCRIPT_LICENSE = 'MIT' + +# Description of the script. +SCRIPT_DESC = 'Sends highlight and message notifications through notify-send.' + +# Name of a function to be called when the script is unloaded. +SCRIPT_SHUTDOWN_FUNC = '' + +# Used character set (utf-8 by default). +SCRIPT_CHARSET = '' + +# Script options. +OPTIONS = { + 'notify_on_highlights': ( + 'on', + 'Send notifications on highlights.' + ), + 'notify_on_privmsgs': ( + 'on', + 'Send notifications on private messages.' + ), + 'notify_on_filtered_messages': ( + 'off', + 'Send notifications also on filtered (hidden) messages.' + ), + 'notify_when_away': ( + 'on', + 'Send also notifications when away.' + ), + 'notify_for_current_buffer': ( + 'on', + 'Send also notifications for the currently active buffer.' + ), + 'notify_on_all_messages_in_buffers': ( + '', + 'A comma-separated list of buffers for which you want to receive ' + 'notifications on all messages that appear in them.' + ), + 'notify_on_all_messages_in_buffers_that_match': ( + '', + 'A comma-separated list of regex patterns of buffers for which you ' + 'want to receive notifications on all messages that appear in them.' + ), + 'notify_on_messages_that_match': ( + '', + 'A comma-separated list of regex patterns that you want to receive ' + 'notifications on when message matches.' + ), + 'min_notification_delay': ( + '500', + 'A minimal delay between successive notifications from the same ' + 'buffer (in milliseconds; set to 0 to show all notifications).' + ), + 'ignore_messages_tagged_with': ( + ','.join([ + 'notify_none', # Buffer with line is not added to hotlist + 'irc_join', # Joined IRC + 'irc_quit', # Quit IRC + 'irc_part', # Parted a channel + 'irc_status', # Status messages + 'irc_nick_back', # A nick is back on server + 'irc_401', # No such nick/channel + 'irc_402', # No such server + ]), + 'A comma-separated list of message tags for which no notifications ' + 'should be shown.' + ), + 'ignore_buffers': ( + '', + 'A comma-separated list of buffers from which no notifications should ' + 'be shown.' + ), + 'ignore_buffers_starting_with': ( + '', + 'A comma-separated list of buffer prefixes from which no ' + 'notifications should be shown.' + ), + 'ignore_nicks': ( + '', + 'A comma-separated list of nicks from which no notifications should ' + 'be shown.' + ), + 'ignore_nicks_starting_with': ( + '', + 'A comma-separated list of nick prefixes from which no ' + 'notifications should be shown.' + ), + 'hide_messages_in_buffers_that_match': ( + '', + 'A comma-separated list of regex patterns for names of buffers from ' + 'which you want to receive notifications without messages.' + ), + 'nick_separator': ( + ': ', + 'A separator between a nick and a message.' + ), + 'escape_html': ( + 'on', + "Escapes the '<', '>', and '&' characters in notification messages." + ), + 'max_length': ( + '72', + 'Maximal length of a notification (0 means no limit).' + ), + 'ellipsis': ( + '[..]', + 'Ellipsis to be used for notifications that are too long.' + ), + 'icon': ( + '/usr/share/icons/hicolor/32x32/apps/weechat.png', + 'Path to an icon to be shown in notifications.' + ), + 'desktop_entry': ( + 'weechat', + 'Name of the desktop entry for WeeChat.' + ), + 'timeout': ( + '5000', + 'Time after which the notification disappears (in milliseconds; ' + 'set to 0 to disable).' + ), + 'transient': ( + 'on', + 'When a notification expires or is dismissed, remove it from the ' + 'notification bar.' + ), + 'urgency': ( + 'normal', + 'Urgency (low, normal, critical).' + ) +} + + +class Notification(object): + """A representation of a notification.""" + + def __init__(self, source, message, icon, desktop_entry, timeout, transient, urgency): + self.source = source + self.message = message + self.icon = icon + self.desktop_entry = desktop_entry + self.timeout = timeout + self.transient = transient + self.urgency = urgency + + +def default_value_of(option): + """Returns the default value of the given option.""" + return OPTIONS[option][0] + + +def add_default_value_to(description, default_value): + """Adds the given default value to the given option description.""" + # All descriptions end with a period, so do not add another period. + return '{} Default: {}.'.format( + description, + default_value if default_value else '""' + ) + + +def nick_that_sent_message(tags, prefix): + """Returns a nick that sent the message based on the given data passed to + the callback. + """ + # 'tags' is a comma-separated list of tags that WeeChat passed to the + # callback. It should contain a tag of the following form: nick_XYZ, where + # XYZ is the nick that sent the message. + for tag in tags: + if tag.startswith('nick_'): + return tag[5:] + + # There is no nick in the tags, so check the prefix as a fallback. + # 'prefix' (str) is the prefix of the printed line with the message. + # Usually (but not always), it is a nick with an optional mode (e.g. on + # IRC, @ denotes an operator and + denotes a user with voice). We have to + # remove the mode (if any) before returning the nick. + # Strip also a space as some protocols (e.g. Matrix) may start prefixes + # with a space. It probably means that the nick has no mode set. + if prefix.startswith(('~', '&', '@', '%', '+', '-', ' ')): + return prefix[1:] + + return prefix + + +def parse_tags(tags): + """Parses the given "list" of tags (str) from WeeChat into a list.""" + return tags.split(',') + + +def message_printed_callback(data, buffer, date, tags, is_displayed, + is_highlight, prefix, message): + """A callback when a message is printed.""" + is_displayed = int(is_displayed) + is_highlight = int(is_highlight) + tags = parse_tags(tags) + nick = nick_that_sent_message(tags, prefix) + + if notification_should_be_sent(buffer, tags, nick, is_displayed, is_highlight, message): + notification = prepare_notification(buffer, nick, message) + send_notification(notification) + + return weechat.WEECHAT_RC_OK + + +def notification_should_be_sent(buffer, tags, nick, is_displayed, is_highlight, message): + """Should a notification be sent?""" + if notification_should_be_sent_disregarding_time(buffer, tags, nick, + is_displayed, is_highlight, message): + # The following function should be called only when the notification + # should be sent (it updates the last notification time). + if not is_below_min_notification_delay(buffer): + return True + return False + + +def notification_should_be_sent_disregarding_time(buffer, tags, nick, + is_displayed, is_highlight, message): + """Should a notification be sent when not considering time?""" + if not nick: + # A nick is required to form a correct notification source/message. + return False + + if i_am_author_of_message(buffer, nick): + return False + + if not is_displayed: + if not notify_on_filtered_messages(): + return False + + if buffer == weechat.current_buffer(): + if not notify_for_current_buffer(): + return False + + if is_away(buffer): + if not notify_when_away(): + return False + + if ignore_notifications_from_messages_tagged_with(tags): + return False + + if ignore_notifications_from_nick(nick): + return False + + if ignore_notifications_from_buffer(buffer): + return False + + if is_private_message(buffer): + return notify_on_private_messages() + + if is_highlight: + return notify_on_highlights() + + if notify_on_messages_that_match(message): + return True + + if notify_on_all_messages_in_buffer(buffer): + return True + + return False + + +def is_below_min_notification_delay(buffer): + """Is a notification in the given buffer below the minimal delay between + successive notifications from the same buffer? + + When called, this function updates the time of the last notification. + """ + # We store the time of the last notification in a buffer-local variable to + # make it persistent over the lifetime of this script. + LAST_NOTIFICATION_TIME_VAR = 'notify_send_last_notification_time' + last_notification_time = buffer_get_float( + buffer, + 'localvar_' + LAST_NOTIFICATION_TIME_VAR + ) + + min_notification_delay = weechat.config_get_plugin('min_notification_delay') + # min_notification_delay is in milliseconds (str). To compare it with + # last_notification_time (float in seconds), we have to convert it to + # seconds (float). + min_notification_delay = float(min_notification_delay) / 1000 + + current_time = time.time() + + # We have to update the last notification time before returning the result. + buffer_set_float( + buffer, + 'localvar_set_' + LAST_NOTIFICATION_TIME_VAR, + current_time + ) + + return (min_notification_delay > 0 and + current_time - last_notification_time < min_notification_delay) + + +def buffer_get_float(buffer, property): + """A variant of weechat.buffer_get_x() for floats. + + This variant is needed because WeeChat supports only buffer_get_string() + and buffer_get_int(). + """ + value = weechat.buffer_get_string(buffer, property) + return float(value) if value else 0.0 + + +def buffer_set_float(buffer, property, value): + """A variant of weechat.buffer_set() for floats. + + This variant is needed because WeeChat supports only integers and strings. + """ + weechat.buffer_set(buffer, property, str(value)) + + +def names_for_buffer(buffer): + """Returns a list of all names for the given buffer.""" + # The 'buffer' parameter passed to our callback is actually the buffer's ID + # (e.g. '0x2719cf0'). We have to check its name (e.g. 'freenode.#weechat') + # and short name (e.g. '#weechat') because these are what users specify in + # their configs. + buffer_names = [] + + full_name = weechat.buffer_get_string(buffer, 'name') + if full_name: + buffer_names.append(full_name) + + short_name = weechat.buffer_get_string(buffer, 'short_name') + if short_name: + buffer_names.append(short_name) + # Consider >channel and #channel to be equal buffer names. The reason + # is that the https://github.com/rawdigits/wee-slack script replaces + # '#' with '>' to indicate that someone in the buffer is typing. This + # fixes the behavior of several configuration options (e.g. + # 'notify_on_all_messages_in_buffers') when weechat_notify_send is used + # together with the wee_slack script. + # + # Note that this is only needed to be done for the short name. Indeed, + # the full name always stays unchanged. + if short_name.startswith('>'): + buffer_names.append('#' + short_name[1:]) + + return buffer_names + + +def notify_for_current_buffer(): + """Should we also send notifications for the current buffer?""" + return weechat.config_get_plugin('notify_for_current_buffer') == 'on' + + +def notify_on_highlights(): + """Should we send notifications on highlights?""" + return weechat.config_get_plugin('notify_on_highlights') == 'on' + + +def notify_on_private_messages(): + """Should we send notifications on private messages?""" + return weechat.config_get_plugin('notify_on_privmsgs') == 'on' + + +def notify_on_filtered_messages(): + """Should we also send notifications for filtered (hidden) messages?""" + return weechat.config_get_plugin('notify_on_filtered_messages') == 'on' + + +def notify_when_away(): + """Should we also send notifications when away?""" + return weechat.config_get_plugin('notify_when_away') == 'on' + + +def is_away(buffer): + """Is the user away?""" + return weechat.buffer_get_string(buffer, 'localvar_away') != '' + + +def is_private_message(buffer): + """Has a private message been sent?""" + return weechat.buffer_get_string(buffer, 'localvar_type') == 'private' + + +def i_am_author_of_message(buffer, nick): + """Am I (the current WeeChat user) the author of the message?""" + return weechat.buffer_get_string(buffer, 'localvar_nick') == nick + + +def split_option_value(option, separator=','): + """Splits the value of the given plugin option by the given separator and + returns the result in a list. + """ + values = weechat.config_get_plugin(option) + if not values: + # When there are no values, return the empty list instead of ['']. + return [] + + return [value.strip() for value in values.split(separator)] + + +def ignore_notifications_from_messages_tagged_with(tags): + """Should notifications be ignored for a message tagged with the given + tags? + """ + ignored_tags = split_option_value('ignore_messages_tagged_with') + for ignored_tag in ignored_tags: + for tag in tags: + if tag == ignored_tag: + return True + return False + + +def ignore_notifications_from_buffer(buffer): + """Should notifications from the given buffer be ignored?""" + buffer_names = names_for_buffer(buffer) + + for buffer_name in buffer_names: + if buffer_name and buffer_name in ignored_buffers(): + return True + + for buffer_name in buffer_names: + for prefix in ignored_buffer_prefixes(): + if prefix and buffer_name.startswith(prefix): + return True + + return False + + +def ignored_buffers(): + """A generator of buffers from which notifications should be ignored.""" + for buffer in split_option_value('ignore_buffers'): + yield buffer + + +def ignored_buffer_prefixes(): + """A generator of buffer prefixes from which notifications should be + ignored. + """ + for prefix in split_option_value('ignore_buffers_starting_with'): + yield prefix + + +def ignore_notifications_from_nick(nick): + """Should notifications from the given nick be ignored?""" + if nick in ignored_nicks(): + return True + + for prefix in ignored_nick_prefixes(): + if prefix and nick.startswith(prefix): + return True + + return False + + +def ignored_nicks(): + """A generator of nicks from which notifications should be ignored.""" + for nick in split_option_value('ignore_nicks'): + yield nick + + +def ignored_nick_prefixes(): + """A generator of nick prefixes from which notifications should be + ignored. + """ + for prefix in split_option_value('ignore_nicks_starting_with'): + yield prefix + + +def notify_on_messages_that_match(message): + """Should we send a notification for the given message, provided it matches + any of the requested patterns? + """ + message_patterns = split_option_value('notify_on_messages_that_match') + for pattern in message_patterns: + if re.search(pattern, message): + return True + + return False + + +def buffers_to_notify_on_all_messages(): + """A generator of buffer names in which the user wants to be notified for + all messages. + """ + for buffer in split_option_value('notify_on_all_messages_in_buffers'): + yield buffer + + +def buffer_patterns_to_notify_on_all_messages(): + """A generator of buffer-name patterns in which the user wants to be + notifier for all messages. + """ + for pattern in split_option_value('notify_on_all_messages_in_buffers_that_match'): + yield pattern + + +def notify_on_all_messages_in_buffer(buffer): + """Does the user want to be notified for all messages in the given buffer? + """ + buffer_names = names_for_buffer(buffer) + + # Option notify_on_all_messages_in_buffers: + for buf in buffers_to_notify_on_all_messages(): + if buf in buffer_names: + return True + + # Option notify_on_all_messages_in_buffers_that_match: + for pattern in buffer_patterns_to_notify_on_all_messages(): + for buf in buffer_names: + if re.search(pattern, buf): + return True + + return False + + +def buffer_patterns_to_hide_messages(): + """A generator of buffer-name patterns in which the user wants to hide + messages. + """ + for pattern in split_option_value('hide_messages_in_buffers_that_match'): + yield pattern + + +def hide_message_in_buffer(buffer): + """Should we hide messages in the given buffer?""" + buffer_names = names_for_buffer(buffer) + + for pattern in buffer_patterns_to_hide_messages(): + for buf in buffer_names: + if re.search(pattern, buf): + return True + + return False + + +def prepare_notification(buffer, nick, message): + """Prepares a notification from the given data.""" + if is_private_message(buffer): + source = nick + else: + source = (weechat.buffer_get_string(buffer, 'short_name') or + weechat.buffer_get_string(buffer, 'name')) + message = nick + nick_separator() + message + + if hide_message_in_buffer(buffer): + message = '' + + max_length = int(weechat.config_get_plugin('max_length')) + if max_length > 0: + ellipsis = weechat.config_get_plugin('ellipsis') + message = shorten_message(message, max_length, ellipsis) + + if weechat.config_get_plugin('escape_html') == 'on': + message = escape_html(message) + + message = escape_slashes(message) + + icon = weechat.config_get_plugin('icon') + desktop_entry = weechat.config_get_plugin('desktop_entry') + timeout = weechat.config_get_plugin('timeout') + transient = should_notifications_be_transient() + urgency = weechat.config_get_plugin('urgency') + + return Notification(source, message, icon, desktop_entry, timeout, transient, urgency) + + +def should_notifications_be_transient(): + """Should the sent notifications be transient, i.e. should they be removed + from the notification bar once they expire or are dismissed? + """ + return weechat.config_get_plugin('transient') == 'on' + + +def nick_separator(): + """Returns a nick separator to be used.""" + separator = weechat.config_get_plugin('nick_separator') + return separator if separator else default_value_of('nick_separator') + + +def shorten_message(message, max_length, ellipsis): + """Shortens the message to at most max_length characters by using the given + ellipsis. + """ + # In Python 2, we need to decode the message and ellipsis into Unicode to + # correctly (1) detect their length and (2) shorten the message. Failing to + # do that could make the shortened message invalid and cause notify-send to + # fail. For example, when we have bytes, we cannot guarantee that we do not + # split the message inside of a multibyte character. + if sys.version_info.major == 2: + try: + message = message.decode('utf-8') + ellipsis = ellipsis.decode('utf-8') + except UnicodeDecodeError: + # Either (or both) of the two cannot be decoded. Continue in a + # best-effort manner. + pass + + message = shorten_unicode_message(message, max_length, ellipsis) + + if sys.version_info.major == 2: + if not isinstance(message, str): + message = message.encode('utf-8') + + return message + + +def shorten_unicode_message(message, max_length, ellipsis): + """An internal specialized version of shorten_message() when the both the + message and ellipsis are str (in Python 3) or unicode (in Python 2). + """ + if max_length <= 0 or len(message) <= max_length: + # Nothing to shorten. + return message + + if len(ellipsis) >= max_length: + # We cannot include any part of the message. + return ellipsis[:max_length] + + return message[:max_length - len(ellipsis)] + ellipsis + + +def escape_html(message): + """Escapes HTML characters in the given message.""" + # Only the following characters need to be escaped + # (https://wiki.ubuntu.com/NotificationDevelopmentGuidelines). + message = message.replace('&', '&') + message = message.replace('<', '<') + message = message.replace('>', '>') + return message + + +def escape_slashes(message): + """Escapes slashes in the given message.""" + # We need to escape backslashes to prevent notify-send from interpreting + # them, e.g. we do not want to print a newline when the message contains + # '\n'. + return message.replace('\\', r'\\') + + +def send_notification(notification): + """Sends the given notification to the user.""" + notify_cmd = ['notify-send', '--app-name', 'weechat'] + if notification.icon: + notify_cmd += ['--icon', notification.icon] + if notification.desktop_entry: + notify_cmd += ['--hint', 'string:desktop-entry:{}'.format(notification.desktop_entry)] + if notification.timeout: + notify_cmd += ['--expire-time', str(notification.timeout)] + if notification.transient: + notify_cmd += ['--hint', 'int:transient:1'] + if notification.urgency: + notify_cmd += ['--urgency', notification.urgency] + # We need to add '--' before the source and message to ensure that + # notify-send considers the remaining parameters as the source and the + # message. This prevents errors when a source or message starts with '--'. + notify_cmd += [ + '--', + # notify-send fails with "No summary specified." when no source is + # specified, so ensure that there is always a non-empty source. + notification.source or '-', + notification.message + ] + + # Prevent notify-send from messing up the WeeChat screen when occasionally + # emitting assertion messages by redirecting the output to /dev/null (users + # would need to run /redraw to fix the screen). + # In Python < 3.3, there is no subprocess.DEVNULL, so we have to use a + # workaround. + with open(os.devnull, 'wb') as devnull: + try: + subprocess.check_call( + notify_cmd, + stderr=subprocess.STDOUT, + stdout=devnull, + ) + except Exception as ex: + error_message = '{} (reason: {!r}). {}'.format( + 'Failed to send the notification via notify-send', + '{}: {}'.format(ex.__class__.__name__, ex), + 'Ensure that you have notify-send installed in your system.', + ) + print(error_message, file=sys.stderr) + + +if __name__ == '__main__': + # Registration. + weechat.register( + SCRIPT_NAME, + SCRIPT_AUTHOR, + SCRIPT_VERSION, + SCRIPT_LICENSE, + SCRIPT_DESC, + SCRIPT_SHUTDOWN_FUNC, + SCRIPT_CHARSET + ) + + # Initialization. + for option, (default_value, description) in OPTIONS.items(): + description = add_default_value_to(description, default_value) + weechat.config_set_desc_plugin(option, description) + if not weechat.config_is_set_plugin(option): + weechat.config_set_plugin(option, default_value) + + # Catch all messages on all buffers and strip colors from them before + # passing them into the callback. + weechat.hook_print('', '', '', 1, 'message_printed_callback', '') diff --git a/.weechat/python/vimode.py b/.weechat/python/vimode.py new file mode 100644 index 0000000..b7dc35c --- /dev/null +++ b/.weechat/python/vimode.py @@ -0,0 +1,1877 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013-2014 Germain Z. <germanosz@gmail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# + +# +# Add vi/vim-like modes to WeeChat. +# + + +import csv +import json +import os +import re +import subprocess +try: + from StringIO import StringIO +except ImportError: + from io import StringIO +import time + +import weechat + + +# Script info. +# ============ + +SCRIPT_NAME = "vimode" +SCRIPT_AUTHOR = "GermainZ <germanosz@gmail.com>" +SCRIPT_VERSION = "0.7" +SCRIPT_LICENSE = "GPL3" +SCRIPT_DESC = ("Add vi/vim-like modes and keybindings to WeeChat.") + + +# Global variables. +# ================= + +# General. +# -------- + +# Halp! Halp! Halp! +GITHUB_BASE = "https://github.com/GermainZ/weechat-vimode/blob/master/" +README_URL = GITHUB_BASE + "README.md" +FAQ_KEYBINDINGS = GITHUB_BASE + "FAQ.md#problematic-key-bindings" +FAQ_ESC = GITHUB_BASE + "FAQ.md#esc-key-not-being-detected-instantly" + +# Holds the text of the tab-completions for the command-line mode. +cmd_compl_text = "" +# Holds the original text of the command-line mode, used for completion. +cmd_text_orig = None +# Index of current suggestion, used for completion. +cmd_compl_pos = 0 +# Used for command-line mode history. +cmd_history = [] +cmd_history_index = 0 +# Used to store the content of the input line when going into COMMAND mode. +input_line_backup = {} +# Mode we're in. One of INSERT, NORMAL, REPLACE, COMMAND or SEARCH. +# SEARCH is only used if search_vim is enabled. +mode = "INSERT" +# Holds normal commands (e.g. "dd"). +vi_buffer = "" +# See `cb_key_combo_default()`. +esc_pressed = 0 +# See `cb_key_pressed()`. +last_signal_time = 0 +# See `start_catching_keys()` for more info. +catching_keys_data = {'amount': 0} +# Used for ; and , to store the last f/F/t/T motion. +last_search_motion = {'motion': None, 'data': None} +# Used for undo history. +undo_history = {} +undo_history_index = {} +# Holds mode colors (loaded from vimode_settings). +mode_colors = {} + +# Script options. +vimode_settings = { + 'no_warn': ("off", ("don't warn about problematic keybindings and " + "tmux/screen")), + 'copy_clipboard_cmd': ("xclip -selection c", + ("command used to copy to clipboard; must read " + "input from stdin")), + 'paste_clipboard_cmd': ("xclip -selection c -o", + ("command used to paste clipboard; must output " + "content to stdout")), + 'imap_esc': ("", ("use alternate mapping to enter Normal mode while in " + "Insert mode; having it set to 'jk' is similar to " + "`:imap jk <Esc>` in vim")), + 'imap_esc_timeout': ("1000", ("time in ms to wait for the imap_esc " + "sequence to complete")), + 'search_vim': ("off", ("allow n/N usage after searching (requires an extra" + " <Enter> to return to normal mode)")), + 'user_mappings': ("", ("see the `:nmap` command in the README for more " + "info; please do not modify this field manually " + "unless you know what you're doing")), + 'mode_indicator_prefix': ("", "prefix for the bar item mode_indicator"), + 'mode_indicator_suffix': ("", "suffix for the bar item mode_indicator"), + 'mode_indicator_normal_color': ("white", + "color for mode indicator in Normal mode"), + 'mode_indicator_normal_color_bg': ("gray", + ("background color for mode indicator " + "in Normal mode")), + 'mode_indicator_insert_color': ("white", + "color for mode indicator in Insert mode"), + 'mode_indicator_insert_color_bg': ("blue", + ("background color for mode indicator " + "in Insert mode")), + 'mode_indicator_replace_color': ("white", + "color for mode indicator in Replace mode"), + 'mode_indicator_replace_color_bg': ("red", + ("background color for mode indicator " + "in Replace mode")), + 'mode_indicator_cmd_color': ("white", + "color for mode indicator in Command mode"), + 'mode_indicator_cmd_color_bg': ("cyan", + ("background color for mode indicator in " + "Command mode")), + 'mode_indicator_search_color': ("white", + "color for mode indicator in Search mode"), + 'mode_indicator_search_color_bg': ("magenta", + ("background color for mode indicator " + "in Search mode")), + 'line_number_prefix': ("", "prefix for line numbers"), + 'line_number_suffix': (" ", "suffix for line numbers") +} + + +# Regex patterns. +# --------------- + +WHITESPACE = re.compile(r"\s") +IS_KEYWORD = re.compile(r"[a-zA-Z0-9_@À-ÿ]") +REGEX_MOTION_LOWERCASE_W = re.compile(r"\b\S|(?<=\s)\S") +REGEX_MOTION_UPPERCASE_W = re.compile(r"(?<=\s)\S") +REGEX_MOTION_UPPERCASE_E = re.compile(r"\S(?!\S)") +REGEX_MOTION_UPPERCASE_B = REGEX_MOTION_UPPERCASE_E +REGEX_MOTION_G_UPPERCASE_E = REGEX_MOTION_UPPERCASE_W +REGEX_MOTION_CARRET = re.compile(r"\S") +REGEX_INT = r"[0-9]" +REGEX_MAP_KEYS_1 = { + re.compile("<([^>]*-)Left>", re.IGNORECASE): '<\\1\x01[[D>', + re.compile("<([^>]*-)Right>", re.IGNORECASE): '<\\1\x01[[C>', + re.compile("<([^>]*-)Up>", re.IGNORECASE): '<\\1\x01[[A>', + re.compile("<([^>]*-)Down>", re.IGNORECASE): '<\\1\x01[[B>', + re.compile("<Left>", re.IGNORECASE): '\x01[[D', + re.compile("<Right>", re.IGNORECASE): '\x01[[C', + re.compile("<Up>", re.IGNORECASE): '\x01[[A', + re.compile("<Down>", re.IGNORECASE): '\x01[[B' +} +REGEX_MAP_KEYS_2 = { + re.compile(r"<C-([^>]*)>", re.IGNORECASE): '\x01\\1', + re.compile(r"<M-([^>]*)>", re.IGNORECASE): '\x01[\\1' +} + +# Regex used to detect problematic keybindings. +# For example: meta-wmeta-s is bound by default to ``/window swap``. +# If the user pressed Esc-w, WeeChat will detect it as meta-w and will not +# send any signal to `cb_key_combo_default()` just yet, since it's the +# beginning of a known key combo. +# Instead, `cb_key_combo_default()` will receive the Esc-ws signal, which +# becomes "ws" after removing the Esc part, and won't know how to handle it. +REGEX_PROBLEMATIC_KEYBINDINGS = re.compile(r"meta-\w(meta|ctrl)") + + +# Vi commands. +# ------------ + +def cmd_nmap(args): + """Add a user-defined key mapping. + + Some (but not all) vim-like key codes are supported to simplify things for + the user: <Up>, <Down>, <Left>, <Right>, <C-...> and <M-...>. + + See Also: + `cmd_unmap()`. + """ + args = args.strip() + if not args: + mappings = vimode_settings['user_mappings'] + if mappings: + weechat.prnt("", "User-defined key mappings:") + for key, mapping in mappings.items(): + weechat.prnt("", "{} -> {}".format(key, mapping)) + else: + weechat.prnt("", "nmap: no mapping found.") + elif not " " in args: + weechat.prnt("", "nmap syntax -> :nmap {lhs} {rhs}") + else: + key, mapping = args.split(" ", 1) + # First pass of replacements. We perform two passes as a simple way to + # avoid incorrect replacements due to dictionaries not being + # insertion-ordered prior to Python 3.7. + for regex, repl in REGEX_MAP_KEYS_1.items(): + key = regex.sub(repl, key) + mapping = regex.sub(repl, mapping) + # Second pass of replacements. + for regex, repl in REGEX_MAP_KEYS_2.items(): + key = regex.sub(repl, key) + mapping = regex.sub(repl, mapping) + mappings = vimode_settings['user_mappings'] + mappings[key] = mapping + weechat.config_set_plugin('user_mappings', json.dumps(mappings)) + vimode_settings['user_mappings'] = mappings + +def cmd_nunmap(args): + """Remove a user-defined key mapping. + + See Also: + `cmd_map()`. + """ + args = args.strip() + if not args: + weechat.prnt("", "nunmap syntax -> :unmap {lhs}") + else: + key = args + for regex, repl in REGEX_MAP_KEYS_1.items(): + key = regex.sub(repl, key) + for regex, repl in REGEX_MAP_KEYS_2.items(): + key = regex.sub(repl, key) + mappings = vimode_settings['user_mappings'] + if key in mappings: + del mappings[key] + weechat.config_set_plugin('user_mappings', json.dumps(mappings)) + vimode_settings['user_mappings'] = mappings + else: + weechat.prnt("", "nunmap: No such mapping") + +# See Also: `cb_exec_cmd()`. +VI_COMMAND_GROUPS = {('h', 'help'): "/help", + ('qa', 'qall', 'quita', 'quitall'): "/exit", + ('q', 'quit'): "/close", + ('w', 'write'): "/save", + ('bN', 'bNext', 'bp', 'bprevious'): "/buffer -1", + ('bn', 'bnext'): "/buffer +1", + ('bd', 'bdel', 'bdelete'): "/close", + ('b#',): "/input jump_last_buffer_displayed", + ('b', 'bu', 'buf', 'buffer'): "/buffer", + ('sp', 'split'): "/window splith", + ('vs', 'vsplit'): "/window splitv", + ('nm', 'nmap'): cmd_nmap, + ('nun', 'nunmap'): cmd_nunmap} + +VI_COMMANDS = dict() +for T, v in VI_COMMAND_GROUPS.items(): + VI_COMMANDS.update(dict.fromkeys(T, v)) + + +# Vi operators. +# ------------- + +# Each operator must have a corresponding function, called "operator_X" where +# X is the operator. For example: `operator_c()`. +VI_OPERATORS = ["c", "d", "y"] + + +# Vi motions. +# ----------- + +# Vi motions. Each motion must have a corresponding function, called +# "motion_X" where X is the motion (e.g. `motion_w()`). +# See Also: `SPECIAL_CHARS`. +VI_MOTIONS = ["w", "e", "b", "^", "$", "h", "l", "W", "E", "B", "f", "F", "t", + "T", "ge", "gE", "0"] + +# Special characters for motions. The corresponding function's name is +# converted before calling. For example, "^" will call `motion_carret` instead +# of `motion_^` (which isn't allowed because of illegal characters). +SPECIAL_CHARS = {'^': "carret", + '$': "dollar"} + + +# Methods for vi operators, motions and key bindings. +# =================================================== + +# Documented base examples: +# ------------------------- + +def operator_base(buf, input_line, pos1, pos2, overwrite): + """Operator method example. + + Args: + buf (str): pointer to the current WeeChat buffer. + input_line (str): the content of the input line. + pos1 (int): the starting position of the motion. + pos2 (int): the ending position of the motion. + overwrite (bool, optional): whether the character at the cursor's new + position should be overwritten or not (for inclusive motions). + Defaults to False. + + Notes: + Should be called "operator_X", where X is the operator, and defined in + `VI_OPERATORS`. + Must perform actions (e.g. modifying the input line) on its own, + using the WeeChat API. + + See Also: + For additional examples, see `operator_d()` and + `operator_y()`. + """ + # Get start and end positions. + start = min(pos1, pos2) + end = max(pos1, pos2) + # Print the text the operator should go over. + weechat.prnt("", "Selection: %s" % input_line[start:end]) + +def motion_base(input_line, cur, count): + """Motion method example. + + Args: + input_line (str): the content of the input line. + cur (int): the position of the cursor. + count (int): the amount of times to multiply or iterate the action. + + Returns: + A tuple containing three values: + int: the new position of the cursor. + bool: True if the motion is inclusive, False otherwise. + bool: True if the motion is catching, False otherwise. + See `start_catching_keys()` for more info on catching motions. + + Notes: + Should be called "motion_X", where X is the motion, and defined in + `VI_MOTIONS`. + Must not modify the input line directly. + + See Also: + For additional examples, see `motion_w()` (normal motion) and + `motion_f()` (catching motion). + """ + # Find (relative to cur) position of next number. + pos = get_pos(input_line, REGEX_INT, cur, True, count) + # Return the new (absolute) cursor position. + # This motion is exclusive, so overwrite is False. + return cur + pos, False + +def key_base(buf, input_line, cur, count): + """Key method example. + + Args: + buf (str): pointer to the current WeeChat buffer. + input_line (str): the content of the input line. + cur (int): the position of the cursor. + count (int): the amount of times to multiply or iterate the action. + + Notes: + Should be called `key_X`, where X represents the key(s), and defined + in `VI_KEYS`. + Must perform actions on its own (using the WeeChat API). + + See Also: + For additional examples, see `key_a()` (normal key) and + `key_r()` (catching key). + """ + # Key was pressed. Go to Insert mode (similar to "i"). + set_mode("INSERT") + + +# Operators: +# ---------- + +def operator_d(buf, input_line, pos1, pos2, overwrite=False): + """Delete text from `pos1` to `pos2` from the input line. + + If `overwrite` is set to True, the character at the cursor's new position + is removed as well (the motion is inclusive). + + See Also: + `operator_base()`. + """ + start = min(pos1, pos2) + end = max(pos1, pos2) + if overwrite: + end += 1 + input_line = list(input_line) + del input_line[start:end] + input_line = "".join(input_line) + weechat.buffer_set(buf, "input", input_line) + set_cur(buf, input_line, pos1) + +def operator_c(buf, input_line, pos1, pos2, overwrite=False): + """Delete text from `pos1` to `pos2` from the input and enter Insert mode. + + If `overwrite` is set to True, the character at the cursor's new position + is removed as well (the motion is inclusive.) + + See Also: + `operator_base()`. + """ + operator_d(buf, input_line, pos1, pos2, overwrite) + set_mode("INSERT") + +def operator_y(buf, input_line, pos1, pos2, _): + """Yank text from `pos1` to `pos2` from the input line. + + See Also: + `operator_base()`. + """ + start = min(pos1, pos2) + end = max(pos1, pos2) + cmd = vimode_settings['copy_clipboard_cmd'] + proc = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE) + proc.communicate(input=input_line[start:end].encode()) + + +# Motions: +# -------- + +def motion_0(input_line, cur, count): + """Go to the first character of the line. + + See Also; + `motion_base()`. + """ + return 0, False, False + +def motion_w(input_line, cur, count): + """Go `count` words forward and return position. + + See Also: + `motion_base()`. + """ + pos = get_pos(input_line, REGEX_MOTION_LOWERCASE_W, cur, True, count) + if pos == -1: + return len(input_line), False, False + return cur + pos, False, False + +def motion_W(input_line, cur, count): + """Go `count` WORDS forward and return position. + + See Also: + `motion_base()`. + """ + pos = get_pos(input_line, REGEX_MOTION_UPPERCASE_W, cur, True, count) + if pos == -1: + return len(input_line), False, False + return cur + pos, False, False + +def motion_e(input_line, cur, count): + """Go to the end of `count` words and return position. + + See Also: + `motion_base()`. + """ + for _ in range(max(1, count)): + found = False + pos = cur + for pos in range(cur + 1, len(input_line) - 1): + # Whitespace, keep going. + if WHITESPACE.match(input_line[pos]): + pass + # End of sequence made from 'iskeyword' characters only, + # or end of sequence made from non 'iskeyword' characters only. + elif ((IS_KEYWORD.match(input_line[pos]) and + (not IS_KEYWORD.match(input_line[pos + 1]) or + WHITESPACE.match(input_line[pos + 1]))) or + (not IS_KEYWORD.match(input_line[pos]) and + (IS_KEYWORD.match(input_line[pos + 1]) or + WHITESPACE.match(input_line[pos + 1])))): + found = True + cur = pos + break + # We're at the character before the last and we still found nothing. + # Go to the last character. + if not found: + cur = pos + 1 + return cur, True, False + +def motion_E(input_line, cur, count): + """Go to the end of `count` WORDS and return cusor position. + + See Also: + `motion_base()`. + """ + pos = get_pos(input_line, REGEX_MOTION_UPPERCASE_E, cur, True, count) + if pos == -1: + return len(input_line), False, False + return cur + pos, True, False + +def motion_b(input_line, cur, count): + """Go `count` words backwards and return position. + + See Also: + `motion_base()`. + """ + # "b" is just "e" on inverted data (e.g. "olleH" instead of "Hello"). + pos_inv = motion_e(input_line[::-1], len(input_line) - cur - 1, count)[0] + pos = len(input_line) - pos_inv - 1 + return pos, True, False + +def motion_B(input_line, cur, count): + """Go `count` WORDS backwards and return position. + + See Also: + `motion_base()`. + """ + new_cur = len(input_line) - cur + pos = get_pos(input_line[::-1], REGEX_MOTION_UPPERCASE_B, new_cur, + count=count) + if pos == -1: + return 0, False, False + pos = len(input_line) - (pos + new_cur + 1) + return pos, True, False + +def motion_ge(input_line, cur, count): + """Go to end of `count` words backwards and return position. + + See Also: + `motion_base()`. + """ + # "ge is just "w" on inverted data (e.g. "olleH" instead of "Hello"). + pos_inv = motion_w(input_line[::-1], len(input_line) - cur - 1, count)[0] + pos = len(input_line) - pos_inv - 1 + return pos, True, False + +def motion_gE(input_line, cur, count): + """Go to end of `count` WORDS backwards and return position. + + See Also: + `motion_base()`. + """ + new_cur = len(input_line) - cur - 1 + pos = get_pos(input_line[::-1], REGEX_MOTION_G_UPPERCASE_E, new_cur, + True, count) + if pos == -1: + return 0, False, False + pos = len(input_line) - (pos + new_cur + 1) + return pos, True, False + +def motion_h(input_line, cur, count): + """Go `count` characters to the left and return position. + + See Also: + `motion_base()`. + """ + return max(0, cur - max(count, 1)), False, False + +def motion_l(input_line, cur, count): + """Go `count` characters to the right and return position. + + See Also: + `motion_base()`. + """ + return cur + max(count, 1), False, False + +def motion_carret(input_line, cur, count): + """Go to first non-blank character of line and return position. + + See Also: + `motion_base()`. + """ + pos = get_pos(input_line, REGEX_MOTION_CARRET, 0) + return pos, False, False + +def motion_dollar(input_line, cur, count): + """Go to end of line and return position. + + See Also: + `motion_base()`. + """ + pos = len(input_line) + return pos, False, False + +def motion_f(input_line, cur, count): + """Go to `count`'th occurence of character and return position. + + See Also: + `motion_base()`. + """ + return start_catching_keys(1, "cb_motion_f", input_line, cur, count) + +def cb_motion_f(update_last=True): + """Callback for `motion_f()`. + + Args: + update_last (bool, optional): should `last_search_motion` be updated? + Set to False when calling from `key_semicolon()` or `key_comma()` + so that the last search motion isn't overwritten. + Defaults to True. + + See Also: + `start_catching_keys()`. + """ + global last_search_motion + pattern = catching_keys_data['keys'] + pos = get_pos(catching_keys_data['input_line'], re.escape(pattern), + catching_keys_data['cur'], True, + catching_keys_data['count']) + catching_keys_data['new_cur'] = max(0, pos) + catching_keys_data['cur'] + if update_last: + last_search_motion = {'motion': "f", 'data': pattern} + cb_key_combo_default(None, None, "") + +def motion_F(input_line, cur, count): + """Go to `count`'th occurence of char to the right and return position. + + See Also: + `motion_base()`. + """ + return start_catching_keys(1, "cb_motion_F", input_line, cur, count) + +def cb_motion_F(update_last=True): + """Callback for `motion_F()`. + + Args: + update_last (bool, optional): should `last_search_motion` be updated? + Set to False when calling from `key_semicolon()` or `key_comma()` + so that the last search motion isn't overwritten. + Defaults to True. + + See Also: + `start_catching_keys()`. + """ + global last_search_motion + pattern = catching_keys_data['keys'] + cur = len(catching_keys_data['input_line']) - catching_keys_data['cur'] + pos = get_pos(catching_keys_data['input_line'][::-1], + re.escape(pattern), + cur, + False, + catching_keys_data['count']) + catching_keys_data['new_cur'] = catching_keys_data['cur'] - max(0, pos + 1) + if update_last: + last_search_motion = {'motion': "F", 'data': pattern} + cb_key_combo_default(None, None, "") + +def motion_t(input_line, cur, count): + """Go to `count`'th occurence of char and return position. + + The position returned is the position of the character to the left of char. + + See Also: + `motion_base()`. + """ + return start_catching_keys(1, "cb_motion_t", input_line, cur, count) + +def cb_motion_t(update_last=True): + """Callback for `motion_t()`. + + Args: + update_last (bool, optional): should `last_search_motion` be updated? + Set to False when calling from `key_semicolon()` or `key_comma()` + so that the last search motion isn't overwritten. + Defaults to True. + + See Also: + `start_catching_keys()`. + """ + global last_search_motion + pattern = catching_keys_data['keys'] + pos = get_pos(catching_keys_data['input_line'], re.escape(pattern), + catching_keys_data['cur'] + 1, + True, catching_keys_data['count']) + pos += 1 + if pos > 0: + catching_keys_data['new_cur'] = pos + catching_keys_data['cur'] - 1 + else: + catching_keys_data['new_cur'] = catching_keys_data['cur'] + if update_last: + last_search_motion = {'motion': "t", 'data': pattern} + cb_key_combo_default(None, None, "") + +def motion_T(input_line, cur, count): + """Go to `count`'th occurence of char to the left and return position. + + The position returned is the position of the character to the right of + char. + + See Also: + `motion_base()`. + """ + return start_catching_keys(1, "cb_motion_T", input_line, cur, count) + +def cb_motion_T(update_last=True): + """Callback for `motion_T()`. + + Args: + update_last (bool, optional): should `last_search_motion` be updated? + Set to False when calling from `key_semicolon()` or `key_comma()` + so that the last search motion isn't overwritten. + Defaults to True. + + See Also: + `start_catching_keys()`. + """ + global last_search_motion + pattern = catching_keys_data['keys'] + pos = get_pos(catching_keys_data['input_line'][::-1], re.escape(pattern), + (len(catching_keys_data['input_line']) - + (catching_keys_data['cur'] + 1)) + 1, + True, catching_keys_data['count']) + pos += 1 + if pos > 0: + catching_keys_data['new_cur'] = catching_keys_data['cur'] - pos + 1 + else: + catching_keys_data['new_cur'] = catching_keys_data['cur'] + if update_last: + last_search_motion = {'motion': "T", 'data': pattern} + cb_key_combo_default(None, None, "") + + +# Keys: +# ----- + +def key_cc(buf, input_line, cur, count): + """Delete line and start Insert mode. + + See Also: + `key_base()`. + """ + weechat.command("", "/input delete_line") + set_mode("INSERT") + +def key_C(buf, input_line, cur, count): + """Delete from cursor to end of line and start Insert mode. + + See Also: + `key_base()`. + """ + weechat.command("", "/input delete_end_of_line") + set_mode("INSERT") + +def key_yy(buf, input_line, cur, count): + """Yank line. + + See Also: + `key_base()`. + """ + cmd = vimode_settings['copy_clipboard_cmd'] + proc = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE) + proc.communicate(input=input_line.encode()) + +def key_p(buf, input_line, cur, count): + """Paste text. + + See Also: + `key_base()`. + """ + cmd = vimode_settings['paste_clipboard_cmd'] + weechat.hook_process(cmd, 10 * 1000, "cb_key_p", weechat.current_buffer()) + +def cb_key_p(data, command, return_code, output, err): + """Callback for fetching clipboard text and pasting it.""" + buf = "" + this_buffer = data + if output != "": + buf += output.strip() + if return_code == 0: + my_input = weechat.buffer_get_string(this_buffer, "input") + pos = weechat.buffer_get_integer(this_buffer, "input_pos") + my_input = my_input[:pos] + buf + my_input[pos:] + pos += len(buf) + weechat.buffer_set(this_buffer, "input", my_input) + weechat.buffer_set(this_buffer, "input_pos", str(pos)) + return weechat.WEECHAT_RC_OK + +def key_i(buf, input_line, cur, count): + """Start Insert mode. + + See Also: + `key_base()`. + """ + set_mode("INSERT") + +def key_a(buf, input_line, cur, count): + """Move cursor one character to the right and start Insert mode. + + See Also: + `key_base()`. + """ + set_cur(buf, input_line, cur + 1, False) + set_mode("INSERT") + +def key_A(buf, input_line, cur, count): + """Move cursor to end of line and start Insert mode. + + See Also: + `key_base()`. + """ + set_cur(buf, input_line, len(input_line), False) + set_mode("INSERT") + +def key_I(buf, input_line, cur, count): + """Move cursor to first non-blank character and start Insert mode. + + See Also: + `key_base()`. + """ + pos, _, _ = motion_carret(input_line, cur, 0) + set_cur(buf, input_line, pos) + set_mode("INSERT") + +def key_G(buf, input_line, cur, count): + """Scroll to specified line or bottom of buffer. + + See Also: + `key_base()`. + """ + if count > 0: + # This is necessary to prevent weird scroll jumps. + weechat.command("", "/window scroll_top") + weechat.command("", "/window scroll %s" % (count - 1)) + else: + weechat.command("", "/window scroll_bottom") + +def key_r(buf, input_line, cur, count): + """Replace `count` characters under the cursor. + + See Also: + `key_base()`. + """ + start_catching_keys(1, "cb_key_r", input_line, cur, count, buf) + +def cb_key_r(): + """Callback for `key_r()`. + + See Also: + `start_catching_keys()`. + """ + global catching_keys_data + input_line = list(catching_keys_data['input_line']) + count = max(catching_keys_data['count'], 1) + cur = catching_keys_data['cur'] + if cur + count <= len(input_line): + for _ in range(count): + input_line[cur] = catching_keys_data['keys'] + cur += 1 + input_line = "".join(input_line) + weechat.buffer_set(catching_keys_data['buf'], "input", input_line) + set_cur(catching_keys_data['buf'], input_line, cur - 1) + catching_keys_data = {'amount': 0} + +def key_R(buf, input_line, cur, count): + """Start Replace mode. + + See Also: + `key_base()`. + """ + set_mode("REPLACE") + +def key_tilda(buf, input_line, cur, count): + """Switch the case of `count` characters under the cursor. + + See Also: + `key_base()`. + """ + input_line = list(input_line) + count = max(1, count) + while count and cur < len(input_line): + input_line[cur] = input_line[cur].swapcase() + count -= 1 + cur += 1 + input_line = "".join(input_line) + weechat.buffer_set(buf, "input", input_line) + set_cur(buf, input_line, cur) + +def key_alt_j(buf, input_line, cur, count): + """Go to WeeChat buffer. + + Called to preserve WeeChat's alt-j buffer switching. + + This is only called when alt-j<num> is pressed after pressing Esc, because + \x01\x01j is received in key_combo_default which becomes \x01j after + removing the detected Esc key. + If Esc isn't the last pressed key, \x01j<num> is directly received in + key_combo_default. + """ + start_catching_keys(2, "cb_key_alt_j", input_line, cur, count) + +def cb_key_alt_j(): + """Callback for `key_alt_j()`. + + See Also: + `start_catching_keys()`. + """ + global catching_keys_data + weechat.command("", "/buffer " + catching_keys_data['keys']) + catching_keys_data = {'amount': 0} + +def key_semicolon(buf, input_line, cur, count, swap=False): + """Repeat last f, t, F, T `count` times. + + Args: + swap (bool, optional): if True, the last motion will be repeated in the + opposite direction (e.g. "f" instead of "F"). Defaults to False. + + See Also: + `key_base()`. + """ + global catching_keys_data, vi_buffer + catching_keys_data = ({'amount': 0, + 'input_line': input_line, + 'cur': cur, + 'keys': last_search_motion['data'], + 'count': count, + 'new_cur': 0, + 'buf': buf}) + # Swap the motion's case if called from key_comma. + if swap: + motion = last_search_motion['motion'].swapcase() + else: + motion = last_search_motion['motion'] + func = "cb_motion_%s" % motion + vi_buffer = motion + globals()[func](False) + +def key_comma(buf, input_line, cur, count): + """Repeat last f, t, F, T in opposite direction `count` times. + + See Also: + `key_base()`. + """ + key_semicolon(buf, input_line, cur, count, True) + +def key_u(buf, input_line, cur, count): + """Undo change `count` times. + + See Also: + `key_base()`. + """ + buf = weechat.current_buffer() + if buf not in undo_history: + return + for _ in range(max(count, 1)): + if undo_history_index[buf] > -len(undo_history[buf]): + undo_history_index[buf] -= 1 + input_line = undo_history[buf][undo_history_index[buf]] + weechat.buffer_set(buf, "input", input_line) + else: + break + +def key_ctrl_r(buf, input_line, cur, count): + """Redo change `count` times. + + See Also: + `key_base()`. + """ + if buf not in undo_history: + return + for _ in range(max(count, 1)): + if undo_history_index[buf] < -1: + undo_history_index[buf] += 1 + input_line = undo_history[buf][undo_history_index[buf]] + weechat.buffer_set(buf, "input", input_line) + else: + break + + +# Vi key bindings. +# ================ + +# String values will be executed as normal WeeChat commands. +# For functions, see `key_base()` for reference. +VI_KEYS = {'j': "/window scroll_down", + 'k': "/window scroll_up", + 'G': key_G, + 'gg': "/window scroll_top", + 'x': "/input delete_next_char", + 'X': "/input delete_previous_char", + 'dd': "/input delete_line", + 'D': "/input delete_end_of_line", + 'cc': key_cc, + 'C': key_C, + 'i': key_i, + 'a': key_a, + 'A': key_A, + 'I': key_I, + 'yy': key_yy, + 'p': key_p, + 'gt': "/buffer -1", + 'K': "/buffer -1", + 'gT': "/buffer +1", + 'J': "/buffer +1", + 'r': key_r, + 'R': key_R, + '~': key_tilda, + 'nt': "/bar scroll nicklist * -100%", + 'nT': "/bar scroll nicklist * +100%", + '\x01[[A': "/input history_previous", + '\x01[[B': "/input history_next", + '\x01[[C': "/input move_next_char", + '\x01[[D': "/input move_previous_char", + '\x01[[H': "/input move_beginning_of_line", + '\x01[[F': "/input move_end_of_line", + '\x01[[5~': "/window page_up", + '\x01[[6~': "/window page_down", + '\x01[[3~': "/input delete_next_char", + '\x01[[2~': key_i, + '\x01M': "/input return", + '\x01?': "/input move_previous_char", + ' ': "/input move_next_char", + '\x01[j': key_alt_j, + '\x01[1': "/buffer *1", + '\x01[2': "/buffer *2", + '\x01[3': "/buffer *3", + '\x01[4': "/buffer *4", + '\x01[5': "/buffer *5", + '\x01[6': "/buffer *6", + '\x01[7': "/buffer *7", + '\x01[8': "/buffer *8", + '\x01[9': "/buffer *9", + '\x01[0': "/buffer *10", + '\x01^': "/input jump_last_buffer_displayed", + '\x01D': "/window page_down", + '\x01U': "/window page_up", + '\x01Wh': "/window left", + '\x01Wj': "/window down", + '\x01Wk': "/window up", + '\x01Wl': "/window right", + '\x01W=': "/window balance", + '\x01Wx': "/window swap", + '\x01Ws': "/window splith", + '\x01Wv': "/window splitv", + '\x01Wq': "/window merge", + ';': key_semicolon, + ',': key_comma, + 'u': key_u, + '\x01R': key_ctrl_r} + +# Add alt-j<number> bindings. +for i in range(10, 99): + VI_KEYS['\x01[j%s' % i] = "/buffer %s" % i + + +# Key handling. +# ============= + +def cb_key_pressed(data, signal, signal_data): + """Detect potential Esc presses. + + Alt and Esc are detected as the same key in most terminals. The difference + is that Alt signal is sent just before the other pressed key's signal. + We therefore use a timeout (50ms) to detect whether Alt or Esc was pressed. + """ + global last_signal_time + last_signal_time = time.time() + if signal_data == "\x01[": + # In 50ms, check if any other keys were pressed. If not, it's Esc! + weechat.hook_timer(50, 0, 1, "cb_check_esc", + "{:f}".format(last_signal_time)) + return weechat.WEECHAT_RC_OK + +def cb_check_esc(data, remaining_calls): + """Check if the Esc key was pressed and change the mode accordingly.""" + global esc_pressed, vi_buffer, catching_keys_data + # Not perfect, would be better to use direct comparison (==) but that only + # works for py2 and not for py3. + if abs(last_signal_time - float(data)) <= 0.000001: + esc_pressed += 1 + if mode == "SEARCH": + weechat.command("", "/input search_stop_here") + set_mode("NORMAL") + # Cancel any current partial commands. + vi_buffer = "" + catching_keys_data = {'amount': 0} + weechat.bar_item_update("vi_buffer") + return weechat.WEECHAT_RC_OK + +def cb_key_combo_default(data, signal, signal_data): + """Eat and handle key events when in Normal mode, if needed. + + The key_combo_default signal is sent when a key combo is pressed. For + example, alt-k will send the "\x01[k" signal. + + Esc is handled a bit differently to avoid delays, see `cb_key_pressed()`. + """ + global esc_pressed, vi_buffer, cmd_compl_text, cmd_text_orig, \ + cmd_compl_pos, cmd_history_index + + # If Esc was pressed, strip the Esc part from the pressed keys. + # Example: user presses Esc followed by i. This is detected as "\x01[i", + # but we only want to handle "i". + keys = signal_data + if esc_pressed or esc_pressed == -2: + if keys.startswith("\x01[" * esc_pressed): + # Multiples of 3 seem to "cancel" themselves, + # e.g. Esc-Esc-Esc-Alt-j-11 is detected as "\x01[\x01[\x01" + # followed by "\x01[j11" (two different signals). + if signal_data == "\x01[" * 3: + esc_pressed = -1 # `cb_check_esc()` will increment it to 0. + else: + esc_pressed = 0 + # This can happen if a valid combination is started but interrupted + # with Esc, such as Ctrl-W→Esc→w which would send two signals: + # "\x01W\x01[" then "\x01W\x01[w". + # In that case, we still need to handle the next signal ("\x01W\x01[w") + # so we use the special value "-2". + else: + esc_pressed = -2 + keys = keys.split("\x01[")[-1] # Remove the "Esc" part(s). + # Ctrl-Space. + elif keys == "\x01@": + set_mode("NORMAL") + return weechat.WEECHAT_RC_OK_EAT + + # Clear the undo history for this buffer on <Return>. + if keys == "\x01M": + buf = weechat.current_buffer() + clear_undo_history(buf) + + # Detect imap_esc presses if any. + if mode == "INSERT": + imap_esc = vimode_settings['imap_esc'] + if not imap_esc: + return weechat.WEECHAT_RC_OK + if (imap_esc.startswith(vi_buffer) and + imap_esc[len(vi_buffer):len(vi_buffer)+1] == keys): + vi_buffer += keys + weechat.bar_item_update("vi_buffer") + weechat.hook_timer(int(vimode_settings['imap_esc_timeout']), 0, 1, + "cb_check_imap_esc", vi_buffer) + elif (vi_buffer and imap_esc.startswith(vi_buffer) and + imap_esc[len(vi_buffer):len(vi_buffer)+1] != keys): + vi_buffer = "" + weechat.bar_item_update("vi_buffer") + # imap_esc sequence detected -- remove the sequence keys from the + # Weechat input bar and enter Normal mode. + if imap_esc == vi_buffer: + buf = weechat.current_buffer() + input_line = weechat.buffer_get_string(buf, "input") + cur = weechat.buffer_get_integer(buf, "input_pos") + input_line = (input_line[:cur-len(imap_esc)+1] + + input_line[cur:]) + weechat.buffer_set(buf, "input", input_line) + set_cur(buf, input_line, cur-len(imap_esc)+1, False) + set_mode("NORMAL") + vi_buffer = "" + weechat.bar_item_update("vi_buffer") + return weechat.WEECHAT_RC_OK_EAT + return weechat.WEECHAT_RC_OK + + # We're in Replace mode — allow "normal" key presses (e.g. "a") and + # overwrite the next character with them, but let the other key presses + # pass normally (e.g. backspace, arrow keys, etc). + if mode == "REPLACE": + if len(keys) == 1: + weechat.command("", "/input delete_next_char") + elif keys == "\x01?": + weechat.command("", "/input move_previous_char") + return weechat.WEECHAT_RC_OK_EAT + return weechat.WEECHAT_RC_OK + + # We're catching keys! Only "normal" key presses interest us (e.g. "a"), + # not complex ones (e.g. backspace). + if len(keys) == 1 and catching_keys_data['amount']: + catching_keys_data['keys'] += keys + catching_keys_data['amount'] -= 1 + # Done catching keys, execute the callback. + if catching_keys_data['amount'] == 0: + globals()[catching_keys_data['callback']]() + vi_buffer = "" + weechat.bar_item_update("vi_buffer") + return weechat.WEECHAT_RC_OK_EAT + + # We're in command-line mode. + if mode == "COMMAND": + buf = weechat.current_buffer() + cmd_text = weechat.buffer_get_string(buf, "input") + weechat.hook_timer(1, 0, 1, "cb_check_cmd_mode", "") + # Return key. + if keys == "\x01M": + weechat.hook_timer(1, 0, 1, "cb_exec_cmd", cmd_text) + if len(cmd_text) > 1 and (not cmd_history or + cmd_history[-1] != cmd_text): + cmd_history.append(cmd_text) + cmd_history_index = 0 + set_mode("NORMAL") + buf = weechat.current_buffer() + input_line = input_line_backup[buf]['input_line'] + weechat.buffer_set(buf, "input", input_line) + set_cur(buf, input_line, input_line_backup[buf]['cur'], False) + # Up arrow. + elif keys == "\x01[[A": + if cmd_history_index > -len(cmd_history): + cmd_history_index -= 1 + cmd_text = cmd_history[cmd_history_index] + weechat.buffer_set(buf, "input", cmd_text) + set_cur(buf, cmd_text, len(cmd_text), False) + # Down arrow. + elif keys == "\x01[[B": + if cmd_history_index < -1: + cmd_history_index += 1 + cmd_text = cmd_history[cmd_history_index] + else: + cmd_history_index = 0 + cmd_text = ":" + weechat.buffer_set(buf, "input", cmd_text) + set_cur(buf, cmd_text, len(cmd_text), False) + # Tab key. No completion when searching ("/"). + elif keys == "\x01I" and cmd_text[0] == ":": + if cmd_text_orig is None: + input_ = list(cmd_text) + del input_[0] + cmd_text_orig = "".join(input_) + cmd_compl_list = [] + for cmd in VI_COMMANDS.keys(): + if cmd.startswith(cmd_text_orig): + cmd_compl_list.append(cmd) + if cmd_compl_list: + curr_suggestion = cmd_compl_list[cmd_compl_pos] + cmd_text = ":%s" % curr_suggestion + cmd_compl_list[cmd_compl_pos] = weechat.string_eval_expression( + "${color:bold}%s${color:-bold}" % curr_suggestion, + {}, {}, {}) + cmd_compl_text = ", ".join(cmd_compl_list) + cmd_compl_pos = (cmd_compl_pos + 1) % len(cmd_compl_list) + weechat.buffer_set(buf, "input", cmd_text) + set_cur(buf, cmd_text, len(cmd_text), False) + # Input. + else: + cmd_compl_text = "" + cmd_text_orig = None + cmd_compl_pos = 0 + weechat.bar_item_update("cmd_completion") + if keys in ["\x01M", "\x01[[A", "\x01[[B"]: + cmd_compl_text = "" + return weechat.WEECHAT_RC_OK_EAT + else: + return weechat.WEECHAT_RC_OK + # Enter command mode. + elif keys in [":", "/"]: + if keys == "/": + weechat.command("", "/input search_text_here") + if not weechat.config_string_to_boolean( + vimode_settings['search_vim']): + return weechat.WEECHAT_RC_OK + else: + buf = weechat.current_buffer() + cur = weechat.buffer_get_integer(buf, "input_pos") + input_line = weechat.buffer_get_string(buf, "input") + input_line_backup[buf] = {'input_line': input_line, 'cur': cur} + input_line = ":" + weechat.buffer_set(buf, "input", input_line) + set_cur(buf, input_line, 1, False) + set_mode("COMMAND") + cmd_compl_text = "" + cmd_text_orig = None + cmd_compl_pos = 0 + return weechat.WEECHAT_RC_OK_EAT + + # Add key to the buffer. + vi_buffer += keys + weechat.bar_item_update("vi_buffer") + if not vi_buffer: + return weechat.WEECHAT_RC_OK + + # Check if the keys have a (partial or full) match. If so, also get the + # keys without the count. (These are the actual keys we should handle.) + # After that, `vi_buffer` is only used for display purposes — only + # `vi_keys` is checked for all the handling. + # If no matches are found, the keys buffer is cleared. + matched, vi_keys, count = get_keys_and_count(vi_buffer) + if not matched: + vi_buffer = "" + return weechat.WEECHAT_RC_OK_EAT + # Check if it's a command (user defined key mapped to a :cmd). + if vi_keys.startswith(":"): + weechat.hook_timer(1, 0, 1, "cb_exec_cmd", "{} {}".format(vi_keys, + count)) + vi_buffer = "" + return weechat.WEECHAT_RC_OK_EAT + # It's a WeeChat command (user defined key mapped to a /cmd). + if vi_keys.startswith("/"): + weechat.command("", vi_keys) + vi_buffer = "" + return weechat.WEECHAT_RC_OK_EAT + + buf = weechat.current_buffer() + input_line = weechat.buffer_get_string(buf, "input") + cur = weechat.buffer_get_integer(buf, "input_pos") + + # It's a default mapping. If the corresponding value is a string, we assume + # it's a WeeChat command. Otherwise, it's a method we'll call. + if vi_keys in VI_KEYS: + if vi_keys not in ['u', '\x01R']: + add_undo_history(buf, input_line) + if isinstance(VI_KEYS[vi_keys], str): + for _ in range(max(count, 1)): + # This is to avoid crashing WeeChat on script reloads/unloads, + # because no hooks must still be running when a script is + # reloaded or unloaded. + if (VI_KEYS[vi_keys] == "/input return" and + input_line.startswith("/script ")): + return weechat.WEECHAT_RC_OK + weechat.command("", VI_KEYS[vi_keys]) + current_cur = weechat.buffer_get_integer(buf, "input_pos") + set_cur(buf, input_line, current_cur) + else: + VI_KEYS[vi_keys](buf, input_line, cur, count) + # It's a motion (e.g. "w") — call `motion_X()` where X is the motion, then + # set the cursor's position to what that function returned. + elif vi_keys in VI_MOTIONS: + if vi_keys in SPECIAL_CHARS: + func = "motion_%s" % SPECIAL_CHARS[vi_keys] + else: + func = "motion_%s" % vi_keys + end, _, _ = globals()[func](input_line, cur, count) + set_cur(buf, input_line, end) + # It's an operator + motion (e.g. "dw") — call `motion_X()` (where X is + # the motion), then we call `operator_Y()` (where Y is the operator) + # with the position `motion_X()` returned. `operator_Y()` should then + # handle changing the input line. + elif (len(vi_keys) > 1 and + vi_keys[0] in VI_OPERATORS and + vi_keys[1:] in VI_MOTIONS): + add_undo_history(buf, input_line) + if vi_keys[1:] in SPECIAL_CHARS: + func = "motion_%s" % SPECIAL_CHARS[vi_keys[1:]] + else: + func = "motion_%s" % vi_keys[1:] + pos, overwrite, catching = globals()[func](input_line, cur, count) + # If it's a catching motion, we don't want to call the operator just + # yet -- this code will run again when the motion is complete, at which + # point we will. + if not catching: + oper = "operator_%s" % vi_keys[0] + globals()[oper](buf, input_line, cur, pos, overwrite) + # The combo isn't completed yet (e.g. just "d"). + else: + return weechat.WEECHAT_RC_OK_EAT + + # We've already handled the key combo, so clear the keys buffer. + if not catching_keys_data['amount']: + vi_buffer = "" + weechat.bar_item_update("vi_buffer") + return weechat.WEECHAT_RC_OK_EAT + +def cb_check_imap_esc(data, remaining_calls): + """Clear the imap_esc sequence after some time if nothing was pressed.""" + global vi_buffer + if vi_buffer == data: + vi_buffer = "" + weechat.bar_item_update("vi_buffer") + return weechat.WEECHAT_RC_OK + +def cb_key_combo_search(data, signal, signal_data): + """Handle keys while search mode is active (if search_vim is enabled).""" + if not weechat.config_string_to_boolean(vimode_settings['search_vim']): + return weechat.WEECHAT_RC_OK + if mode == "COMMAND": + if signal_data == "\x01M": + set_mode("SEARCH") + return weechat.WEECHAT_RC_OK_EAT + elif mode == "SEARCH": + if signal_data == "\x01M": + set_mode("NORMAL") + else: + if signal_data == "n": + weechat.command("", "/input search_next") + elif signal_data == "N": + weechat.command("", "/input search_previous") + # Start a new search. + elif signal_data == "/": + weechat.command("", "/input search_stop_here") + set_mode("NORMAL") + weechat.command("", "/input search_text_here") + return weechat.WEECHAT_RC_OK_EAT + return weechat.WEECHAT_RC_OK + +# Callbacks. +# ========== + +# Bar items. +# ---------- + +def cb_vi_buffer(data, item, window): + """Return the content of the vi buffer (pressed keys on hold).""" + return vi_buffer + +def cb_cmd_completion(data, item, window): + """Return the text of the command line.""" + return cmd_compl_text + +def cb_mode_indicator(data, item, window): + """Return the current mode (INSERT/NORMAL/REPLACE/...).""" + return "{}{}{}{}{}".format( + weechat.color(mode_colors[mode]), + vimode_settings['mode_indicator_prefix'], mode, + vimode_settings['mode_indicator_suffix'], weechat.color("reset")) + +def cb_line_numbers(data, item, window): + """Fill the line numbers bar item.""" + bar_height = weechat.window_get_integer(window, "win_chat_height") + content = "" + for i in range(1, bar_height + 1): + content += "{}{}{}\n".format(vimode_settings['line_number_prefix'], i, + vimode_settings['line_number_suffix']) + return content + +# Callbacks for the line numbers bar. +# ................................... + +def cb_update_line_numbers(data, signal, signal_data): + """Call `cb_timer_update_line_numbers()` when switching buffers. + + A timer is required because the bar item is refreshed before the new buffer + is actually displayed, so ``win_chat_height`` would refer to the old + buffer. Using a timer refreshes the item after the new buffer is displayed. + """ + weechat.hook_timer(10, 0, 1, "cb_timer_update_line_numbers", "") + return weechat.WEECHAT_RC_OK + +def cb_timer_update_line_numbers(data, remaining_calls): + """Update the line numbers bar item.""" + weechat.bar_item_update("line_numbers") + return weechat.WEECHAT_RC_OK + + +# Config. +# ------- + +def cb_config(data, option, value): + """Script option changed, update our copy.""" + option_name = option.split(".")[-1] + if option_name in vimode_settings: + vimode_settings[option_name] = value + if option_name == 'user_mappings': + load_user_mappings() + if "_color" in option_name: + load_mode_colors() + return weechat.WEECHAT_RC_OK + +def load_mode_colors(): + mode_colors.update({ + 'NORMAL': "{},{}".format( + vimode_settings['mode_indicator_normal_color'], + vimode_settings['mode_indicator_normal_color_bg']), + 'INSERT': "{},{}".format( + vimode_settings['mode_indicator_insert_color'], + vimode_settings['mode_indicator_insert_color_bg']), + 'REPLACE': "{},{}".format( + vimode_settings['mode_indicator_replace_color'], + vimode_settings['mode_indicator_replace_color_bg']), + 'COMMAND': "{},{}".format( + vimode_settings['mode_indicator_cmd_color'], + vimode_settings['mode_indicator_cmd_color_bg']), + 'SEARCH': "{},{}".format( + vimode_settings['mode_indicator_search_color'], + vimode_settings['mode_indicator_search_color_bg']) + }) + +def load_user_mappings(): + """Load user-defined mappings.""" + mappings = {} + if vimode_settings['user_mappings']: + mappings.update(json.loads(vimode_settings['user_mappings'])) + vimode_settings['user_mappings'] = mappings + + +# Command-line execution. +# ----------------------- + +def cb_exec_cmd(data, remaining_calls): + """Translate and execute our custom commands to WeeChat command.""" + # Process the entered command. + data = list(data) + del data[0] + data = "".join(data) + # s/foo/bar command. + if data.startswith("s/"): + cmd = data + parsed_cmd = next(csv.reader(StringIO(cmd), delimiter="/", + escapechar="\\")) + pattern = re.escape(parsed_cmd[1]) + repl = parsed_cmd[2] + repl = re.sub(r"([^\\])&", r"\1" + pattern, repl) + flag = None + if len(parsed_cmd) == 4: + flag = parsed_cmd[3] + count = 1 + if flag == "g": + count = 0 + buf = weechat.current_buffer() + input_line = weechat.buffer_get_string(buf, "input") + input_line = re.sub(pattern, repl, input_line, count) + weechat.buffer_set(buf, "input", input_line) + # Shell command. + elif data.startswith("!"): + weechat.command("", "/exec -buffer shell %s" % data[1:]) + # Commands like `:22`. This should start cursor mode (``/cursor``) and take + # us to the relevant line. + elif data.isdigit(): + line_number = int(data) + hdata_window = weechat.hdata_get("window") + window = weechat.current_window() + x = weechat.hdata_integer(hdata_window, window, "win_chat_x") + y = (weechat.hdata_integer(hdata_window, window, "win_chat_y") + + (line_number - 1)) + weechat.command("", "/cursor go {},{}".format(x, y)) + # Check againt defined commands. + elif data: + raw_data = data + data = data.split(" ", 1) + cmd = data[0] + args = "" + if len(data) == 2: + args = data[1] + if cmd in VI_COMMANDS: + if isinstance(VI_COMMANDS[cmd], str): + weechat.command("", "%s %s" % (VI_COMMANDS[cmd], args)) + else: + VI_COMMANDS[cmd](args) + else: + # Check for commands not sepearated by space (e.g. "b2") + for i in range(1, len(raw_data)): + tmp_cmd = raw_data[:i] + tmp_args = raw_data[i:] + if tmp_cmd in VI_COMMANDS and tmp_args.isdigit(): + weechat.command("", "%s %s" % (VI_COMMANDS[tmp_cmd], + tmp_args)) + return weechat.WEECHAT_RC_OK + # No vi commands found, run the command as WeeChat command + weechat.command("", "/{} {}".format(cmd, args)) + return weechat.WEECHAT_RC_OK + +def cb_vimode_go_to_normal(data, buf, args): + set_mode("NORMAL") + return weechat.WEECHAT_RC_OK + +# Script commands. +# ---------------- + +def cb_vimode_cmd(data, buf, args): + """Handle script commands (``/vimode <command>``).""" + # ``/vimode`` or ``/vimode help`` + if not args or args == "help": + weechat.prnt("", "[vimode.py] %s" % README_URL) + # ``/vimode bind_keys`` or ``/vimode bind_keys --list`` + elif args.startswith("bind_keys"): + infolist = weechat.infolist_get("key", "", "default") + weechat.infolist_reset_item_cursor(infolist) + commands = ["/key unbind ctrl-W", + "/key bind ctrl-W /input delete_previous_word", + "/key bind ctrl-^ /input jump_last_buffer_displayed", + "/key bind ctrl-Wh /window left", + "/key bind ctrl-Wj /window down", + "/key bind ctrl-Wk /window up", + "/key bind ctrl-Wl /window right", + "/key bind ctrl-W= /window balance", + "/key bind ctrl-Wx /window swap", + "/key bind ctrl-Ws /window splith", + "/key bind ctrl-Wv /window splitv", + "/key bind ctrl-Wq /window merge"] + while weechat.infolist_next(infolist): + key = weechat.infolist_string(infolist, "key") + if re.match(REGEX_PROBLEMATIC_KEYBINDINGS, key): + commands.append("/key unbind %s" % key) + if args == "bind_keys": + weechat.prnt("", "Running commands:") + for command in commands: + weechat.command("", command) + weechat.prnt("", "Done.") + elif args == "bind_keys --list": + weechat.prnt("", "Listing commands we'll run:") + for command in commands: + weechat.prnt("", " %s" % command) + weechat.prnt("", "Done.") + return weechat.WEECHAT_RC_OK + + +# Helpers. +# ======== + +# Motions/keys helpers. +# --------------------- + +def get_pos(data, regex, cur, ignore_cur=False, count=0): + """Return the position of `regex` match in `data`, starting at `cur`. + + Args: + data (str): the data to search in. + regex (pattern): regex pattern to search for. + cur (int): where to start the search. + ignore_cur (bool, optional): should the first match be ignored if it's + also the character at `cur`? + Defaults to False. + count (int, optional): the index of the match to return. Defaults to 0. + + Returns: + int: position of the match. -1 if no matches are found. + """ + # List of the *positions* of the found patterns. + matches = [m.start() for m in re.finditer(regex, data[cur:])] + pos = -1 + if count: + if len(matches) > count - 1: + if ignore_cur and matches[0] == 0: + if len(matches) > count: + pos = matches[count] + else: + pos = matches[count - 1] + elif matches: + if ignore_cur and matches[0] == 0: + if len(matches) > 1: + pos = matches[1] + else: + pos = matches[0] + return pos + +def set_cur(buf, input_line, pos, cap=True): + """Set the cursor's position. + + Args: + buf (str): pointer to the current WeeChat buffer. + input_line (str): the content of the input line. + pos (int): the position to set the cursor to. + cap (bool, optional): if True, the `pos` will shortened to the length + of `input_line` if it's too long. Defaults to True. + """ + if cap: + pos = min(pos, len(input_line) - 1) + weechat.buffer_set(buf, "input_pos", str(pos)) + +def start_catching_keys(amount, callback, input_line, cur, count, buf=None): + """Start catching keys. Used for special commands (e.g. "f", "r"). + + amount (int): amount of keys to catch. + callback (str): name of method to call once all keys are caught. + input_line (str): input line's content. + cur (int): cursor's position. + count (int): count, e.g. "2" for "2fs". + buf (str, optional): pointer to the current WeeChat buffer. + Defaults to None. + + `catching_keys_data` is a dict with the above arguments, as well as: + keys (str): pressed keys will be added under this key. + new_cur (int): the new cursor's position, set in the callback. + + When catching keys is active, normal pressed keys (e.g. "a" but not arrows) + will get added to `catching_keys_data` under the key "keys", and will not + be handled any further. + Once all keys are caught, the method defined in the "callback" key is + called, and can use the data in `catching_keys_data` to perform its action. + """ + global catching_keys_data + if "new_cur" in catching_keys_data: + new_cur = catching_keys_data['new_cur'] + catching_keys_data = {'amount': 0} + return new_cur, True, False + catching_keys_data = ({'amount': amount, + 'callback': callback, + 'input_line': input_line, + 'cur': cur, + 'keys': "", + 'count': count, + 'new_cur': 0, + 'buf': buf}) + return cur, False, True + +def get_keys_and_count(combo): + """Check if `combo` is a valid combo and extract keys/counts if so. + + Args: + combo (str): pressed keys combo. + + Returns: + matched (bool): True if the combo has a (partial or full) match, False + otherwise. + combo (str): `combo` with the count removed. These are the actual keys + we should handle. User mappings are also expanded. + count (int): count for `combo`. + """ + # Look for a potential match (e.g. "d" might become "dw" or "dd" so we + # accept it, but "d9" is invalid). + matched = False + # Digits are allowed at the beginning (counts or "0"). + count = 0 + if combo.isdigit(): + matched = True + elif combo and combo[0].isdigit(): + count = "" + for char in combo: + if char.isdigit(): + count += char + else: + break + combo = combo.replace(count, "", 1) + count = int(count) + # It's a user defined key. Expand it. + if combo in vimode_settings['user_mappings']: + combo = vimode_settings['user_mappings'][combo] + # It's a WeeChat command. + if not matched and combo.startswith("/"): + matched = True + # Check against defined keys. + if not matched: + for key in VI_KEYS: + if key.startswith(combo): + matched = True + break + # Check against defined motions. + if not matched: + for motion in VI_MOTIONS: + if motion.startswith(combo): + matched = True + break + # Check against defined operators + motions. + if not matched: + for operator in VI_OPERATORS: + if combo.startswith(operator): + # Check for counts before the motion (but after the operator). + vi_keys_no_op = combo[len(operator):] + # There's no motion yet. + if vi_keys_no_op.isdigit(): + matched = True + break + # Get the motion count, then multiply the operator count by + # it, similar to vim's behavior. + elif vi_keys_no_op and vi_keys_no_op[0].isdigit(): + motion_count = "" + for char in vi_keys_no_op: + if char.isdigit(): + motion_count += char + else: + break + # Remove counts from `vi_keys_no_op`. + combo = combo.replace(motion_count, "", 1) + motion_count = int(motion_count) + count = max(count, 1) * motion_count + # Check against defined motions. + for motion in VI_MOTIONS: + if motion.startswith(combo[1:]): + matched = True + break + return matched, combo, count + + +# Other helpers. +# -------------- + +def set_mode(arg): + """Set the current mode and update the bar mode indicator.""" + global mode + buf = weechat.current_buffer() + input_line = weechat.buffer_get_string(buf, "input") + if mode == "INSERT" and arg == "NORMAL": + add_undo_history(buf, input_line) + mode = arg + # If we're going to Normal mode, the cursor must move one character to the + # left. + if mode == "NORMAL": + cur = weechat.buffer_get_integer(buf, "input_pos") + set_cur(buf, input_line, cur - 1, False) + weechat.bar_item_update("mode_indicator") + +def cb_check_cmd_mode(data, remaining_calls): + """Exit command mode if user erases the leading ':' character.""" + buf = weechat.current_buffer() + cmd_text = weechat.buffer_get_string(buf, "input") + if not cmd_text: + set_mode("NORMAL") + return weechat.WEECHAT_RC_OK + +def add_undo_history(buf, input_line): + """Add an item to the per-buffer undo history.""" + if buf in undo_history: + if not undo_history[buf] or undo_history[buf][-1] != input_line: + undo_history[buf].append(input_line) + undo_history_index[buf] = -1 + else: + undo_history[buf] = ['', input_line] + undo_history_index[buf] = -1 + +def clear_undo_history(buf): + """Clear the undo history for a given buffer.""" + undo_history[buf] = [''] + undo_history_index[buf] = -1 + +def print_warning(text): + """Print warning, in red, to the current buffer.""" + weechat.prnt("", ("%s[vimode.py] %s" % (weechat.color("red"), text))) + +def check_warnings(): + """Warn the user about problematic key bindings and tmux/screen.""" + user_warned = False + # Warn the user about problematic key bindings that may conflict with + # vimode. + # The solution is to remove these key bindings, but that's up to the user. + infolist = weechat.infolist_get("key", "", "default") + problematic_keybindings = [] + while weechat.infolist_next(infolist): + key = weechat.infolist_string(infolist, "key") + command = weechat.infolist_string(infolist, "command") + if re.match(REGEX_PROBLEMATIC_KEYBINDINGS, key): + problematic_keybindings.append("%s -> %s" % (key, command)) + if problematic_keybindings: + user_warned = True + print_warning("Problematic keybindings detected:") + for keybinding in problematic_keybindings: + print_warning(" %s" % keybinding) + print_warning("These keybindings may conflict with vimode.") + print_warning("You can remove problematic key bindings and add" + " recommended ones by using /vimode bind_keys, or only" + " list them with /vimode bind_keys --list") + print_warning("For help, see: %s" % FAQ_KEYBINDINGS) + del problematic_keybindings + # Warn tmux/screen users about possible Esc detection delays. + if "STY" in os.environ or "TMUX" in os.environ: + if user_warned: + weechat.prnt("", "") + user_warned = True + print_warning("tmux/screen users, see: %s" % FAQ_ESC) + if (user_warned and not + weechat.config_string_to_boolean(vimode_settings['no_warn'])): + if user_warned: + weechat.prnt("", "") + print_warning("To force disable warnings, you can set" + " plugins.var.python.vimode.no_warn to 'on'") + + +# Main script. +# ============ + +if __name__ == "__main__": + weechat.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, + SCRIPT_LICENSE, SCRIPT_DESC, "", "") + # Warn the user if he's using an unsupported WeeChat version. + VERSION = weechat.info_get("version_number", "") + if int(VERSION) < 0x01000000: + print_warning("Please upgrade to WeeChat ≥ 1.0.0. Previous versions" + " are not supported.") + # Set up script options. + for option, value in list(vimode_settings.items()): + if weechat.config_is_set_plugin(option): + vimode_settings[option] = weechat.config_get_plugin(option) + else: + weechat.config_set_plugin(option, value[0]) + vimode_settings[option] = value[0] + weechat.config_set_desc_plugin(option, + "%s (default: \"%s\")" % (value[1], + value[0])) + load_user_mappings() + load_mode_colors() + # Warn the user about possible problems if necessary. + if not weechat.config_string_to_boolean(vimode_settings['no_warn']): + check_warnings() + # Create bar items and setup hooks. + weechat.bar_item_new("mode_indicator", "cb_mode_indicator", "") + weechat.bar_item_new("cmd_completion", "cb_cmd_completion", "") + weechat.bar_item_new("vi_buffer", "cb_vi_buffer", "") + weechat.bar_item_new("line_numbers", "cb_line_numbers", "") + weechat.bar_new("vi_line_numbers", "on", "0", "window", "", "left", + "vertical", "vertical", "0", "0", "default", "default", + "default", "0", "line_numbers") + weechat.hook_config("plugins.var.python.%s.*" % SCRIPT_NAME, "cb_config", + "") + weechat.hook_signal("key_pressed", "cb_key_pressed", "") + weechat.hook_signal("key_combo_default", "cb_key_combo_default", "") + weechat.hook_signal("key_combo_search", "cb_key_combo_search", "") + weechat.hook_signal("buffer_switch", "cb_update_line_numbers", "") + weechat.hook_command("vimode", SCRIPT_DESC, "[help | bind_keys [--list]]", + " help: show help\n" + "bind_keys: unbind problematic keys, and bind" + " recommended keys to use in WeeChat\n" + " --list: only list changes", + "help || bind_keys |--list", + "cb_vimode_cmd", "") + weechat.hook_command("vimode_go_to_normal", + ("This command can be used for key bindings to go to " + "normal mode."), + "", "", "", "cb_vimode_go_to_normal", "") + # Remove obsolete bar. + vi_cmd_bar = weechat.bar_search("vi_cmd") + weechat.bar_remove(vi_cmd_bar) diff --git a/.weechat/python/wee_slack.py b/.weechat/python/wee_slack.py new file mode 100644 index 0000000..3dd10cc --- /dev/null +++ b/.weechat/python/wee_slack.py @@ -0,0 +1,5013 @@ +# Copyright (c) 2014-2016 Ryan Huber <rhuber@gmail.com> +# Copyright (c) 2015-2018 Tollef Fog Heen <tfheen@err.no> +# Copyright (c) 2015-2020 Trygve Aaberge <trygveaa@gmail.com> +# Released under the MIT license. + +from __future__ import print_function, unicode_literals + +from collections import OrderedDict +from datetime import date, datetime, timedelta +from functools import partial, wraps +from io import StringIO +from itertools import chain, count, islice + +import errno +import textwrap +import time +import json +import hashlib +import os +import re +import sys +import traceback +import collections +import ssl +import random +import socket +import string + +# Prevent websocket from using numpy (it's an optional dependency). We do this +# because numpy causes python (and thus weechat) to crash when it's reloaded. +# See https://github.com/numpy/numpy/issues/11925 +sys.modules["numpy"] = None + +from websocket import ABNF, create_connection, WebSocketConnectionClosedException + +try: + basestring # Python 2 + unicode +except NameError: # Python 3 + basestring = unicode = str + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + +try: + from json import JSONDecodeError +except: + JSONDecodeError = ValueError + +# hack to make tests possible.. better way? +try: + import weechat +except ImportError: + pass + +SCRIPT_NAME = "slack" +SCRIPT_AUTHOR = "Ryan Huber <rhuber@gmail.com>" +SCRIPT_VERSION = "2.4.0" +SCRIPT_LICENSE = "MIT" +SCRIPT_DESC = "Extends weechat for typing notification/search/etc on slack.com" +REPO_URL = "https://github.com/wee-slack/wee-slack" + +BACKLOG_SIZE = 200 +SCROLLBACK_SIZE = 500 + +RECORD_DIR = "/tmp/weeslack-debug" + +SLACK_API_TRANSLATOR = { + "channel": { + "history": "channels.history", + "join": "conversations.join", + "leave": "conversations.leave", + "mark": "channels.mark", + "info": "channels.info", + }, + "im": { + "history": "im.history", + "join": "conversations.open", + "leave": "conversations.close", + "mark": "im.mark", + }, + "mpim": { + "history": "mpim.history", + "join": "mpim.open", # conversations.open lacks unread_count_display + "leave": "conversations.close", + "mark": "mpim.mark", + "info": "groups.info", + }, + "group": { + "history": "groups.history", + "join": "conversations.join", + "leave": "conversations.leave", + "mark": "groups.mark", + "info": "groups.info" + }, + "private": { + "history": "conversations.history", + "join": "conversations.join", + "leave": "conversations.leave", + "mark": "conversations.mark", + "info": "conversations.info", + }, + "shared": { + "history": "conversations.history", + "join": "conversations.join", + "leave": "conversations.leave", + "mark": "channels.mark", + "info": "conversations.info", + }, + "thread": { + "history": None, + "join": None, + "leave": None, + "mark": None, + } + + +} + +###### Decorators have to be up here + + +def slack_buffer_or_ignore(f): + """ + Only run this function if we're in a slack buffer, else ignore + """ + @wraps(f) + def wrapper(data, current_buffer, *args, **kwargs): + if current_buffer not in EVENTROUTER.weechat_controller.buffers: + return w.WEECHAT_RC_OK + return f(data, current_buffer, *args, **kwargs) + return wrapper + + +def slack_buffer_required(f): + """ + Only run this function if we're in a slack buffer, else print error + """ + @wraps(f) + def wrapper(data, current_buffer, *args, **kwargs): + if current_buffer not in EVENTROUTER.weechat_controller.buffers: + command_name = f.__name__.replace('command_', '', 1) + w.prnt('', 'slack: command "{}" must be executed on slack buffer'.format(command_name)) + return w.WEECHAT_RC_ERROR + return f(data, current_buffer, *args, **kwargs) + return wrapper + + +def utf8_decode(f): + """ + Decode all arguments from byte strings to unicode strings. Use this for + functions called from outside of this script, e.g. callbacks from weechat. + """ + @wraps(f) + def wrapper(*args, **kwargs): + return f(*decode_from_utf8(args), **decode_from_utf8(kwargs)) + return wrapper + + +NICK_GROUP_HERE = "0|Here" +NICK_GROUP_AWAY = "1|Away" +NICK_GROUP_EXTERNAL = "2|External" + +sslopt_ca_certs = {} +if hasattr(ssl, "get_default_verify_paths") and callable(ssl.get_default_verify_paths): + ssl_defaults = ssl.get_default_verify_paths() + if ssl_defaults.cafile is not None: + sslopt_ca_certs = {'ca_certs': ssl_defaults.cafile} + +EMOJI = {} +EMOJI_WITH_SKIN_TONES_REVERSE = {} + +###### Unicode handling + + +def encode_to_utf8(data): + if sys.version_info.major > 2: + return data + elif isinstance(data, unicode): + return data.encode('utf-8') + if isinstance(data, bytes): + return data + elif isinstance(data, collections.Mapping): + return type(data)(map(encode_to_utf8, data.items())) + elif isinstance(data, collections.Iterable): + return type(data)(map(encode_to_utf8, data)) + else: + return data + + +def decode_from_utf8(data): + if sys.version_info.major > 2: + return data + elif isinstance(data, bytes): + return data.decode('utf-8') + if isinstance(data, unicode): + return data + elif isinstance(data, collections.Mapping): + return type(data)(map(decode_from_utf8, data.items())) + elif isinstance(data, collections.Iterable): + return type(data)(map(decode_from_utf8, data)) + else: + return data + + +class WeechatWrapper(object): + def __init__(self, wrapped_class): + self.wrapped_class = wrapped_class + + # Helper method used to encode/decode method calls. + def wrap_for_utf8(self, method): + def hooked(*args, **kwargs): + result = method(*encode_to_utf8(args), **encode_to_utf8(kwargs)) + # Prevent wrapped_class from becoming unwrapped + if result == self.wrapped_class: + return self + return decode_from_utf8(result) + return hooked + + # Encode and decode everything sent to/received from weechat. We use the + # unicode type internally in wee-slack, but has to send utf8 to weechat. + def __getattr__(self, attr): + orig_attr = self.wrapped_class.__getattribute__(attr) + if callable(orig_attr): + return self.wrap_for_utf8(orig_attr) + else: + return decode_from_utf8(orig_attr) + + # Ensure all lines sent to weechat specifies a prefix. For lines after the + # first, we want to disable the prefix, which is done by specifying a space. + def prnt_date_tags(self, buffer, date, tags, message): + message = message.replace("\n", "\n \t") + return self.wrap_for_utf8(self.wrapped_class.prnt_date_tags)(buffer, date, tags, message) + + +class ProxyWrapper(object): + def __init__(self): + self.proxy_name = w.config_string(w.config_get('weechat.network.proxy_curl')) + self.proxy_string = "" + self.proxy_type = "" + self.proxy_address = "" + self.proxy_port = "" + self.proxy_user = "" + self.proxy_password = "" + self.has_proxy = False + + if self.proxy_name: + self.proxy_string = "weechat.proxy.{}".format(self.proxy_name) + self.proxy_type = w.config_string(w.config_get("{}.type".format(self.proxy_string))) + if self.proxy_type == "http": + self.proxy_address = w.config_string(w.config_get("{}.address".format(self.proxy_string))) + self.proxy_port = w.config_integer(w.config_get("{}.port".format(self.proxy_string))) + self.proxy_user = w.config_string(w.config_get("{}.username".format(self.proxy_string))) + self.proxy_password = w.config_string(w.config_get("{}.password".format(self.proxy_string))) + self.has_proxy = True + else: + w.prnt("", "\nWarning: weechat.network.proxy_curl is set to {} type (name : {}, conf string : {}). Only HTTP proxy is supported.\n\n".format(self.proxy_type, self.proxy_name, self.proxy_string)) + + def curl(self): + if not self.has_proxy: + return "" + + if self.proxy_user and self.proxy_password: + user = "{}:{}@".format(self.proxy_user, self.proxy_password) + else: + user = "" + + if self.proxy_port: + port = ":{}".format(self.proxy_port) + else: + port = "" + + return "-x{}{}{}".format(user, self.proxy_address, port) + + +##### Helpers + + +def colorize_string(color, string, reset_color='reset'): + if color: + return w.color(color) + string + w.color(reset_color) + else: + return string + + +def print_error(message, buffer=''): + w.prnt(buffer, '{}Error: {}'.format(w.prefix('error'), message)) + + +def format_exc_tb(): + return decode_from_utf8(traceback.format_exc()) + + +def format_exc_only(): + etype, value, _ = sys.exc_info() + return ''.join(decode_from_utf8(traceback.format_exception_only(etype, value))) + + +def get_nick_color(nick): + info_name_prefix = "irc_" if int(weechat_version) < 0x1050000 else "" + return w.info_get(info_name_prefix + "nick_color_name", nick) + + +def get_thread_color(thread_id): + if config.color_thread_suffix == 'multiple': + return get_nick_color(thread_id) + else: + return config.color_thread_suffix + + +def sha1_hex(s): + return hashlib.sha1(s.encode('utf-8')).hexdigest() + + +def get_functions_with_prefix(prefix): + return {name[len(prefix):]: ref for name, ref in globals().items() + if name.startswith(prefix)} + + +def handle_socket_error(exception, team, caller_name): + if not (isinstance(exception, WebSocketConnectionClosedException) or + exception.errno in (errno.EPIPE, errno.ECONNRESET, errno.ETIMEDOUT)): + raise + + w.prnt(team.channel_buffer, + 'Lost connection to slack team {} (on {}), reconnecting.'.format( + team.domain, caller_name)) + dbg('Socket failed on {} with exception:\n{}'.format( + caller_name, format_exc_tb()), level=5) + team.set_disconnected() + + +EMOJI_NAME_REGEX = re.compile(':([^: ]+):') +EMOJI_REGEX_STRING = '[\U00000080-\U0010ffff]+' + + +def regex_match_to_emoji(match, include_name=False): + emoji = match.group(1) + full_match = match.group() + char = EMOJI.get(emoji, full_match) + if include_name and char != full_match: + return '{} ({})'.format(char, full_match) + return char + + +def replace_string_with_emoji(text): + if config.render_emoji_as_string == 'both': + return EMOJI_NAME_REGEX.sub( + partial(regex_match_to_emoji, include_name=True), + text, + ) + elif config.render_emoji_as_string: + return text + return EMOJI_NAME_REGEX.sub(regex_match_to_emoji, text) + + +def replace_emoji_with_string(text): + return EMOJI_WITH_SKIN_TONES_REVERSE.get(text, text) + + +###### New central Event router + +class EventRouter(object): + + def __init__(self): + """ + complete + Eventrouter is the central hub we use to route: + 1) incoming websocket data + 2) outgoing http requests and incoming replies + 3) local requests + It has a recorder that, when enabled, logs most events + to the location specified in RECORD_DIR. + """ + self.queue = [] + self.slow_queue = [] + self.slow_queue_timer = 0 + self.teams = {} + self.subteams = {} + self.context = {} + self.weechat_controller = WeechatController(self) + self.previous_buffer = "" + self.reply_buffer = {} + self.cmds = get_functions_with_prefix("command_") + self.proc = get_functions_with_prefix("process_") + self.handlers = get_functions_with_prefix("handle_") + self.local_proc = get_functions_with_prefix("local_process_") + self.shutting_down = False + self.recording = False + self.recording_path = "/tmp" + self.handle_next_hook = None + self.handle_next_hook_interval = -1 + + def record(self): + """ + complete + Toggles the event recorder and creates a directory for data if enabled. + """ + self.recording = not self.recording + if self.recording: + if not os.path.exists(RECORD_DIR): + os.makedirs(RECORD_DIR) + + def record_event(self, message_json, file_name_field, subdir=None): + """ + complete + Called each time you want to record an event. + message_json is a json in dict form + file_name_field is the json key whose value you want to be part of the file name + """ + now = time.time() + if subdir: + directory = "{}/{}".format(RECORD_DIR, subdir) + else: + directory = RECORD_DIR + if not os.path.exists(directory): + os.makedirs(directory) + mtype = message_json.get(file_name_field, 'unknown') + f = open('{}/{}-{}.json'.format(directory, now, mtype), 'w') + f.write("{}".format(json.dumps(message_json))) + f.close() + + def store_context(self, data): + """ + A place to store data and vars needed by callback returns. We need this because + weechat's "callback_data" has a limited size and weechat will crash if you exceed + this size. + """ + identifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(40)) + self.context[identifier] = data + dbg("stored context {} {} ".format(identifier, data.url)) + return identifier + + def retrieve_context(self, identifier): + """ + A place to retrieve data and vars needed by callback returns. We need this because + weechat's "callback_data" has a limited size and weechat will crash if you exceed + this size. + """ + return self.context.get(identifier) + + def delete_context(self, identifier): + """ + Requests can span multiple requests, so we may need to delete this as a last step + """ + if identifier in self.context: + del self.context[identifier] + + def shutdown(self): + """ + complete + This toggles shutdown mode. Shutdown mode tells us not to + talk to Slack anymore. Without this, typing /quit will trigger + a race with the buffer close callback and may result in you + leaving every slack channel. + """ + self.shutting_down = not self.shutting_down + + def register_team(self, team): + """ + complete + Adds a team to the list of known teams for this EventRouter. + """ + if isinstance(team, SlackTeam): + self.teams[team.get_team_hash()] = team + else: + raise InvalidType(type(team)) + + def reconnect_if_disconnected(self): + for team in self.teams.values(): + time_since_last_ping = time.time() - team.last_ping_time + time_since_last_pong = time.time() - team.last_pong_time + if team.connected and time_since_last_ping < 5 and time_since_last_pong > 30: + w.prnt(team.channel_buffer, + 'Lost connection to slack team {} (no pong), reconnecting.'.format( + team.domain)) + team.set_disconnected() + if not team.connected: + team.connect() + dbg("reconnecting {}".format(team)) + + @utf8_decode + def receive_ws_callback(self, team_hash, fd): + """ + This is called by the global method of the same name. + It is triggered when we have incoming data on a websocket, + which needs to be read. Once it is read, we will ensure + the data is valid JSON, add metadata, and place it back + on the queue for processing as JSON. + """ + team = self.teams[team_hash] + while True: + try: + # Read the data from the websocket associated with this team. + opcode, data = team.ws.recv_data(control_frame=True) + except ssl.SSLWantReadError: + # No more data to read at this time. + return w.WEECHAT_RC_OK + except (WebSocketConnectionClosedException, socket.error) as e: + handle_socket_error(e, team, 'receive') + return w.WEECHAT_RC_OK + + if opcode == ABNF.OPCODE_PONG: + team.last_pong_time = time.time() + return w.WEECHAT_RC_OK + elif opcode != ABNF.OPCODE_TEXT: + return w.WEECHAT_RC_OK + + message_json = json.loads(data.decode('utf-8')) + message_json["wee_slack_metadata_team"] = team + if self.recording: + self.record_event(message_json, 'type', 'websocket') + self.receive(message_json) + return w.WEECHAT_RC_OK + + @utf8_decode + def receive_httprequest_callback(self, data, command, return_code, out, err): + """ + complete + Receives the result of an http request we previously handed + off to weechat (weechat bundles libcurl). Weechat can fragment + replies, so it buffers them until the reply is complete. + It is then populated with metadata here so we can identify + where the request originated and route properly. + """ + request_metadata = self.retrieve_context(data) + dbg("RECEIVED CALLBACK with request of {} id of {} and code {} of length {}".format(request_metadata.request, request_metadata.response_id, return_code, len(out))) + if return_code == 0: + if len(out) > 0: + if request_metadata.response_id not in self.reply_buffer: + self.reply_buffer[request_metadata.response_id] = StringIO() + self.reply_buffer[request_metadata.response_id].write(out) + try: + j = json.loads(self.reply_buffer[request_metadata.response_id].getvalue()) + except: + pass + # dbg("Incomplete json, awaiting more", True) + try: + j["wee_slack_process_method"] = request_metadata.request_normalized + if self.recording: + self.record_event(j, 'wee_slack_process_method', 'http') + j["wee_slack_request_metadata"] = request_metadata + self.reply_buffer.pop(request_metadata.response_id) + self.receive(j) + self.delete_context(data) + except: + dbg("HTTP REQUEST CALLBACK FAILED", True) + pass + # We got an empty reply and this is weird so just ditch it and retry + else: + dbg("length was zero, probably a bug..") + self.delete_context(data) + self.receive(request_metadata) + elif return_code == -1: + if request_metadata.response_id not in self.reply_buffer: + self.reply_buffer[request_metadata.response_id] = StringIO() + self.reply_buffer[request_metadata.response_id].write(out) + else: + self.reply_buffer.pop(request_metadata.response_id, None) + self.delete_context(data) + if request_metadata.request.startswith('rtm.'): + retry_text = ('retrying' if request_metadata.should_try() else + 'will not retry after too many failed attempts') + w.prnt('', ('Failed connecting to slack team with token starting with {}, {}. ' + + 'If this persists, try increasing slack_timeout. Error: {}') + .format(request_metadata.token[:15], retry_text, err)) + dbg('rtm.start failed with return_code {}. stack:\n{}' + .format(return_code, ''.join(traceback.format_stack())), level=5) + self.receive(request_metadata) + return w.WEECHAT_RC_OK + + def receive(self, dataobj): + """ + complete + Receives a raw object and places it on the queue for + processing. Object must be known to handle_next or + be JSON. + """ + dbg("RECEIVED FROM QUEUE") + self.queue.append(dataobj) + + def receive_slow(self, dataobj): + """ + complete + Receives a raw object and places it on the slow queue for + processing. Object must be known to handle_next or + be JSON. + """ + dbg("RECEIVED FROM QUEUE") + self.slow_queue.append(dataobj) + + def handle_next(self): + """ + complete + Main handler of the EventRouter. This is called repeatedly + via callback to drain events from the queue. It also attaches + useful metadata and context to events as they are processed. + """ + wanted_interval = 100 + if len(self.slow_queue) > 0 or len(self.queue) > 0: + wanted_interval = 10 + if self.handle_next_hook is None or wanted_interval != self.handle_next_hook_interval: + if self.handle_next_hook: + w.unhook(self.handle_next_hook) + self.handle_next_hook = w.hook_timer(wanted_interval, 0, 0, "handle_next", "") + self.handle_next_hook_interval = wanted_interval + + + if len(self.slow_queue) > 0 and ((self.slow_queue_timer + 1) < time.time()): + dbg("from slow queue", 0) + self.queue.append(self.slow_queue.pop()) + self.slow_queue_timer = time.time() + if len(self.queue) > 0: + j = self.queue.pop(0) + # Reply is a special case of a json reply from websocket. + kwargs = {} + if isinstance(j, SlackRequest): + if j.should_try(): + if j.retry_ready(): + local_process_async_slack_api_request(j, self) + else: + self.slow_queue.append(j) + else: + dbg("Max retries for Slackrequest") + + else: + + if "reply_to" in j: + dbg("SET FROM REPLY") + function_name = "reply" + elif "type" in j: + dbg("SET FROM type") + function_name = j["type"] + elif "wee_slack_process_method" in j: + dbg("SET FROM META") + function_name = j["wee_slack_process_method"] + else: + dbg("SET FROM NADA") + function_name = "unknown" + + request = j.get("wee_slack_request_metadata") + if request: + team = request.team + channel = request.channel + metadata = request.metadata + else: + team = j.get("wee_slack_metadata_team") + channel = None + metadata = {} + + if team: + if "channel" in j: + channel_id = j["channel"]["id"] if type(j["channel"]) == dict else j["channel"] + channel = team.channels.get(channel_id, channel) + if "user" in j: + user_id = j["user"]["id"] if type(j["user"]) == dict else j["user"] + metadata['user'] = team.users.get(user_id) + + dbg("running {}".format(function_name)) + if function_name.startswith("local_") and function_name in self.local_proc: + self.local_proc[function_name](j, self, team, channel, metadata) + elif function_name in self.proc: + self.proc[function_name](j, self, team, channel, metadata) + elif function_name in self.handlers: + self.handlers[function_name](j, self, team, channel, metadata) + else: + dbg("Callback not implemented for event: {}".format(function_name)) + + +def handle_next(data, remaining_calls): + try: + EVENTROUTER.handle_next() + except: + if config.debug_mode: + traceback.print_exc() + else: + pass + return w.WEECHAT_RC_OK + + +class WeechatController(object): + """ + Encapsulates our interaction with weechat + """ + + def __init__(self, eventrouter): + self.eventrouter = eventrouter + self.buffers = {} + self.previous_buffer = None + self.buffer_list_stale = False + + def iter_buffers(self): + for b in self.buffers: + yield (b, self.buffers[b]) + + def register_buffer(self, buffer_ptr, channel): + """ + complete + Adds a weechat buffer to the list of handled buffers for this EventRouter + """ + if isinstance(buffer_ptr, basestring): + self.buffers[buffer_ptr] = channel + else: + raise InvalidType(type(buffer_ptr)) + + def unregister_buffer(self, buffer_ptr, update_remote=False, close_buffer=False): + """ + complete + Adds a weechat buffer to the list of handled buffers for this EventRouter + """ + channel = self.buffers.get(buffer_ptr) + if channel: + channel.destroy_buffer(update_remote) + del self.buffers[buffer_ptr] + if close_buffer: + w.buffer_close(buffer_ptr) + + def get_channel_from_buffer_ptr(self, buffer_ptr): + return self.buffers.get(buffer_ptr) + + def get_all(self, buffer_ptr): + return self.buffers + + def get_previous_buffer_ptr(self): + return self.previous_buffer + + def set_previous_buffer(self, data): + self.previous_buffer = data + + def check_refresh_buffer_list(self): + return self.buffer_list_stale and self.last_buffer_list_update + 1 < time.time() + + def set_refresh_buffer_list(self, setting): + self.buffer_list_stale = setting + +###### New Local Processors + + +def local_process_async_slack_api_request(request, event_router): + """ + complete + Sends an API request to Slack. You'll need to give this a well formed SlackRequest object. + DEBUGGING!!! The context here cannot be very large. Weechat will crash. + """ + if not event_router.shutting_down: + weechat_request = 'url:{}'.format(request.request_string()) + weechat_request += '&nonce={}'.format(''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4))) + params = {'useragent': 'wee_slack {}'.format(SCRIPT_VERSION)} + request.tried() + context = event_router.store_context(request) + # TODO: let flashcode know about this bug - i have to 'clear' the hashtable or retry requests fail + w.hook_process_hashtable('url:', params, config.slack_timeout, "", context) + w.hook_process_hashtable(weechat_request, params, config.slack_timeout, "receive_httprequest_callback", context) + +###### New Callbacks + + +@utf8_decode +def ws_ping_cb(data, remaining_calls): + for team in EVENTROUTER.teams.values(): + if team.ws and team.connected: + try: + team.ws.ping() + team.last_ping_time = time.time() + except (WebSocketConnectionClosedException, socket.error) as e: + handle_socket_error(e, team, 'ping') + return w.WEECHAT_RC_OK + + +@utf8_decode +def reconnect_callback(*args): + EVENTROUTER.reconnect_if_disconnected() + return w.WEECHAT_RC_OK + + +@utf8_decode +def buffer_closing_callback(signal, sig_type, data): + """ + Receives a callback from weechat when a buffer is being closed. + """ + EVENTROUTER.weechat_controller.unregister_buffer(data, True, False) + return w.WEECHAT_RC_OK + + +@utf8_decode +def buffer_input_callback(signal, buffer_ptr, data): + """ + incomplete + Handles everything a user types in the input bar. In our case + this includes add/remove reactions, modifying messages, and + sending messages. + """ + eventrouter = eval(signal) + channel = eventrouter.weechat_controller.get_channel_from_buffer_ptr(buffer_ptr) + if not channel: + return w.WEECHAT_RC_ERROR + + def get_id(message_id): + if not message_id: + return 1 + elif message_id[0] == "$": + return message_id[1:] + else: + return int(message_id) + + message_id_regex = r"(\d*|\$[0-9a-fA-F]{3,})" + reaction = re.match(r"^{}(\+|-)(:(.+):|{})\s*$".format(message_id_regex, EMOJI_REGEX_STRING), data) + substitute = re.match("^{}s/".format(message_id_regex), data) + if reaction: + emoji_match = reaction.group(4) or reaction.group(3) + emoji = replace_emoji_with_string(emoji_match) + if reaction.group(2) == "+": + channel.send_add_reaction(get_id(reaction.group(1)), emoji) + elif reaction.group(2) == "-": + channel.send_remove_reaction(get_id(reaction.group(1)), emoji) + elif substitute: + msg_id = get_id(substitute.group(1)) + try: + old, new, flags = re.split(r'(?<!\\)/', data)[1:] + except ValueError: + pass + else: + # Replacement string in re.sub() is a string, not a regex, so get + # rid of escapes. + new = new.replace(r'\/', '/') + old = old.replace(r'\/', '/') + channel.edit_nth_previous_message(msg_id, old, new, flags) + else: + if data.startswith(('//', ' ')): + data = data[1:] + channel.send_message(data) + # this is probably wrong channel.mark_read(update_remote=True, force=True) + return w.WEECHAT_RC_OK + + +# Workaround for supporting multiline messages. It intercepts before the input +# callback is called, as this is called with the whole message, while it is +# normally split on newline before being sent to buffer_input_callback +def input_text_for_buffer_cb(data, modifier, current_buffer, string): + if current_buffer not in EVENTROUTER.weechat_controller.buffers: + return string + message = decode_from_utf8(string) + if not message.startswith("/") and "\n" in message: + buffer_input_callback("EVENTROUTER", current_buffer, message) + return "" + return string + + +@utf8_decode +def buffer_switch_callback(signal, sig_type, data): + """ + Every time we change channels in weechat, we call this to: + 1) set read marker 2) determine if we have already populated + channel history data 3) set presence to active + """ + eventrouter = eval(signal) + + prev_buffer_ptr = eventrouter.weechat_controller.get_previous_buffer_ptr() + # this is to see if we need to gray out things in the buffer list + prev = eventrouter.weechat_controller.get_channel_from_buffer_ptr(prev_buffer_ptr) + if prev: + prev.mark_read() + + new_channel = eventrouter.weechat_controller.get_channel_from_buffer_ptr(data) + if new_channel: + if not new_channel.got_history: + new_channel.get_history() + set_own_presence_active(new_channel.team) + + eventrouter.weechat_controller.set_previous_buffer(data) + return w.WEECHAT_RC_OK + + +@utf8_decode +def buffer_list_update_callback(data, somecount): + """ + incomplete + A simple timer-based callback that will update the buffer list + if needed. We only do this max 1x per second, as otherwise it + uses a lot of cpu for minimal changes. We use buffer short names + to indicate typing via "#channel" <-> ">channel" and + user presence via " name" <-> "+name". + """ + eventrouter = eval(data) + + for b in eventrouter.weechat_controller.iter_buffers(): + b[1].refresh() +# buffer_list_update = True +# if eventrouter.weechat_controller.check_refresh_buffer_list(): +# # gray_check = False +# # if len(servers) > 1: +# # gray_check = True +# eventrouter.weechat_controller.set_refresh_buffer_list(False) + return w.WEECHAT_RC_OK + + +def quit_notification_callback(signal, sig_type, data): + stop_talking_to_slack() + return w.WEECHAT_RC_OK + + +@utf8_decode +def typing_notification_cb(data, signal, current_buffer): + msg = w.buffer_get_string(current_buffer, "input") + if len(msg) > 8 and msg[0] != "/": + global typing_timer + now = time.time() + if typing_timer + 4 < now: + channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if channel and channel.type != "thread": + identifier = channel.identifier + request = {"type": "typing", "channel": identifier} + channel.team.send_to_websocket(request, expect_reply=False) + typing_timer = now + return w.WEECHAT_RC_OK + + +@utf8_decode +def typing_update_cb(data, remaining_calls): + w.bar_item_update("slack_typing_notice") + return w.WEECHAT_RC_OK + + +@utf8_decode +def slack_never_away_cb(data, remaining_calls): + if config.never_away: + for team in EVENTROUTER.teams.values(): + set_own_presence_active(team) + return w.WEECHAT_RC_OK + + +@utf8_decode +def typing_bar_item_cb(data, item, current_window, current_buffer, extra_info): + """ + Privides a bar item indicating who is typing in the current channel AND + why is typing a DM to you globally. + """ + typers = [] + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + + # first look for people typing in this channel + if current_channel: + # this try is mostly becuase server buffers don't implement is_someone_typing + try: + if current_channel.type != 'im' and current_channel.is_someone_typing(): + typers += current_channel.get_typing_list() + except: + pass + + # here is where we notify you that someone is typing in DM + # regardless of which buffer you are in currently + for team in EVENTROUTER.teams.values(): + for channel in team.channels.values(): + if channel.type == "im": + if channel.is_someone_typing(): + typers.append("D/" + channel.slack_name) + pass + + typing = ", ".join(typers) + if typing != "": + typing = colorize_string(config.color_typing_notice, "typing: " + typing) + + return typing + + +@utf8_decode +def away_bar_item_cb(data, item, current_window, current_buffer, extra_info): + channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if not channel: + return '' + + if channel.team.is_user_present(channel.team.myidentifier): + return '' + else: + away_color = w.config_string(w.config_get('weechat.color.item_away')) + if channel.team.my_manual_presence == 'away': + return colorize_string(away_color, 'manual away') + else: + return colorize_string(away_color, 'auto away') + + +@utf8_decode +def channel_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all channels on all teams to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + should_include_channel = lambda channel: channel.active and channel.type in ['channel', 'group', 'private', 'shared'] + + other_teams = [team for team in EVENTROUTER.teams.values() if not current_channel or team != current_channel.team] + for team in other_teams: + for channel in team.channels.values(): + if should_include_channel(channel): + w.hook_completion_list_add(completion, channel.name, 0, w.WEECHAT_LIST_POS_SORT) + + if current_channel: + for channel in sorted(current_channel.team.channels.values(), key=lambda channel: channel.name, reverse=True): + if should_include_channel(channel): + w.hook_completion_list_add(completion, channel.name, 0, w.WEECHAT_LIST_POS_BEGINNING) + + if should_include_channel(current_channel): + w.hook_completion_list_add(completion, current_channel.name, 0, w.WEECHAT_LIST_POS_BEGINNING) + return w.WEECHAT_RC_OK + + +@utf8_decode +def dm_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all dms/mpdms on all teams to completion list + """ + for team in EVENTROUTER.teams.values(): + for channel in team.channels.values(): + if channel.active and channel.type in ['im', 'mpim']: + w.hook_completion_list_add(completion, channel.name, 0, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +@utf8_decode +def nick_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all @-prefixed nicks to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if current_channel is None or current_channel.members is None: + return w.WEECHAT_RC_OK + + base_command = w.hook_completion_get_string(completion, "base_command") + if base_command in ['invite', 'msg', 'query', 'whois']: + members = current_channel.team.members + else: + members = current_channel.members + + for member in members: + user = current_channel.team.users.get(member) + if user and not user.deleted: + w.hook_completion_list_add(completion, user.name, 1, w.WEECHAT_LIST_POS_SORT) + w.hook_completion_list_add(completion, "@" + user.name, 1, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +@utf8_decode +def emoji_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all :-prefixed emoji to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if current_channel is None: + return w.WEECHAT_RC_OK + + base_word = w.hook_completion_get_string(completion, "base_word") + if ":" not in base_word: + return w.WEECHAT_RC_OK + prefix = base_word.split(":")[0] + ":" + + for emoji in current_channel.team.emoji_completions: + w.hook_completion_list_add(completion, prefix + emoji + ":", 0, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +@utf8_decode +def thread_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all $-prefixed thread ids to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if current_channel is None or not hasattr(current_channel, 'hashed_messages'): + return w.WEECHAT_RC_OK + + threads = current_channel.hashed_messages.items() + for thread_id, message in sorted(threads, key=lambda item: item[1].ts): + if message.number_of_replies(): + w.hook_completion_list_add(completion, "$" + thread_id, 0, w.WEECHAT_LIST_POS_BEGINNING) + return w.WEECHAT_RC_OK + + +@utf8_decode +def topic_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds topic for current channel to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if current_channel is None: + return w.WEECHAT_RC_OK + + topic = current_channel.render_topic() + channel_names = [channel.name for channel in current_channel.team.channels.values()] + if topic.split(' ', 1)[0] in channel_names: + topic = '{} {}'.format(current_channel.name, topic) + + w.hook_completion_list_add(completion, topic, 0, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +@utf8_decode +def usergroups_completion_cb(data, completion_item, current_buffer, completion): + """ + Adds all @-prefixed usergroups to completion list + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if current_channel is None: + return w.WEECHAT_RC_OK + + subteam_handles = [subteam.handle for subteam in current_channel.team.subteams.values()] + for group in subteam_handles + ["@channel", "@everyone", "@here"]: + w.hook_completion_list_add(completion, group, 1, w.WEECHAT_LIST_POS_SORT) + return w.WEECHAT_RC_OK + + +@utf8_decode +def complete_next_cb(data, current_buffer, command): + """Extract current word, if it is equal to a nick, prefix it with @ and + rely on nick_completion_cb adding the @-prefixed versions to the + completion lists, then let Weechat's internal completion do its + thing + """ + current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) + if not hasattr(current_channel, 'members') or current_channel is None or current_channel.members is None: + return w.WEECHAT_RC_OK + + line_input = w.buffer_get_string(current_buffer, "input") + current_pos = w.buffer_get_integer(current_buffer, "input_pos") - 1 + input_length = w.buffer_get_integer(current_buffer, "input_length") + + word_start = 0 + word_end = input_length + # If we're on a non-word, look left for something to complete + while current_pos >= 0 and line_input[current_pos] != '@' and not line_input[current_pos].isalnum(): + current_pos = current_pos - 1 + if current_pos < 0: + current_pos = 0 + for l in range(current_pos, 0, -1): + if line_input[l] != '@' and not line_input[l].isalnum(): + word_start = l + 1 + break + for l in range(current_pos, input_length): + if not line_input[l].isalnum(): + word_end = l + break + word = line_input[word_start:word_end] + + for member in current_channel.members: + user = current_channel.team.users.get(member) + if user and user.name == word: + # Here, we cheat. Insert a @ in front and rely in the @ + # nicks being in the completion list + w.buffer_set(current_buffer, "input", line_input[:word_start] + "@" + line_input[word_start:]) + w.buffer_set(current_buffer, "input_pos", str(w.buffer_get_integer(current_buffer, "input_pos") + 1)) + return w.WEECHAT_RC_OK_EAT + return w.WEECHAT_RC_OK + + +def script_unloaded(): + stop_talking_to_slack() + return w.WEECHAT_RC_OK + + +def stop_talking_to_slack(): + """ + complete + Prevents a race condition where quitting closes buffers + which triggers leaving the channel because of how close + buffer is handled + """ + EVENTROUTER.shutdown() + for team in EVENTROUTER.teams.values(): + team.ws.shutdown() + return w.WEECHAT_RC_OK + +##### New Classes + + +class SlackRequest(object): + """ + Encapsulates a Slack api request. Valuable as an object that we can add to the queue and/or retry. + makes a SHA of the requst url and current time so we can re-tag this on the way back through. + """ + + def __init__(self, team, request, post_data=None, channel=None, metadata=None, retries=3, token=None): + if team is None and token is None: + raise ValueError("Both team and token can't be None") + self.team = team + self.request = request + self.post_data = post_data if post_data else {} + self.channel = channel + self.metadata = metadata if metadata else {} + self.retries = retries + self.token = token if token else team.token + self.tries = 0 + self.start_time = time.time() + self.request_normalized = re.sub(r'\W+', '', request) + self.domain = 'api.slack.com' + self.post_data['token'] = self.token + self.url = 'https://{}/api/{}?{}'.format(self.domain, self.request, urlencode(encode_to_utf8(self.post_data))) + self.params = {'useragent': 'wee_slack {}'.format(SCRIPT_VERSION)} + self.response_id = sha1_hex('{}{}'.format(self.url, self.start_time)) + + def __repr__(self): + return ("SlackRequest(team={}, request='{}', post_data={}, retries={}, token='{}...', " + "tries={}, start_time={})").format(self.team, self.request, self.post_data, + self.retries, self.token[:15], self.tries, self.start_time) + + def request_string(self): + return "{}".format(self.url) + + def tried(self): + self.tries += 1 + self.response_id = sha1_hex("{}{}".format(self.url, time.time())) + + def should_try(self): + return self.tries < self.retries + + def retry_ready(self): + return (self.start_time + (self.tries**2)) < time.time() + + +class SlackSubteam(object): + """ + Represents a slack group or subteam + """ + + def __init__(self, originating_team_id, is_member, **kwargs): + self.handle = '@{}'.format(kwargs['handle']) + self.identifier = kwargs['id'] + self.name = kwargs['name'] + self.description = kwargs.get('description') + self.team_id = originating_team_id + self.is_member = is_member + + def __repr__(self): + return "Name:{} Identifier:{}".format(self.name, self.identifier) + + def __eq__(self, compare_str): + return compare_str == self.identifier + + +class SlackTeam(object): + """ + incomplete + Team object under which users and channels live.. Does lots. + """ + + def __init__(self, eventrouter, token, websocket_url, team_info, subteams, nick, myidentifier, my_manual_presence, users, bots, channels, **kwargs): + self.identifier = team_info["id"] + self.active = True + self.ws_url = websocket_url + self.connected = False + self.connecting_rtm = False + self.connecting_ws = False + self.ws = None + self.ws_counter = 0 + self.ws_replies = {} + self.last_ping_time = 0 + self.last_pong_time = time.time() + self.eventrouter = eventrouter + self.token = token + self.team = self + self.subteams = subteams + self.team_info = team_info + self.subdomain = team_info["domain"] + self.domain = self.subdomain + ".slack.com" + self.preferred_name = self.domain + self.nick = nick + self.myidentifier = myidentifier + self.my_manual_presence = my_manual_presence + try: + if self.channels: + for c in channels.keys(): + if not self.channels.get(c): + self.channels[c] = channels[c] + except: + self.channels = channels + self.users = users + self.bots = bots + self.team_hash = SlackTeam.generate_team_hash(self.nick, self.subdomain) + self.name = self.domain + self.channel_buffer = None + self.got_history = True + self.create_buffer() + self.set_muted_channels(kwargs.get('muted_channels', "")) + for c in self.channels.keys(): + channels[c].set_related_server(self) + channels[c].check_should_open() + # Last step is to make sure my nickname is the set color + self.users[self.myidentifier].force_color(w.config_string(w.config_get('weechat.color.chat_nick_self'))) + # This highlight step must happen after we have set related server + self.set_highlight_words(kwargs.get('highlight_words', "")) + self.load_emoji_completions() + self.type = "team" + + def __repr__(self): + return "domain={} nick={}".format(self.subdomain, self.nick) + + def __eq__(self, compare_str): + return compare_str == self.token or compare_str == self.domain or compare_str == self.subdomain + + @property + def members(self): + return self.users.keys() + + def load_emoji_completions(self): + self.emoji_completions = list(EMOJI.keys()) + if self.emoji_completions: + s = SlackRequest(self, "emoji.list") + self.eventrouter.receive(s) + + def add_channel(self, channel): + self.channels[channel["id"]] = channel + channel.set_related_server(self) + + def generate_usergroup_map(self): + return {s.handle: s.identifier for s in self.subteams.values()} + + def create_buffer(self): + if not self.channel_buffer: + alias = config.server_aliases.get(self.subdomain) + if alias: + self.preferred_name = alias + elif config.short_buffer_names: + self.preferred_name = self.subdomain + else: + self.preferred_name = self.domain + self.channel_buffer = w.buffer_new(self.preferred_name, "buffer_input_callback", "EVENTROUTER", "", "") + self.eventrouter.weechat_controller.register_buffer(self.channel_buffer, self) + w.buffer_set(self.channel_buffer, "localvar_set_type", 'server') + w.buffer_set(self.channel_buffer, "localvar_set_nick", self.nick) + w.buffer_set(self.channel_buffer, "localvar_set_server", self.preferred_name) + self.buffer_merge() + + def buffer_merge(self, config_value=None): + if not config_value: + config_value = w.config_string(w.config_get('irc.look.server_buffer')) + if config_value == 'merge_with_core': + w.buffer_merge(self.channel_buffer, w.buffer_search_main()) + else: + w.buffer_unmerge(self.channel_buffer, 0) + + def destroy_buffer(self, update_remote): + pass + + def set_muted_channels(self, muted_str): + self.muted_channels = {x for x in muted_str.split(',') if x} + for channel in self.channels.values(): + channel.set_highlights() + + def set_highlight_words(self, highlight_str): + self.highlight_words = {x for x in highlight_str.split(',') if x} + for channel in self.channels.values(): + channel.set_highlights() + + def formatted_name(self, **kwargs): + return self.domain + + def buffer_prnt(self, data, message=False): + tag_name = "team_message" if message else "team_info" + w.prnt_date_tags(self.channel_buffer, SlackTS().major, tag(tag_name), data) + + def send_message(self, message, subtype=None, request_dict_ext={}): + w.prnt("", "ERROR: Sending a message in the team buffer is not supported") + + def find_channel_by_members(self, members, channel_type=None): + for channel in self.channels.values(): + if channel.get_members() == members and ( + channel_type is None or channel.type == channel_type): + return channel + + def get_channel_map(self): + return {v.name: k for k, v in self.channels.items()} + + def get_username_map(self): + return {v.name: k for k, v in self.users.items()} + + def get_team_hash(self): + return self.team_hash + + @staticmethod + def generate_team_hash(nick, subdomain): + return str(sha1_hex("{}{}".format(nick, subdomain))) + + def refresh(self): + self.rename() + + def rename(self): + pass + + def is_user_present(self, user_id): + user = self.users.get(user_id) + if user and user.presence == 'active': + return True + else: + return False + + def mark_read(self, ts=None, update_remote=True, force=False): + pass + + def connect(self): + if not self.connected and not self.connecting_ws: + if self.ws_url: + self.connecting_ws = True + try: + # only http proxy is currently supported + proxy = ProxyWrapper() + if proxy.has_proxy == True: + ws = create_connection(self.ws_url, sslopt=sslopt_ca_certs, http_proxy_host=proxy.proxy_address, http_proxy_port=proxy.proxy_port, http_proxy_auth=(proxy.proxy_user, proxy.proxy_password)) + else: + ws = create_connection(self.ws_url, sslopt=sslopt_ca_certs) + + self.hook = w.hook_fd(ws.sock.fileno(), 1, 0, 0, "receive_ws_callback", self.get_team_hash()) + ws.sock.setblocking(0) + self.ws = ws + self.set_reconnect_url(None) + self.set_connected() + self.connecting_ws = False + except: + w.prnt(self.channel_buffer, + 'Failed connecting to slack team {}, retrying.'.format(self.domain)) + dbg('connect failed with exception:\n{}'.format(format_exc_tb()), level=5) + self.connecting_ws = False + return False + elif not self.connecting_rtm: + # The fast reconnect failed, so start over-ish + for chan in self.channels: + self.channels[chan].got_history = False + s = initiate_connection(self.token, retries=999, team=self) + self.eventrouter.receive(s) + self.connecting_rtm = True + + def set_connected(self): + self.connected = True + self.last_pong_time = time.time() + self.buffer_prnt('Connected to Slack team {} ({}) with username {}'.format( + self.team_info["name"], self.domain, self.nick)) + dbg("connected to {}".format(self.domain)) + + def set_disconnected(self): + w.unhook(self.hook) + self.connected = False + + def set_reconnect_url(self, url): + self.ws_url = url + + def next_ws_transaction_id(self): + self.ws_counter += 1 + return self.ws_counter + + def send_to_websocket(self, data, expect_reply=True): + data["id"] = self.next_ws_transaction_id() + message = json.dumps(data) + try: + if expect_reply: + self.ws_replies[data["id"]] = data + self.ws.send(encode_to_utf8(message)) + dbg("Sent {}...".format(message[:100])) + except (WebSocketConnectionClosedException, socket.error) as e: + handle_socket_error(e, self, 'send') + + def update_member_presence(self, user, presence): + user.presence = presence + + for c in self.channels: + c = self.channels[c] + if user.id in c.members: + c.update_nicklist(user.id) + + def subscribe_users_presence(self): + # FIXME: There is a limitation in the API to the size of the + # json we can send. + # We should try to be smarter to fetch the users whom we want to + # subscribe to. + users = list(self.users.keys())[:750] + if self.myidentifier not in users: + users.append(self.myidentifier) + self.send_to_websocket({ + "type": "presence_sub", + "ids": users, + }, expect_reply=False) + + +class SlackChannelCommon(object): + def send_add_reaction(self, msg_id, reaction): + self.send_change_reaction("reactions.add", msg_id, reaction) + + def send_remove_reaction(self, msg_id, reaction): + self.send_change_reaction("reactions.remove", msg_id, reaction) + + def send_change_reaction(self, method, msg_id, reaction): + if type(msg_id) is not int: + if msg_id in self.hashed_messages: + timestamp = str(self.hashed_messages[msg_id].ts) + else: + return + elif 0 < msg_id <= len(self.messages): + keys = self.main_message_keys_reversed() + timestamp = next(islice(keys, msg_id - 1, None)) + else: + return + data = {"channel": self.identifier, "timestamp": timestamp, "name": reaction} + s = SlackRequest(self.team, method, data, channel=self, metadata={'reaction': reaction}) + self.eventrouter.receive(s) + + def edit_nth_previous_message(self, msg_id, old, new, flags): + message = self.my_last_message(msg_id) + if message is None: + return + if new == "" and old == "": + s = SlackRequest(self.team, "chat.delete", {"channel": self.identifier, "ts": message['ts']}, channel=self) + self.eventrouter.receive(s) + else: + num_replace = 0 if 'g' in flags else 1 + f = re.UNICODE + f |= re.IGNORECASE if 'i' in flags else 0 + f |= re.MULTILINE if 'm' in flags else 0 + f |= re.DOTALL if 's' in flags else 0 + new_message = re.sub(old, new, message["text"], num_replace, f) + if new_message != message["text"]: + s = SlackRequest(self.team, "chat.update", + {"channel": self.identifier, "ts": message['ts'], "text": new_message}, channel=self) + self.eventrouter.receive(s) + + def my_last_message(self, msg_id): + if type(msg_id) is not int: + m = self.hashed_messages.get(msg_id) + if m is not None and m.message_json.get("user") == self.team.myidentifier: + return m.message_json + else: + for key in self.main_message_keys_reversed(): + m = self.messages[key] + if m.message_json.get("user") == self.team.myidentifier: + msg_id -= 1 + if msg_id == 0: + return m.message_json + + def change_message(self, ts, message_json=None, text=None): + ts = SlackTS(ts) + m = self.messages.get(ts) + if not m: + return + if message_json: + m.message_json.update(message_json) + if text: + m.change_text(text) + + if type(m) == SlackMessage or config.thread_messages_in_channel: + new_text = self.render(m, force=True) + modify_buffer_line(self.channel_buffer, ts, new_text) + if type(m) == SlackThreadMessage: + thread_channel = m.parent_message.thread_channel + if thread_channel and thread_channel.active: + new_text = thread_channel.render(m, force=True) + modify_buffer_line(thread_channel.channel_buffer, ts, new_text) + + def hash_message(self, ts): + ts = SlackTS(ts) + + def calc_hash(msg): + return sha1_hex(str(msg.ts)) + + if ts in self.messages and not self.messages[ts].hash: + message = self.messages[ts] + tshash = calc_hash(message) + hl = 3 + shorthash = tshash[:hl] + while any(x.startswith(shorthash) for x in self.hashed_messages): + hl += 1 + shorthash = tshash[:hl] + + if shorthash[:-1] in self.hashed_messages: + col_msg = self.hashed_messages.pop(shorthash[:-1]) + col_new_hash = calc_hash(col_msg)[:hl] + col_msg.hash = col_new_hash + self.hashed_messages[col_new_hash] = col_msg + self.change_message(str(col_msg.ts)) + if col_msg.thread_channel: + col_msg.thread_channel.rename() + + self.hashed_messages[shorthash] = message + message.hash = shorthash + return shorthash + elif ts in self.messages: + return self.messages[ts].hash + + + +class SlackChannel(SlackChannelCommon): + """ + Represents an individual slack channel. + """ + + def __init__(self, eventrouter, **kwargs): + # We require these two things for a valid object, + # the rest we can just learn from slack + self.active = False + for key, value in kwargs.items(): + setattr(self, key, value) + self.eventrouter = eventrouter + self.slack_name = kwargs["name"] + self.slack_purpose = kwargs.get("purpose", {"value": ""}) + self.topic = kwargs.get("topic", {"value": ""}) + self.identifier = kwargs["id"] + self.last_read = SlackTS(kwargs.get("last_read", SlackTS())) + self.channel_buffer = None + self.team = kwargs.get('team') + self.got_history = False + self.messages = OrderedDict() + self.hashed_messages = {} + self.new_messages = False + self.typing = {} + self.type = 'channel' + self.set_name(self.slack_name) + # short name relates to the localvar we change for typing indication + self.current_short_name = self.name + self.set_members(kwargs.get('members', [])) + self.unread_count_display = 0 + self.last_line_from = None + + def __eq__(self, compare_str): + if compare_str == self.slack_name or compare_str == self.formatted_name() or compare_str == self.formatted_name(style="long_default"): + return True + else: + return False + + def __repr__(self): + return "Name:{} Identifier:{}".format(self.name, self.identifier) + + @property + def muted(self): + return self.identifier in self.team.muted_channels + + def set_name(self, slack_name): + self.name = "#" + slack_name + + def refresh(self): + return self.rename() + + def rename(self): + if self.channel_buffer: + new_name = self.formatted_name(typing=self.is_someone_typing(), style="sidebar") + if self.current_short_name != new_name: + self.current_short_name = new_name + w.buffer_set(self.channel_buffer, "short_name", new_name) + return True + return False + + def set_members(self, members): + self.members = set(members) + self.update_nicklist() + + def get_members(self): + return self.members + + def set_unread_count_display(self, count): + self.unread_count_display = count + self.new_messages = bool(self.unread_count_display) + if self.muted and config.muted_channels_activity != "all": + return + for c in range(self.unread_count_display): + if self.type in ["im", "mpim"]: + w.buffer_set(self.channel_buffer, "hotlist", "2") + else: + w.buffer_set(self.channel_buffer, "hotlist", "1") + + def formatted_name(self, style="default", typing=False, **kwargs): + if typing and config.channel_name_typing_indicator: + prepend = ">" + elif self.type == "group" or self.type == "private": + prepend = config.group_name_prefix + elif self.type == "shared": + prepend = config.shared_name_prefix + else: + prepend = "#" + sidebar_color = config.color_buflist_muted_channels if self.muted else "" + select = { + "default": prepend + self.slack_name, + "sidebar": colorize_string(sidebar_color, prepend + self.slack_name), + "base": self.slack_name, + "long_default": "{}.{}{}".format(self.team.preferred_name, prepend, self.slack_name), + "long_base": "{}.{}".format(self.team.preferred_name, self.slack_name), + } + return select[style] + + def render_topic(self, fallback_to_purpose=False): + topic = self.topic['value'] + if not topic and fallback_to_purpose: + topic = self.slack_purpose['value'] + return unhtmlescape(unfurl_refs(topic)) + + def set_topic(self, value=None): + if value is not None: + self.topic = {"value": value} + if self.channel_buffer: + topic = self.render_topic(fallback_to_purpose=True) + w.buffer_set(self.channel_buffer, "title", topic) + + def update_from_message_json(self, message_json): + for key, value in message_json.items(): + setattr(self, key, value) + + def open(self, update_remote=True): + if update_remote: + if "join" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["join"], + {"channel": self.identifier}, channel=self) + self.eventrouter.receive(s) + self.create_buffer() + self.active = True + self.get_history() + + def check_should_open(self, force=False): + if hasattr(self, "is_archived") and self.is_archived: + return + + if force: + self.create_buffer() + return + + # Only check is_member if is_open is not set, because in some cases + # (e.g. group DMs), is_member should be ignored in favor of is_open. + is_open = self.is_open if hasattr(self, "is_open") else self.is_member + if is_open or self.unread_count_display: + self.create_buffer() + if config.background_load_all_history: + self.get_history(slow_queue=True) + + def set_related_server(self, team): + self.team = team + + def highlights(self): + nick_highlights = {'@' + self.team.nick, self.team.myidentifier} + subteam_highlights = {subteam.handle for subteam in self.team.subteams.values() + if subteam.is_member} + highlights = nick_highlights | subteam_highlights | self.team.highlight_words + if self.muted and config.muted_channels_activity == "personal_highlights": + return highlights + else: + return highlights | {"@channel", "@everyone", "@group", "@here"} + + def set_highlights(self): + # highlight my own name and any set highlights + if self.channel_buffer: + h_str = ",".join(self.highlights()) + w.buffer_set(self.channel_buffer, "highlight_words", h_str) + + if self.muted and config.muted_channels_activity != "all": + notify_level = "0" if config.muted_channels_activity == "none" else "1" + w.buffer_set(self.channel_buffer, "notify", notify_level) + else: + w.buffer_set(self.channel_buffer, "notify", "3") + + if self.muted and config.muted_channels_activity == "none": + w.buffer_set(self.channel_buffer, "highlight_tags_restrict", "highlight_force") + else: + w.buffer_set(self.channel_buffer, "highlight_tags_restrict", "") + + def create_buffer(self): + """ + Creates the weechat buffer where the channel magic happens. + """ + if not self.channel_buffer: + self.active = True + self.channel_buffer = w.buffer_new(self.formatted_name(style="long_default"), "buffer_input_callback", "EVENTROUTER", "", "") + self.eventrouter.weechat_controller.register_buffer(self.channel_buffer, self) + if self.type == "im": + w.buffer_set(self.channel_buffer, "localvar_set_type", 'private') + else: + w.buffer_set(self.channel_buffer, "localvar_set_type", 'channel') + w.buffer_set(self.channel_buffer, "localvar_set_channel", self.formatted_name()) + w.buffer_set(self.channel_buffer, "localvar_set_nick", self.team.nick) + w.buffer_set(self.channel_buffer, "short_name", self.formatted_name(style="sidebar", enable_color=True)) + self.set_topic() + self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + if self.channel_buffer: + w.buffer_set(self.channel_buffer, "localvar_set_server", self.team.preferred_name) + self.update_nicklist() + + if "info" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["info"], + {"channel": self.identifier}, channel=self) + self.eventrouter.receive(s) + + if self.type == "im": + if "join" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["join"], + {"users": self.user, "return_im": True}, channel=self) + self.eventrouter.receive(s) + + def clear_messages(self): + w.buffer_clear(self.channel_buffer) + self.messages = OrderedDict() + self.hashed_messages = {} + self.got_history = False + + def destroy_buffer(self, update_remote): + self.clear_messages() + self.channel_buffer = None + self.active = False + if update_remote and not self.eventrouter.shutting_down: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["leave"], + {"channel": self.identifier}, channel=self) + self.eventrouter.receive(s) + + def buffer_prnt(self, nick, text, timestamp=str(time.time()), tagset=None, tag_nick=None, history_message=False, extra_tags=None): + data = "{}\t{}".format(format_nick(nick, self.last_line_from), text) + self.last_line_from = nick + ts = SlackTS(timestamp) + last_read = SlackTS(self.last_read) + # without this, DMs won't open automatically + if not self.channel_buffer and ts > last_read: + self.open(update_remote=False) + if self.channel_buffer: + # backlog messages - we will update the read marker as we print these + backlog = ts <= last_read + if not backlog: + self.new_messages = True + + if not tagset: + if self.type in ["im", "mpim"]: + tagset = "dm" + else: + tagset = "channel" + + no_log = history_message and backlog + self_msg = tag_nick == self.team.nick + tags = tag(tagset, user=tag_nick, self_msg=self_msg, backlog=backlog, no_log=no_log, extra_tags=extra_tags) + + try: + if (config.unhide_buffers_with_activity + and not self.is_visible() and not self.muted): + w.buffer_set(self.channel_buffer, "hidden", "0") + + w.prnt_date_tags(self.channel_buffer, ts.major, tags, data) + modify_last_print_time(self.channel_buffer, ts.minor) + if backlog or self_msg: + self.mark_read(ts, update_remote=False, force=True) + except: + dbg("Problem processing buffer_prnt") + + def send_message(self, message, subtype=None, request_dict_ext={}): + message = linkify_text(message, self.team) + dbg(message) + if subtype == 'me_message': + s = SlackRequest(self.team, "chat.meMessage", {"channel": self.identifier, "text": message}, channel=self) + self.eventrouter.receive(s) + else: + request = {"type": "message", "channel": self.identifier, + "text": message, "user": self.team.myidentifier} + request.update(request_dict_ext) + self.team.send_to_websocket(request) + + def store_message(self, message, team, from_me=False): + if not self.active: + return + if from_me: + message.message_json["user"] = team.myidentifier + self.messages[SlackTS(message.ts)] = message + + sorted_messages = sorted(self.messages.items()) + messages_to_delete = sorted_messages[:-SCROLLBACK_SIZE] + messages_to_keep = sorted_messages[-SCROLLBACK_SIZE:] + for message_hash in [m[1].hash for m in messages_to_delete]: + if message_hash in self.hashed_messages: + del self.hashed_messages[message_hash] + self.messages = OrderedDict(messages_to_keep) + + def is_visible(self): + return w.buffer_get_integer(self.channel_buffer, "hidden") == 0 + + def get_history(self, slow_queue=False): + if not self.got_history: + # we have probably reconnected. flush the buffer + if self.team.connected: + self.clear_messages() + w.prnt_date_tags(self.channel_buffer, SlackTS().major, + tag(backlog=True, no_log=True), '\tgetting channel history...') + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["history"], + {"channel": self.identifier, "count": BACKLOG_SIZE}, channel=self, metadata={'clear': True}) + if not slow_queue: + self.eventrouter.receive(s) + else: + self.eventrouter.receive_slow(s) + self.got_history = True + + def main_message_keys_reversed(self): + return (key for key in reversed(self.messages) + if type(self.messages[key]) == SlackMessage) + + # Typing related + def set_typing(self, user): + if self.channel_buffer and self.is_visible(): + self.typing[user] = time.time() + self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + + def unset_typing(self, user): + if self.channel_buffer and self.is_visible(): + u = self.typing.get(user) + if u: + self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + + def is_someone_typing(self): + """ + Walks through dict of typing folks in a channel and fast + returns if any of them is actively typing. If none are, + nulls the dict and returns false. + """ + for user, timestamp in self.typing.items(): + if timestamp + 4 > time.time(): + return True + if len(self.typing) > 0: + self.typing = {} + self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + return False + + def get_typing_list(self): + """ + Returns the names of everyone in the channel who is currently typing. + """ + typing = [] + for user, timestamp in self.typing.items(): + if timestamp + 4 > time.time(): + typing.append(user) + else: + del self.typing[user] + return typing + + def mark_read(self, ts=None, update_remote=True, force=False): + if self.new_messages or force: + if self.channel_buffer: + w.buffer_set(self.channel_buffer, "unread", "") + w.buffer_set(self.channel_buffer, "hotlist", "-1") + if not ts: + ts = next(reversed(self.messages), SlackTS()) + if ts > self.last_read: + self.last_read = ts + if update_remote: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["mark"], + {"channel": self.identifier, "ts": ts}, channel=self) + self.eventrouter.receive(s) + self.new_messages = False + + def user_joined(self, user_id): + # ugly hack - for some reason this gets turned into a list + self.members = set(self.members) + self.members.add(user_id) + self.update_nicklist(user_id) + + def user_left(self, user_id): + self.members.discard(user_id) + self.update_nicklist(user_id) + + def update_nicklist(self, user=None): + if not self.channel_buffer: + return + if self.type not in ["channel", "group", "mpim", "private", "shared"]: + return + w.buffer_set(self.channel_buffer, "nicklist", "1") + # create nicklists for the current channel if they don't exist + # if they do, use the existing pointer + here = w.nicklist_search_group(self.channel_buffer, '', NICK_GROUP_HERE) + if not here: + here = w.nicklist_add_group(self.channel_buffer, '', NICK_GROUP_HERE, "weechat.color.nicklist_group", 1) + afk = w.nicklist_search_group(self.channel_buffer, '', NICK_GROUP_AWAY) + if not afk: + afk = w.nicklist_add_group(self.channel_buffer, '', NICK_GROUP_AWAY, "weechat.color.nicklist_group", 1) + + # Add External nicklist group only for shared channels + if self.type == 'shared': + external = w.nicklist_search_group(self.channel_buffer, '', NICK_GROUP_EXTERNAL) + if not external: + external = w.nicklist_add_group(self.channel_buffer, '', NICK_GROUP_EXTERNAL, 'weechat.color.nicklist_group', 2) + + if user and len(self.members) < 1000: + user = self.team.users.get(user) + # External users that have left shared channels won't exist + if not user or user.deleted: + return + nick = w.nicklist_search_nick(self.channel_buffer, "", user.name) + # since this is a change just remove it regardless of where it is + w.nicklist_remove_nick(self.channel_buffer, nick) + # now add it back in to whichever.. + nick_group = afk + if user.is_external: + nick_group = external + elif self.team.is_user_present(user.identifier): + nick_group = here + if user.identifier in self.members: + w.nicklist_add_nick(self.channel_buffer, nick_group, user.name, user.color_name, "", "", 1) + + # if we didn't get a user, build a complete list. this is expensive. + else: + if len(self.members) < 1000: + try: + for user in self.members: + user = self.team.users.get(user) + if user.deleted: + continue + nick_group = afk + if user.is_external: + nick_group = external + elif self.team.is_user_present(user.identifier): + nick_group = here + w.nicklist_add_nick(self.channel_buffer, nick_group, user.name, user.color_name, "", "", 1) + except: + dbg("DEBUG: {} {} {}".format(self.identifier, self.name, format_exc_only())) + else: + w.nicklist_remove_all(self.channel_buffer) + for fn in ["1| too", "2| many", "3| users", "4| to", "5| show"]: + w.nicklist_add_group(self.channel_buffer, '', fn, w.color('white'), 1) + + def render(self, message, force=False): + text = message.render(force) + if isinstance(message, SlackThreadMessage): + thread_id = message.parent_message.hash or message.parent_message.ts + return colorize_string(get_thread_color(thread_id), '[{}]'.format(thread_id)) + ' {}'.format(text) + + return text + + +class SlackDMChannel(SlackChannel): + """ + Subclass of a normal channel for person-to-person communication, which + has some important differences. + """ + + def __init__(self, eventrouter, users, **kwargs): + dmuser = kwargs["user"] + kwargs["name"] = users[dmuser].name if dmuser in users else dmuser + super(SlackDMChannel, self).__init__(eventrouter, **kwargs) + self.type = 'im' + self.update_color() + self.set_name(self.slack_name) + if dmuser in users: + self.set_topic(create_user_status_string(users[dmuser].profile)) + + def set_related_server(self, team): + super(SlackDMChannel, self).set_related_server(team) + if self.user not in self.team.users: + s = SlackRequest(self.team, 'users.info', {'user': self.slack_name}, channel=self) + self.eventrouter.receive(s) + + def set_name(self, slack_name): + self.name = slack_name + + def get_members(self): + return {self.user} + + def create_buffer(self): + if not self.channel_buffer: + super(SlackDMChannel, self).create_buffer() + w.buffer_set(self.channel_buffer, "localvar_set_type", 'private') + + def update_color(self): + if config.colorize_private_chats: + self.color_name = get_nick_color(self.name) + else: + self.color_name = "" + + def formatted_name(self, style="default", typing=False, present=True, enable_color=False, **kwargs): + prepend = "" + if config.show_buflist_presence: + prepend = "+" if present else " " + select = { + "default": self.slack_name, + "sidebar": prepend + self.slack_name, + "base": self.slack_name, + "long_default": "{}.{}".format(self.team.preferred_name, self.slack_name), + "long_base": "{}.{}".format(self.team.preferred_name, self.slack_name), + } + if config.colorize_private_chats and enable_color: + return colorize_string(self.color_name, select[style]) + else: + return select[style] + + def open(self, update_remote=True): + self.create_buffer() + self.get_history() + if "info" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["info"], + {"name": self.identifier}, channel=self) + self.eventrouter.receive(s) + if update_remote: + if "join" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["join"], + {"users": self.user, "return_im": True}, channel=self) + self.eventrouter.receive(s) + + def rename(self): + if self.channel_buffer: + new_name = self.formatted_name(style="sidebar", present=self.team.is_user_present(self.user), enable_color=config.colorize_private_chats) + if self.current_short_name != new_name: + self.current_short_name = new_name + w.buffer_set(self.channel_buffer, "short_name", new_name) + return True + return False + + def refresh(self): + return self.rename() + + +class SlackGroupChannel(SlackChannel): + """ + A group channel is a private discussion group. + """ + + def __init__(self, eventrouter, **kwargs): + super(SlackGroupChannel, self).__init__(eventrouter, **kwargs) + self.type = "group" + self.set_name(self.slack_name) + + def set_name(self, slack_name): + self.name = config.group_name_prefix + slack_name + + +class SlackPrivateChannel(SlackGroupChannel): + """ + A private channel is a private discussion group. At the time of writing, it + differs from group channels in that group channels are channels initially + created as private, while private channels are public channels which are + later converted to private. + """ + + def __init__(self, eventrouter, **kwargs): + super(SlackPrivateChannel, self).__init__(eventrouter, **kwargs) + self.type = "private" + + def set_related_server(self, team): + super(SlackPrivateChannel, self).set_related_server(team) + # Fetch members here (after the team is known) since they aren't + # included in rtm.start + s = SlackRequest(team, 'conversations.members', {'channel': self.identifier}, channel=self) + self.eventrouter.receive(s) + + +class SlackMPDMChannel(SlackChannel): + """ + An MPDM channel is a special instance of a 'group' channel. + We change the name to look less terrible in weechat. + """ + + def __init__(self, eventrouter, team_users, myidentifier, **kwargs): + kwargs["name"] = ','.join(sorted( + getattr(team_users.get(user_id), 'name', user_id) + for user_id in kwargs["members"] + if user_id != myidentifier + )) + super(SlackMPDMChannel, self).__init__(eventrouter, **kwargs) + self.type = "mpim" + + def open(self, update_remote=True): + self.create_buffer() + self.active = True + self.get_history() + if "info" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]["info"], + {"channel": self.identifier}, channel=self) + self.eventrouter.receive(s) + if update_remote and 'join' in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team, SLACK_API_TRANSLATOR[self.type]['join'], + {'users': ','.join(self.members)}, channel=self) + self.eventrouter.receive(s) + + def set_name(self, slack_name): + self.name = slack_name + + def formatted_name(self, style="default", typing=False, **kwargs): + if typing and config.channel_name_typing_indicator: + prepend = ">" + else: + prepend = "@" + select = { + "default": self.name, + "sidebar": prepend + self.name, + "base": self.name, + "long_default": "{}.{}".format(self.team.preferred_name, self.name), + "long_base": "{}.{}".format(self.team.preferred_name, self.name), + } + return select[style] + + def rename(self): + pass + + +class SlackSharedChannel(SlackChannel): + def __init__(self, eventrouter, **kwargs): + super(SlackSharedChannel, self).__init__(eventrouter, **kwargs) + self.type = 'shared' + + def set_related_server(self, team): + super(SlackSharedChannel, self).set_related_server(team) + # Fetch members here (after the team is known) since they aren't + # included in rtm.start + s = SlackRequest(team, 'conversations.members', {'channel': self.identifier}, channel=self) + self.eventrouter.receive(s) + + def get_history(self, slow_queue=False): + # Get info for external users in the channel + for user in self.members - set(self.team.users.keys()): + s = SlackRequest(self.team, 'users.info', {'user': user}, channel=self) + self.eventrouter.receive(s) + super(SlackSharedChannel, self).get_history(slow_queue) + + def set_name(self, slack_name): + self.name = config.shared_name_prefix + slack_name + + +class SlackThreadChannel(SlackChannelCommon): + """ + A thread channel is a virtual channel. We don't inherit from + SlackChannel, because most of how it operates will be different. + """ + + def __init__(self, eventrouter, parent_message): + self.eventrouter = eventrouter + self.parent_message = parent_message + self.hashed_messages = {} + self.channel_buffer = None + self.type = "thread" + self.got_history = False + self.label = None + self.members = self.parent_message.channel.members + self.team = self.parent_message.team + self.last_line_from = None + + @property + def identifier(self): + return self.parent_message.channel.identifier + + @property + def messages(self): + return self.parent_message.channel.messages + + @property + def muted(self): + return self.parent_message.channel.muted + + def formatted_name(self, style="default", **kwargs): + hash_or_ts = self.parent_message.hash or self.parent_message.ts + styles = { + "default": " +{}".format(hash_or_ts), + "long_default": "{}.{}".format(self.parent_message.channel.formatted_name(style="long_default"), hash_or_ts), + "sidebar": " +{}".format(hash_or_ts), + } + return styles[style] + + def refresh(self): + self.rename() + + def mark_read(self, ts=None, update_remote=True, force=False): + if self.channel_buffer: + w.buffer_set(self.channel_buffer, "unread", "") + w.buffer_set(self.channel_buffer, "hotlist", "-1") + + def buffer_prnt(self, nick, text, timestamp, tag_nick=None): + data = "{}\t{}".format(format_nick(nick, self.last_line_from), text) + self.last_line_from = nick + ts = SlackTS(timestamp) + if self.channel_buffer: + if self.parent_message.channel.type in ["im", "mpim"]: + tagset = "dm" + else: + tagset = "channel" + self_msg = tag_nick == self.team.nick + tags = tag(tagset, user=tag_nick, self_msg=self_msg) + + w.prnt_date_tags(self.channel_buffer, ts.major, tags, data) + modify_last_print_time(self.channel_buffer, ts.minor) + if self_msg: + self.mark_read(ts, update_remote=False, force=True) + + def get_history(self): + self.got_history = True + for message in self.parent_message.submessages: + text = self.render(message) + self.buffer_prnt(message.sender, text, message.ts, tag_nick=message.sender_plain) + if len(self.parent_message.submessages) < self.parent_message.number_of_replies(): + s = SlackRequest(self.team, "conversations.replies", + {"channel": self.identifier, "ts": self.parent_message.ts}, + channel=self.parent_message.channel) + self.eventrouter.receive(s) + + def main_message_keys_reversed(self): + return (message.ts for message in reversed(self.parent_message.submessages)) + + def send_message(self, message, subtype=None, request_dict_ext={}): + if subtype == 'me_message': + w.prnt("", "ERROR: /me is not supported in threads") + return w.WEECHAT_RC_ERROR + message = linkify_text(message, self.team) + dbg(message) + request = {"type": "message", "text": message, + "channel": self.parent_message.channel.identifier, + "thread_ts": str(self.parent_message.ts), + "user": self.team.myidentifier} + request.update(request_dict_ext) + self.team.send_to_websocket(request) + + def open(self, update_remote=True): + self.create_buffer() + self.active = True + self.get_history() + + def rename(self): + if self.channel_buffer and not self.label: + w.buffer_set(self.channel_buffer, "short_name", self.formatted_name(style="sidebar", enable_color=True)) + + def create_buffer(self): + """ + Creates the weechat buffer where the thread magic happens. + """ + if not self.channel_buffer: + self.channel_buffer = w.buffer_new(self.formatted_name(style="long_default"), "buffer_input_callback", "EVENTROUTER", "", "") + self.eventrouter.weechat_controller.register_buffer(self.channel_buffer, self) + w.buffer_set(self.channel_buffer, "localvar_set_type", 'channel') + w.buffer_set(self.channel_buffer, "localvar_set_nick", self.team.nick) + w.buffer_set(self.channel_buffer, "localvar_set_channel", self.formatted_name()) + w.buffer_set(self.channel_buffer, "localvar_set_server", self.team.preferred_name) + w.buffer_set(self.channel_buffer, "short_name", self.formatted_name(style="sidebar", enable_color=True)) + time_format = w.config_string(w.config_get("weechat.look.buffer_time_format")) + parent_time = time.localtime(SlackTS(self.parent_message.ts).major) + topic = '{} {} | {}'.format(time.strftime(time_format, parent_time), self.parent_message.sender, self.render(self.parent_message) ) + w.buffer_set(self.channel_buffer, "title", topic) + + # self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + + def destroy_buffer(self, update_remote): + self.channel_buffer = None + self.got_history = False + self.active = False + + def render(self, message, force=False): + return message.render(force) + + +class SlackUser(object): + """ + Represends an individual slack user. Also where you set their name formatting. + """ + + def __init__(self, originating_team_id, **kwargs): + self.identifier = kwargs["id"] + # These attributes may be missing in the response, so we have to make + # sure they're set + self.profile = {} + self.presence = kwargs.get("presence", "unknown") + self.deleted = kwargs.get("deleted", False) + self.is_external = (not kwargs.get("is_bot") and + kwargs.get("team_id") != originating_team_id) + for key, value in kwargs.items(): + setattr(self, key, value) + + self.name = nick_from_profile(self.profile, kwargs["name"]) + self.username = kwargs["name"] + self.update_color() + + def __repr__(self): + return "Name:{} Identifier:{}".format(self.name, self.identifier) + + def force_color(self, color_name): + self.color_name = color_name + + def update_color(self): + # This will automatically be none/"" if the user has disabled nick + # colourization. + self.color_name = get_nick_color(self.name) + + def update_status(self, status_emoji, status_text): + self.profile["status_emoji"] = status_emoji + self.profile["status_text"] = status_text + + def formatted_name(self, prepend="", enable_color=True): + name = prepend + self.name + if enable_color: + return colorize_string(self.color_name, name) + else: + return name + + +class SlackBot(SlackUser): + """ + Basically the same as a user, but split out to identify and for future + needs + """ + def __init__(self, originating_team_id, **kwargs): + super(SlackBot, self).__init__(originating_team_id, is_bot=True, **kwargs) + + +class SlackMessage(object): + """ + Represents a single slack message and associated context/metadata. + These are modifiable and can be rerendered to change a message, + delete a message, add a reaction, add a thread. + Note: these can't be tied to a SlackUser object because users + can be deleted, so we have to store sender in each one. + """ + def __init__(self, message_json, team, channel, override_sender=None): + self.team = team + self.channel = channel + self.message_json = message_json + self.submessages = [] + self.thread_channel = None + self.hash = None + if override_sender: + self.sender = override_sender + self.sender_plain = override_sender + else: + senders = self.get_sender() + self.sender, self.sender_plain = senders[0], senders[1] + self.ts = SlackTS(message_json['ts']) + + def __hash__(self): + return hash(self.ts) + + def open_thread(self, switch=False): + if not self.thread_channel or not self.thread_channel.active: + self.thread_channel = SlackThreadChannel(EVENTROUTER, self) + self.thread_channel.open() + if switch: + w.buffer_set(self.thread_channel.channel_buffer, "display", "1") + + def render(self, force=False): + # If we already have a rendered version in the object, just return that. + if not force and self.message_json.get("_rendered_text"): + return self.message_json["_rendered_text"] + + if "fallback" in self.message_json: + text = self.message_json["fallback"] + elif self.message_json.get("text"): + text = self.message_json["text"] + else: + text = "" + + if self.message_json.get('mrkdwn', True): + text = render_formatting(text) + + if (self.message_json.get('subtype') in ('channel_join', 'group_join') and + self.message_json.get('inviter')): + inviter_id = self.message_json.get('inviter') + text += " by invitation from <@{}>".format(inviter_id) + + if "blocks" in self.message_json: + text += unfurl_blocks(self.message_json) + + text = unfurl_refs(text) + + if (self.message_json.get('subtype') == 'me_message' and + not self.message_json['text'].startswith(self.sender)): + text = "{} {}".format(self.sender, text) + + if "edited" in self.message_json: + text += " " + colorize_string(config.color_edited_suffix, '(edited)') + + text += unfurl_refs(unwrap_attachments(self.message_json, text)) + text += unfurl_refs(unwrap_files(self.message_json, text)) + text = unhtmlescape(text.lstrip().replace("\t", " ")) + + text += create_reactions_string( + self.message_json.get("reactions", ""), self.team.myidentifier) + + if self.number_of_replies(): + self.channel.hash_message(self.ts) + text += " " + colorize_string(get_thread_color(self.hash), "[ Thread: {} Replies: {} ]".format( + self.hash, self.number_of_replies())) + + text = replace_string_with_emoji(text) + + self.message_json["_rendered_text"] = text + return text + + def change_text(self, new_text): + self.message_json["text"] = new_text + dbg(self.message_json) + + def get_sender(self): + name = "" + name_plain = "" + user = self.team.users.get(self.message_json.get('user')) + if user: + name = "{}".format(user.formatted_name()) + name_plain = "{}".format(user.formatted_name(enable_color=False)) + if user.is_external: + name += config.external_user_suffix + name_plain += config.external_user_suffix + elif 'username' in self.message_json: + username = self.message_json["username"] + if self.message_json.get("subtype") == "bot_message": + name = "{} :]".format(username) + name_plain = "{}".format(username) + else: + name = "-{}-".format(username) + name_plain = "{}".format(username) + elif 'service_name' in self.message_json: + name = "-{}-".format(self.message_json["service_name"]) + name_plain = "{}".format(self.message_json["service_name"]) + elif self.message_json.get('bot_id') in self.team.bots: + name = "{} :]".format(self.team.bots[self.message_json["bot_id"]].formatted_name()) + name_plain = "{}".format(self.team.bots[self.message_json["bot_id"]].formatted_name(enable_color=False)) + return (name, name_plain) + + def add_reaction(self, reaction, user): + m = self.message_json.get('reactions') + if m: + found = False + for r in m: + if r["name"] == reaction and user not in r["users"]: + r["users"].append(user) + found = True + if not found: + self.message_json["reactions"].append({"name": reaction, "users": [user]}) + else: + self.message_json["reactions"] = [{"name": reaction, "users": [user]}] + + def remove_reaction(self, reaction, user): + m = self.message_json.get('reactions') + if m: + for r in m: + if r["name"] == reaction and user in r["users"]: + r["users"].remove(user) + + def has_mention(self): + return w.string_has_highlight(unfurl_refs(self.message_json.get('text')), + ",".join(self.channel.highlights())) + + def number_of_replies(self): + return max(len(self.submessages), len(self.message_json.get("replies", []))) + + def notify_thread(self, action=None, sender_id=None): + if config.auto_open_threads: + self.open_thread() + elif sender_id != self.team.myidentifier: + if action == "mention": + template = "You were mentioned in thread {hash}, channel {channel}" + elif action == "participant": + template = "New message in thread {hash}, channel {channel} in which you participated" + elif action == "response": + template = "New message in thread {hash} in response to own message in {channel}" + else: + template = "Notification for message in thread {hash}, channel {channel}" + message = template.format(hash=self.hash, channel=self.channel.formatted_name()) + + self.team.buffer_prnt(message, message=True) + +class SlackThreadMessage(SlackMessage): + + def __init__(self, parent_message, *args): + super(SlackThreadMessage, self).__init__(*args) + self.parent_message = parent_message + + +class Hdata(object): + def __init__(self, w): + self.buffer = w.hdata_get('buffer') + self.line = w.hdata_get('line') + self.line_data = w.hdata_get('line_data') + self.lines = w.hdata_get('lines') + + +class SlackTS(object): + + def __init__(self, ts=None): + if ts: + self.major, self.minor = [int(x) for x in ts.split('.', 1)] + else: + self.major = int(time.time()) + self.minor = 0 + + def __cmp__(self, other): + if isinstance(other, SlackTS): + if self.major < other.major: + return -1 + elif self.major > other.major: + return 1 + elif self.major == other.major: + if self.minor < other.minor: + return -1 + elif self.minor > other.minor: + return 1 + else: + return 0 + else: + s = self.__str__() + if s < other: + return -1 + elif s > other: + return 1 + elif s == other: + return 0 + + def __lt__(self, other): + return self.__cmp__(other) < 0 + + def __le__(self, other): + return self.__cmp__(other) <= 0 + + def __eq__(self, other): + return self.__cmp__(other) == 0 + + def __ge__(self, other): + return self.__cmp__(other) >= 0 + + def __gt__(self, other): + return self.__cmp__(other) > 0 + + def __hash__(self): + return hash("{}.{}".format(self.major, self.minor)) + + def __repr__(self): + return str("{0}.{1:06d}".format(self.major, self.minor)) + + def split(self, *args, **kwargs): + return [self.major, self.minor] + + def majorstr(self): + return str(self.major) + + def minorstr(self): + return str(self.minor) + +###### New handlers + + +def handle_rtmstart(login_data, eventrouter, team, channel, metadata): + """ + This handles the main entry call to slack, rtm.start + """ + metadata = login_data["wee_slack_request_metadata"] + + if not login_data["ok"]: + w.prnt("", "ERROR: Failed connecting to Slack with token starting with {}: {}" + .format(metadata.token[:15], login_data["error"])) + if not re.match(r"^xo\w\w(-\d+){3}-[0-9a-f]+$", metadata.token): + w.prnt("", "ERROR: Token does not look like a valid Slack token. " + "Ensure it is a valid token and not just a OAuth code.") + + return + + # Let's reuse a team if we have it already. + th = SlackTeam.generate_team_hash(login_data['self']['name'], login_data['team']['domain']) + if not eventrouter.teams.get(th): + + users = {} + for item in login_data["users"]: + users[item["id"]] = SlackUser(login_data['team']['id'], **item) + + bots = {} + for item in login_data["bots"]: + bots[item["id"]] = SlackBot(login_data['team']['id'], **item) + + subteams = {} + for item in login_data["subteams"]["all"]: + is_member = item['id'] in login_data["subteams"]["self"] + subteams[item['id']] = SlackSubteam( + login_data['team']['id'], is_member=is_member, **item) + + channels = {} + for item in login_data["channels"]: + if item["is_shared"]: + channels[item["id"]] = SlackSharedChannel(eventrouter, **item) + elif item["is_private"]: + channels[item["id"]] = SlackPrivateChannel(eventrouter, **item) + else: + channels[item["id"]] = SlackChannel(eventrouter, **item) + + for item in login_data["ims"]: + channels[item["id"]] = SlackDMChannel(eventrouter, users, **item) + + for item in login_data["groups"]: + if item["is_mpim"]: + channels[item["id"]] = SlackMPDMChannel(eventrouter, users, login_data["self"]["id"], **item) + else: + channels[item["id"]] = SlackGroupChannel(eventrouter, **item) + + self_profile = next( + user["profile"] + for user in login_data["users"] + if user["id"] == login_data["self"]["id"] + ) + self_nick = nick_from_profile(self_profile, login_data["self"]["name"]) + + t = SlackTeam( + eventrouter, + metadata.token, + login_data['url'], + login_data["team"], + subteams, + self_nick, + login_data["self"]["id"], + login_data["self"]["manual_presence"], + users, + bots, + channels, + muted_channels=login_data["self"]["prefs"]["muted_channels"], + highlight_words=login_data["self"]["prefs"]["highlight_words"], + ) + eventrouter.register_team(t) + + else: + t = eventrouter.teams.get(th) + t.set_reconnect_url(login_data['url']) + t.connecting_rtm = False + + t.connect() + +def handle_rtmconnect(login_data, eventrouter, team, channel, metadata): + metadata = login_data["wee_slack_request_metadata"] + team = metadata.team + team.connecting_rtm = False + + if not login_data["ok"]: + w.prnt("", "ERROR: Failed reconnecting to Slack with token starting with {}: {}" + .format(metadata.token[:15], login_data["error"])) + return + + team.set_reconnect_url(login_data['url']) + team.connect() + + +def handle_emojilist(emoji_json, eventrouter, team, channel, metadata): + if emoji_json["ok"]: + team.emoji_completions.extend(emoji_json["emoji"].keys()) + + +def handle_channelsinfo(channel_json, eventrouter, team, channel, metadata): + channel.set_unread_count_display(channel_json['channel'].get('unread_count_display', 0)) + channel.set_members(channel_json['channel']['members']) + + +def handle_groupsinfo(group_json, eventrouter, team, channel, metadatas): + channel.set_unread_count_display(group_json['group'].get('unread_count_display', 0)) + + +def handle_conversationsopen(conversation_json, eventrouter, team, channel, metadata, object_name='channel'): + # Set unread count if the channel isn't new + if channel: + unread_count_display = conversation_json[object_name].get('unread_count_display', 0) + channel.set_unread_count_display(unread_count_display) + + +def handle_mpimopen(mpim_json, eventrouter, team, channel, metadata, object_name='group'): + handle_conversationsopen(mpim_json, eventrouter, team, channel, metadata, object_name) + + +def handle_history(message_json, eventrouter, team, channel, metadata): + if metadata['clear']: + channel.clear_messages() + channel.got_history = True + for message in reversed(message_json["messages"]): + process_message(message, eventrouter, team, channel, metadata, history_message=True) + + +handle_channelshistory = handle_history +handle_conversationshistory = handle_history +handle_groupshistory = handle_history +handle_imhistory = handle_history +handle_mpimhistory = handle_history + + +def handle_conversationsreplies(message_json, eventrouter, team, channel, metadata): + for message in message_json['messages']: + process_message(message, eventrouter, team, channel, metadata) + + +def handle_conversationsmembers(members_json, eventrouter, team, channel, metadata): + if members_json['ok']: + channel.set_members(members_json['members']) + else: + w.prnt(team.channel_buffer, '{}Couldn\'t load members for channel {}. Error: {}' + .format(w.prefix('error'), channel.name, members_json['error'])) + + +def handle_usersinfo(user_json, eventrouter, team, channel, metadata): + user_info = user_json['user'] + if not metadata.get('user'): + user = SlackUser(team.identifier, **user_info) + team.users[user_info['id']] = user + + if channel.type == 'shared': + channel.update_nicklist(user_info['id']) + elif channel.type == 'im': + channel.slack_name = user.name + channel.set_topic(create_user_status_string(user.profile)) + + +def handle_usergroupsuserslist(users_json, eventrouter, team, channel, metadata): + header = 'Users in {}'.format(metadata['usergroup_handle']) + users = [team.users[key] for key in users_json['users']] + return print_users_info(team, header, users) + + +def handle_usersprofileset(json, eventrouter, team, channel, metadata): + if not json['ok']: + w.prnt('', 'ERROR: Failed to set profile: {}'.format(json['error'])) + + +def handle_conversationsinvite(json, eventrouter, team, channel, metadata): + nicks = ', '.join(metadata['nicks']) + if json['ok']: + w.prnt(team.channel_buffer, 'Invited {} to {}'.format(nicks, channel.name)) + else: + w.prnt(team.channel_buffer, 'ERROR: Couldn\'t invite {} to {}. Error: {}' + .format(nicks, channel.name, json['error'])) + + +def handle_chatcommand(json, eventrouter, team, channel, metadata): + command = '{} {}'.format(metadata['command'], metadata['command_args']).rstrip() + response = unfurl_refs(json['response']) if 'response' in json else '' + if json['ok']: + response_text = 'Response: {}'.format(response) if response else 'No response' + w.prnt(team.channel_buffer, 'Ran command "{}". {}' .format(command, response_text)) + else: + response_text = '. Response: {}'.format(response) if response else '' + w.prnt(team.channel_buffer, 'ERROR: Couldn\'t run command "{}". Error: {}{}' + .format(command, json['error'], response_text)) + + +def handle_reactionsadd(json, eventrouter, team, channel, metadata): + if not json['ok']: + print_error("Couldn't add reaction {}: {}".format(metadata['reaction'], json['error'])) + + +def handle_reactionsremove(json, eventrouter, team, channel, metadata): + if not json['ok']: + print_error("Couldn't remove reaction {}: {}".format(metadata['reaction'], json['error'])) + + +###### New/converted process_ and subprocess_ methods +def process_hello(message_json, eventrouter, team, channel, metadata): + team.subscribe_users_presence() + + +def process_reconnect_url(message_json, eventrouter, team, channel, metadata): + team.set_reconnect_url(message_json['url']) + + +def process_presence_change(message_json, eventrouter, team, channel, metadata): + users = [team.users[user_id] for user_id in message_json.get("users", [])] + if "user" in metadata: + users.append(metadata["user"]) + for user in users: + team.update_member_presence(user, message_json["presence"]) + if team.myidentifier in users: + w.bar_item_update("away") + w.bar_item_update("slack_away") + + +def process_manual_presence_change(message_json, eventrouter, team, channel, metadata): + team.my_manual_presence = message_json["presence"] + w.bar_item_update("away") + w.bar_item_update("slack_away") + + +def process_pref_change(message_json, eventrouter, team, channel, metadata): + if message_json['name'] == 'muted_channels': + team.set_muted_channels(message_json['value']) + elif message_json['name'] == 'highlight_words': + team.set_highlight_words(message_json['value']) + else: + dbg("Preference change not implemented: {}\n".format(message_json['name'])) + + +def process_user_change(message_json, eventrouter, team, channel, metadata): + """ + Currently only used to update status, but lots here we could do. + """ + user = metadata['user'] + profile = message_json['user']['profile'] + if user: + user.update_status(profile.get('status_emoji'), profile.get('status_text')) + dmchannel = team.find_channel_by_members({user.identifier}, channel_type='im') + if dmchannel: + dmchannel.set_topic(create_user_status_string(profile)) + + +def process_user_typing(message_json, eventrouter, team, channel, metadata): + if channel: + channel.set_typing(metadata["user"].name) + w.bar_item_update("slack_typing_notice") + + +def process_team_join(message_json, eventrouter, team, channel, metadata): + user = message_json['user'] + team.users[user["id"]] = SlackUser(team.identifier, **user) + + +def process_pong(message_json, eventrouter, team, channel, metadata): + team.last_pong_time = time.time() + + +def process_message(message_json, eventrouter, team, channel, metadata, history_message=False): + if SlackTS(message_json["ts"]) in channel.messages: + return + + if "thread_ts" in message_json and "reply_count" not in message_json and "subtype" not in message_json: + if message_json.get("reply_broadcast"): + message_json["subtype"] = "thread_broadcast" + else: + message_json["subtype"] = "thread_message" + + subtype = message_json.get("subtype") + subtype_functions = get_functions_with_prefix("subprocess_") + + if subtype in subtype_functions: + subtype_functions[subtype](message_json, eventrouter, team, channel, history_message) + else: + message = SlackMessage(message_json, team, channel) + channel.store_message(message, team) + + text = channel.render(message) + dbg("Rendered message: %s" % text) + dbg("Sender: %s (%s)" % (message.sender, message.sender_plain)) + + if subtype == 'me_message': + prefix = w.prefix("action").rstrip() + else: + prefix = message.sender + + channel.buffer_prnt(prefix, text, message.ts, tag_nick=message.sender_plain, history_message=history_message) + channel.unread_count_display += 1 + dbg("NORMAL REPLY {}".format(message_json)) + + if not history_message: + download_files(message_json, team) + + +def download_files(message_json, team): + download_location = config.files_download_location + if not download_location: + return + download_location = w.string_eval_path_home(download_location, {}, {}, {}) + + if not os.path.exists(download_location): + try: + os.makedirs(download_location) + except: + w.prnt('', 'ERROR: Failed to create directory at files_download_location: {}' + .format(format_exc_only())) + + def fileout_iter(path): + yield path + main, ext = os.path.splitext(path) + for i in count(start=1): + yield main + "-{}".format(i) + ext + + for f in message_json.get('files', []): + if f.get('mode') == 'tombstone': + continue + + filetype = '' if f['title'].endswith(f['filetype']) else '.' + f['filetype'] + filename = '{}_{}{}'.format(team.preferred_name, f['title'], filetype) + for fileout in fileout_iter(os.path.join(download_location, filename)): + if os.path.isfile(fileout): + continue + w.hook_process_hashtable( + "url:" + f['url_private'], + { + 'file_out': fileout, + 'httpheader': 'Authorization: Bearer ' + team.token + }, + config.slack_timeout, "", "") + break + + +def subprocess_thread_message(message_json, eventrouter, team, channel, history_message): + parent_ts = message_json.get('thread_ts') + if parent_ts: + parent_message = channel.messages.get(SlackTS(parent_ts)) + if parent_message: + message = SlackThreadMessage( + parent_message, message_json, team, channel) + parent_message.submessages.append(message) + channel.hash_message(parent_ts) + channel.store_message(message, team) + channel.change_message(parent_ts) + + if parent_message.thread_channel and parent_message.thread_channel.active: + parent_message.thread_channel.buffer_prnt(message.sender, parent_message.thread_channel.render(message), message.ts, tag_nick=message.sender_plain) + elif message.ts > channel.last_read and message.has_mention(): + parent_message.notify_thread(action="mention", sender_id=message_json["user"]) + + if config.thread_messages_in_channel or message_json["subtype"] == "thread_broadcast": + thread_tag = "thread_broadcast" if message_json["subtype"] == "thread_broadcast" else "thread_message" + channel.buffer_prnt( + message.sender, + channel.render(message), + message.ts, + tag_nick=message.sender_plain, + history_message=history_message, + extra_tags=[thread_tag], + ) + + +subprocess_thread_broadcast = subprocess_thread_message + + +def subprocess_channel_join(message_json, eventrouter, team, channel, history_message): + prefix_join = w.prefix("join").strip() + message = SlackMessage(message_json, team, channel, override_sender=prefix_join) + channel.buffer_prnt(prefix_join, channel.render(message), message_json["ts"], tagset='join', tag_nick=message.get_sender()[1], history_message=history_message) + channel.user_joined(message_json['user']) + channel.store_message(message, team) + + +def subprocess_channel_leave(message_json, eventrouter, team, channel, history_message): + prefix_leave = w.prefix("quit").strip() + message = SlackMessage(message_json, team, channel, override_sender=prefix_leave) + channel.buffer_prnt(prefix_leave, channel.render(message), message_json["ts"], tagset='leave', tag_nick=message.get_sender()[1], history_message=history_message) + channel.user_left(message_json['user']) + channel.store_message(message, team) + + +def subprocess_channel_topic(message_json, eventrouter, team, channel, history_message): + prefix_topic = w.prefix("network").strip() + message = SlackMessage(message_json, team, channel, override_sender=prefix_topic) + channel.buffer_prnt(prefix_topic, channel.render(message), message_json["ts"], tagset="topic", tag_nick=message.get_sender()[1], history_message=history_message) + channel.set_topic(message_json["topic"]) + channel.store_message(message, team) + + +subprocess_group_join = subprocess_channel_join +subprocess_group_leave = subprocess_channel_leave +subprocess_group_topic = subprocess_channel_topic + + +def subprocess_message_replied(message_json, eventrouter, team, channel, history_message): + parent_ts = message_json["message"].get("thread_ts") + parent_message = channel.messages.get(SlackTS(parent_ts)) + # Thread exists but is not open yet + if parent_message is not None \ + and not (parent_message.thread_channel and parent_message.thread_channel.active): + channel.hash_message(parent_ts) + last_message = max(message_json["message"]["replies"], key=lambda x: x["ts"]) + if message_json["message"].get("user") == team.myidentifier: + parent_message.notify_thread(action="response", sender_id=last_message["user"]) + elif any(team.myidentifier == r["user"] for r in message_json["message"]["replies"]): + parent_message.notify_thread(action="participant", sender_id=last_message["user"]) + + +def subprocess_message_changed(message_json, eventrouter, team, channel, history_message): + new_message = message_json.get("message") + channel.change_message(new_message["ts"], message_json=new_message) + + +def subprocess_message_deleted(message_json, eventrouter, team, channel, history_message): + message = colorize_string(config.color_deleted, '(deleted)') + channel.change_message(message_json["deleted_ts"], text=message) + + +def process_reply(message_json, eventrouter, team, channel, metadata): + reply_to = int(message_json["reply_to"]) + original_message_json = team.ws_replies.pop(reply_to, None) + if original_message_json: + original_message_json.update(message_json) + channel = team.channels[original_message_json.get('channel')] + process_message(original_message_json, eventrouter, team=team, channel=channel, metadata={}) + dbg("REPLY {}".format(message_json)) + else: + dbg("Unexpected reply {}".format(message_json)) + + +def process_channel_marked(message_json, eventrouter, team, channel, metadata): + ts = message_json.get("ts") + if ts: + channel.mark_read(ts=ts, force=True, update_remote=False) + else: + dbg("tried to mark something weird {}".format(message_json)) + + +process_group_marked = process_channel_marked +process_im_marked = process_channel_marked +process_mpim_marked = process_channel_marked + + +def process_channel_joined(message_json, eventrouter, team, channel, metadata): + channel.update_from_message_json(message_json["channel"]) + channel.open() + + +def process_channel_created(message_json, eventrouter, team, channel, metadata): + item = message_json["channel"] + item['is_member'] = False + channel = SlackChannel(eventrouter, team=team, **item) + team.channels[item["id"]] = channel + team.buffer_prnt('Channel created: {}'.format(channel.slack_name)) + + +def process_channel_rename(message_json, eventrouter, team, channel, metadata): + channel.slack_name = message_json['channel']['name'] + + +def process_im_created(message_json, eventrouter, team, channel, metadata): + item = message_json["channel"] + channel = SlackDMChannel(eventrouter, team=team, users=team.users, **item) + team.channels[item["id"]] = channel + team.buffer_prnt('IM channel created: {}'.format(channel.name)) + + +def process_im_open(message_json, eventrouter, team, channel, metadata): + channel.check_should_open(True) + w.buffer_set(channel.channel_buffer, "hotlist", "2") + + +def process_im_close(message_json, eventrouter, team, channel, metadata): + if channel.channel_buffer: + w.prnt(team.channel_buffer, + 'IM {} closed by another client or the server'.format(channel.name)) + eventrouter.weechat_controller.unregister_buffer(channel.channel_buffer, False, True) + + +def process_group_joined(message_json, eventrouter, team, channel, metadata): + item = message_json["channel"] + if item["name"].startswith("mpdm-"): + channel = SlackMPDMChannel(eventrouter, team.users, team.myidentifier, team=team, **item) + else: + channel = SlackGroupChannel(eventrouter, team=team, **item) + team.channels[item["id"]] = channel + channel.open() + + +def process_reaction_added(message_json, eventrouter, team, channel, metadata): + channel = team.channels.get(message_json["item"].get("channel")) + if message_json["item"].get("type") == "message": + ts = SlackTS(message_json['item']["ts"]) + + message = channel.messages.get(ts) + if message: + message.add_reaction(message_json["reaction"], message_json["user"]) + channel.change_message(ts) + else: + dbg("reaction to item type not supported: " + str(message_json)) + + +def process_reaction_removed(message_json, eventrouter, team, channel, metadata): + channel = team.channels.get(message_json["item"].get("channel")) + if message_json["item"].get("type") == "message": + ts = SlackTS(message_json['item']["ts"]) + + message = channel.messages.get(ts) + if message: + message.remove_reaction(message_json["reaction"], message_json["user"]) + channel.change_message(ts) + else: + dbg("Reaction to item type not supported: " + str(message_json)) + + +def process_subteam_created(subteam_json, eventrouter, team, channel, metadata): + subteam_json_info = subteam_json['subteam'] + is_member = team.myidentifier in subteam_json_info.get('users', []) + subteam = SlackSubteam(team.identifier, is_member=is_member, **subteam_json_info) + team.subteams[subteam_json_info['id']] = subteam + + +def process_subteam_updated(subteam_json, eventrouter, team, channel, metadata): + current_subteam_info = team.subteams[subteam_json['subteam']['id']] + is_member = team.myidentifier in subteam_json['subteam'].get('users', []) + new_subteam_info = SlackSubteam(team.identifier, is_member=is_member, **subteam_json['subteam']) + team.subteams[subteam_json['subteam']['id']] = new_subteam_info + + if current_subteam_info.is_member != new_subteam_info.is_member: + for channel in team.channels.values(): + channel.set_highlights() + + if config.notify_usergroup_handle_updated and current_subteam_info.handle != new_subteam_info.handle: + message = 'User group {old_handle} has updated its handle to {new_handle} in team {team}.'.format( + name=current_subteam_info.handle, handle=new_subteam_info.handle, team=team.preferred_name) + team.buffer_prnt(message, message=True) + + +def process_emoji_changed(message_json, eventrouter, team, channel, metadata): + team.load_emoji_completions() + + +###### New module/global methods +def render_formatting(text): + text = re.sub(r'(^| )\*([^*\n`]+)\*(?=[^\w]|$)', + r'\1{}*\2*{}'.format(w.color(config.render_bold_as), + w.color('-' + config.render_bold_as)), + text, + flags=re.UNICODE) + text = re.sub(r'(^| )_([^_\n`]+)_(?=[^\w]|$)', + r'\1{}_\2_{}'.format(w.color(config.render_italic_as), + w.color('-' + config.render_italic_as)), + text, + flags=re.UNICODE) + return text + + +def linkify_text(message, team, only_users=False): + # The get_username_map function is a bit heavy, but this whole + # function is only called on message send.. + usernames = team.get_username_map() + channels = team.get_channel_map() + usergroups = team.generate_usergroup_map() + message_escaped = (message + # Replace IRC formatting chars with Slack formatting chars. + .replace('\x02', '*') + .replace('\x1D', '_') + .replace('\x1F', config.map_underline_to) + # Escape chars that have special meaning to Slack. Note that we do not + # (and should not) perform full HTML entity-encoding here. + # See https://api.slack.com/docs/message-formatting for details. + .replace('&', '&') + .replace('<', '<') + .replace('>', '>')) + + def linkify_word(match): + word = match.group(0) + prefix, name = match.groups() + if prefix == "@": + if name in ["channel", "everyone", "group", "here"]: + return "<!{}>".format(name) + elif name in usernames: + return "<@{}>".format(usernames[name]) + elif word in usergroups.keys(): + return "<!subteam^{}|{}>".format(usergroups[word], word) + elif prefix == "#" and not only_users: + if word in channels: + return "<#{}|{}>".format(channels[word], name) + return word + + linkify_regex = r'(?:^|(?<=\s))([@#])([\w\(\)\'.-]+)' + return re.sub(linkify_regex, linkify_word, message_escaped, flags=re.UNICODE) + + +def unfurl_blocks(message_json): + block_text = [""] + for block in message_json["blocks"]: + try: + if block["type"] == "section": + fields = block.get("fields", []) + if "text" in block: + fields.insert(0, block["text"]) + block_text.extend(unfurl_block_element(field) for field in fields) + elif block["type"] == "actions": + elements = [] + for element in block["elements"]: + if element["type"] == "button": + elements.append(unfurl_block_element(element["text"])) + else: + elements.append(colorize_string(config.color_deleted, + '<<Unsupported block action type "{}">>'.format(element["type"]))) + block_text.append(" | ".join(elements)) + elif block["type"] == "call": + block_text.append("Join via " + block["call"]["v1"]["join_url"]) + elif block["type"] == "divider": + block_text.append("---") + elif block["type"] == "context": + block_text.append(" | ".join(unfurl_block_element(el) for el in block["elements"])) + elif block["type"] == "image": + if "title" in block: + block_text.append(unfurl_block_element(block["title"])) + block_text.append(unfurl_block_element(block)) + elif block["type"] == "rich_text": + continue + else: + block_text.append(colorize_string(config.color_deleted, + '<<Unsupported block type "{}">>'.format(block["type"]))) + dbg('Unsupported block: "{}"'.format(json.dumps(block)), level=4) + except Exception as e: + dbg("Failed to unfurl block ({}): {}".format(repr(e), json.dumps(block)), level=4) + return "\n".join(block_text) + + +def unfurl_block_element(text): + if text["type"] == "mrkdwn": + return render_formatting(text["text"]) + elif text["type"] == "plain_text": + return text["text"] + elif text["type"] == "image": + return "{} ({})".format(text["image_url"], text["alt_text"]) + + +def unfurl_refs(text): + """ + input : <@U096Q7CQM|someuser> has joined the channel + ouput : someuser has joined the channel + """ + # Find all strings enclosed by <> + # - <https://example.com|example with spaces> + # - <#C2147483705|#otherchannel> + # - <@U2147483697|@othernick> + # - <!subteam^U2147483697|@group> + # Test patterns lives in ./_pytest/test_unfurl.py + + def unfurl_ref(match): + ref, fallback = match.groups() + + resolved_ref = resolve_ref(ref) + if resolved_ref != ref: + return resolved_ref + + if fallback and not config.unfurl_ignore_alt_text: + if ref.startswith("#"): + return "#{}".format(fallback) + elif ref.startswith("@"): + return fallback + elif ref.startswith("!subteam"): + prefix = "@" if not fallback.startswith("@") else "" + return prefix + fallback + elif ref.startswith("!date"): + return fallback + else: + match_url = r"^\w+:(//)?{}$".format(re.escape(fallback)) + url_matches_desc = re.match(match_url, ref) + if url_matches_desc and config.unfurl_auto_link_display == "text": + return fallback + elif url_matches_desc and config.unfurl_auto_link_display == "url": + return ref + else: + return "{} ({})".format(ref, fallback) + return ref + + return re.sub(r"<([^|>]*)(?:\|([^>]*))?>", unfurl_ref, text) + + +def unhtmlescape(text): + return text.replace("<", "<") \ + .replace(">", ">") \ + .replace("&", "&") + + +def unwrap_attachments(message_json, text_before): + text_before_unescaped = unhtmlescape(text_before) + attachment_texts = [] + a = message_json.get("attachments") + if a: + if text_before: + attachment_texts.append('') + for attachment in a: + # Attachments should be rendered roughly like: + # + # $pretext + # $author: (if rest of line is non-empty) $title ($title_link) OR $from_url + # $author: (if no $author on previous line) $text + # $fields + t = [] + prepend_title_text = '' + if 'author_name' in attachment: + prepend_title_text = attachment['author_name'] + ": " + if 'pretext' in attachment: + t.append(attachment['pretext']) + title = attachment.get('title') + title_link = attachment.get('title_link', '') + if title_link in text_before_unescaped: + title_link = '' + if title and title_link: + t.append('%s%s (%s)' % (prepend_title_text, title, title_link,)) + prepend_title_text = '' + elif title and not title_link: + t.append('%s%s' % (prepend_title_text, title,)) + prepend_title_text = '' + from_url = attachment.get('from_url', '') + if from_url not in text_before_unescaped and from_url != title_link: + t.append(from_url) + + atext = attachment.get("text") + if atext: + tx = re.sub(r' *\n[\n ]+', '\n', atext) + t.append(prepend_title_text + tx) + prepend_title_text = '' + + image_url = attachment.get('image_url', '') + if image_url not in text_before_unescaped and image_url != title_link: + t.append(image_url) + + fields = attachment.get("fields") + if fields: + for f in fields: + if f.get('title'): + t.append('%s %s' % (f['title'], f['value'],)) + else: + t.append(f['value']) + fallback = attachment.get("fallback") + if t == [] and fallback: + t.append(fallback) + attachment_texts.append("\n".join([x.strip() for x in t if x])) + return "\n".join(attachment_texts) + + +def unwrap_files(message_json, text_before): + files_texts = [] + for f in message_json.get('files', []): + if f.get('mode', '') != 'tombstone': + text = '{} ({})'.format(f['url_private'], f['title']) + else: + text = colorize_string(config.color_deleted, '(This file was deleted.)') + files_texts.append(text) + + if text_before: + files_texts.insert(0, '') + return "\n".join(files_texts) + + +def resolve_ref(ref): + if ref in ['!channel', '!everyone', '!group', '!here']: + return ref.replace('!', '@') + for team in EVENTROUTER.teams.values(): + if ref.startswith('@'): + user = team.users.get(ref[1:]) + if user: + suffix = config.external_user_suffix if user.is_external else '' + return '@{}{}'.format(user.name, suffix) + elif ref.startswith('#'): + channel = team.channels.get(ref[1:]) + if channel: + return channel.name + elif ref.startswith('!subteam'): + _, subteam_id = ref.split('^') + subteam = team.subteams.get(subteam_id) + if subteam: + return subteam.handle + elif ref.startswith("!date"): + parts = ref.split('^') + ref_datetime = datetime.fromtimestamp(int(parts[1])) + link_suffix = ' ({})'.format(parts[3]) if len(parts) > 3 else '' + token_to_format = { + 'date_num': '%Y-%m-%d', + 'date': '%B %d, %Y', + 'date_short': '%b %d, %Y', + 'date_long': '%A, %B %d, %Y', + 'time': '%H:%M', + 'time_secs': '%H:%M:%S' + } + + def replace_token(match): + token = match.group(1) + if token.startswith('date_') and token.endswith('_pretty'): + if ref_datetime.date() == date.today(): + return 'today' + elif ref_datetime.date() == date.today() - timedelta(days=1): + return 'yesterday' + elif ref_datetime.date() == date.today() + timedelta(days=1): + return 'tomorrow' + else: + token = token.replace('_pretty', '') + if token in token_to_format: + return ref_datetime.strftime(token_to_format[token]) + else: + return match.group(0) + + return re.sub(r"{([^}]+)}", replace_token, parts[2]) + link_suffix + + # Something else, just return as-is + return ref + + +def create_user_status_string(profile): + real_name = profile.get("real_name") + status_emoji = replace_string_with_emoji(profile.get("status_emoji", "")) + status_text = profile.get("status_text") + if status_emoji or status_text: + return "{} | {} {}".format(real_name, status_emoji, status_text) + else: + return real_name + + +def create_reaction_string(reaction, myidentifier): + if config.show_reaction_nicks: + nicks = [resolve_ref('@{}'.format(user)) for user in reaction['users']] + users = '({})'.format(','.join(nicks)) + else: + users = len(reaction['users']) + reaction_string = ':{}:{}'.format(reaction['name'], users) + if myidentifier in reaction['users']: + return colorize_string(config.color_reaction_suffix_added_by_you, reaction_string, + reset_color=config.color_reaction_suffix) + else: + return reaction_string + + +def create_reactions_string(reactions, myidentifier): + reactions_with_users = [r for r in reactions if len(r['users']) > 0] + reactions_string = ' '.join(create_reaction_string(r, myidentifier) for r in reactions_with_users) + if reactions_string: + return ' ' + colorize_string(config.color_reaction_suffix, '[{}]'.format(reactions_string)) + else: + return '' + + +def hdata_line_ts(line_pointer): + data = w.hdata_pointer(hdata.line, line_pointer, 'data') + ts_major = w.hdata_time(hdata.line_data, data, 'date') + ts_minor = w.hdata_time(hdata.line_data, data, 'date_printed') + return (ts_major, ts_minor) + + +def modify_buffer_line(buffer_pointer, ts, new_text): + own_lines = w.hdata_pointer(hdata.buffer, buffer_pointer, 'own_lines') + line_pointer = w.hdata_pointer(hdata.lines, own_lines, 'last_line') + + # Find the last line with this ts + while line_pointer and hdata_line_ts(line_pointer) != (ts.major, ts.minor): + line_pointer = w.hdata_move(hdata.line, line_pointer, -1) + + # Find all lines for the message + pointers = [] + while line_pointer and hdata_line_ts(line_pointer) == (ts.major, ts.minor): + pointers.append(line_pointer) + line_pointer = w.hdata_move(hdata.line, line_pointer, -1) + pointers.reverse() + + # Split the message into at most the number of existing lines as we can't insert new lines + lines = new_text.split('\n', len(pointers) - 1) + # Replace newlines to prevent garbled lines in bare display mode + lines = [line.replace('\n', ' | ') for line in lines] + # Extend lines in case the new message is shorter than the old as we can't delete lines + lines += [''] * (len(pointers) - len(lines)) + + for pointer, line in zip(pointers, lines): + data = w.hdata_pointer(hdata.line, pointer, 'data') + w.hdata_update(hdata.line_data, data, {"message": line}) + + return w.WEECHAT_RC_OK + + +def modify_last_print_time(buffer_pointer, ts_minor): + """ + This overloads the time printed field to let us store the slack + per message unique id that comes after the "." in a slack ts + """ + own_lines = w.hdata_pointer(hdata.buffer, buffer_pointer, 'own_lines') + line_pointer = w.hdata_pointer(hdata.lines, own_lines, 'last_line') + + while line_pointer: + data = w.hdata_pointer(hdata.line, line_pointer, 'data') + w.hdata_update(hdata.line_data, data, {"date_printed": str(ts_minor)}) + + if w.hdata_string(hdata.line_data, data, 'prefix'): + # Reached the first line of the message, so stop here + break + + # Move one line backwards so all lines of the message are set + line_pointer = w.hdata_move(hdata.line, line_pointer, -1) + + return w.WEECHAT_RC_OK + + +def nick_from_profile(profile, username): + full_name = profile.get('real_name') or username + if config.use_full_names: + nick = full_name + else: + nick = profile.get('display_name') or full_name + return nick.replace(' ', '') + + +def format_nick(nick, previous_nick=None): + if nick == previous_nick: + nick = w.config_string(w.config_get('weechat.look.prefix_same_nick')) or nick + nick_prefix = w.config_string(w.config_get('weechat.look.nick_prefix')) + nick_prefix_color_name = w.config_string(w.config_get('weechat.color.chat_nick_prefix')) + + nick_suffix = w.config_string(w.config_get('weechat.look.nick_suffix')) + nick_suffix_color_name = w.config_string(w.config_get('weechat.color.chat_nick_prefix')) + return colorize_string(nick_prefix_color_name, nick_prefix) + nick + colorize_string(nick_suffix_color_name, nick_suffix) + + +def tag(tagset=None, user=None, self_msg=False, backlog=False, no_log=False, extra_tags=None): + tagsets = { + "team_info": {"no_highlight", "log3"}, + "team_message": {"irc_privmsg", "notify_message", "log1"}, + "dm": {"irc_privmsg", "notify_private", "log1"}, + "join": {"irc_join", "no_highlight", "log4"}, + "leave": {"irc_part", "no_highlight", "log4"}, + "topic": {"irc_topic", "no_highlight", "log3"}, + "channel": {"irc_privmsg", "notify_message", "log1"}, + } + nick_tag = {"nick_{}".format(user).replace(" ", "_")} if user else set() + slack_tag = {"slack_{}".format(tagset or "default")} + tags = nick_tag | slack_tag | tagsets.get(tagset, set()) + if self_msg or backlog: + tags -= {"notify_highlight", "notify_message", "notify_private"} + tags |= {"notify_none", "no_highlight"} + if self_msg: + tags |= {"self_msg"} + if backlog: + tags |= {"logger_backlog"} + if no_log: + tags |= {"no_log"} + tags = {tag for tag in tags if not tag.startswith("log")} + if extra_tags: + tags |= set(extra_tags) + return ",".join(tags) + + +def set_own_presence_active(team): + slackbot = team.get_channel_map()['Slackbot'] + channel = team.channels[slackbot] + request = {"type": "typing", "channel": channel.identifier} + channel.team.send_to_websocket(request, expect_reply=False) + + +###### New/converted command_ commands + + +@slack_buffer_or_ignore +@utf8_decode +def invite_command_cb(data, current_buffer, args): + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + split_args = args.split()[1:] + if not split_args: + w.prnt('', 'Too few arguments for command "/invite" (help on command: /help invite)') + return w.WEECHAT_RC_OK_EAT + + if split_args[-1].startswith("#") or split_args[-1].startswith(config.group_name_prefix): + nicks = split_args[:-1] + channel = team.channels.get(team.get_channel_map().get(split_args[-1])) + if not nicks or not channel: + w.prnt('', '{}: No such nick/channel'.format(split_args[-1])) + return w.WEECHAT_RC_OK_EAT + else: + nicks = split_args + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + + all_users = team.get_username_map() + users = set() + for nick in nicks: + user = all_users.get(nick.lstrip('@')) + if not user: + w.prnt('', 'ERROR: Unknown user: {}'.format(nick)) + return w.WEECHAT_RC_OK_EAT + users.add(user) + + s = SlackRequest(team, "conversations.invite", {"channel": channel.identifier, "users": ",".join(users)}, + channel=channel, metadata={"nicks": nicks}) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_or_ignore +@utf8_decode +def part_command_cb(data, current_buffer, args): + e = EVENTROUTER + args = args.split() + if len(args) > 1: + team = e.weechat_controller.buffers[current_buffer].team + cmap = team.get_channel_map() + channel = "".join(args[1:]) + if channel in cmap: + buffer_ptr = team.channels[cmap[channel]].channel_buffer + e.weechat_controller.unregister_buffer(buffer_ptr, update_remote=True, close_buffer=True) + else: + w.prnt(team.channel_buffer, "{}: No such channel".format(channel)) + else: + e.weechat_controller.unregister_buffer(current_buffer, update_remote=True, close_buffer=True) + return w.WEECHAT_RC_OK_EAT + + +def parse_topic_command(command): + args = command.split()[1:] + channel_name = None + topic = None + + if args: + if args[0].startswith('#'): + channel_name = args[0] + topic = args[1:] + else: + topic = args + + if topic == []: + topic = None + if topic: + topic = ' '.join(topic) + if topic == '-delete': + topic = '' + + return channel_name, topic + + +@slack_buffer_or_ignore +@utf8_decode +def topic_command_cb(data, current_buffer, command): + """ + Change the topic of a channel + /topic [<channel>] [<topic>|-delete] + """ + channel_name, topic = parse_topic_command(command) + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + + if channel_name: + channel = team.channels.get(team.get_channel_map().get(channel_name)) + else: + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + + if not channel: + w.prnt(team.channel_buffer, "{}: No such channel".format(channel_name)) + return w.WEECHAT_RC_OK_EAT + + if topic is None: + w.prnt(channel.channel_buffer, + 'Topic for {} is "{}"'.format(channel.name, channel.render_topic())) + else: + s = SlackRequest(team, "conversations.setTopic", + {"channel": channel.identifier, "topic": linkify_text(topic, team)}, channel=channel) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_or_ignore +@utf8_decode +def whois_command_cb(data, current_buffer, command): + """ + Get real name of user + /whois <nick> + """ + args = command.split() + if len(args) < 2: + w.prnt(current_buffer, "Not enough arguments") + return w.WEECHAT_RC_OK_EAT + user = args[1] + if (user.startswith('@')): + user = user[1:] + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + u = team.users.get(team.get_username_map().get(user)) + if u: + def print_profile(field): + value = u.profile.get(field) + if value: + team.buffer_prnt("[{}]: {}: {}".format(user, field, value)) + + team.buffer_prnt("[{}]: {}".format(user, u.real_name)) + status_emoji = replace_string_with_emoji(u.profile.get("status_emoji", "")) + status_text = u.profile.get("status_text", "") + if status_emoji or status_text: + team.buffer_prnt("[{}]: {} {}".format(user, status_emoji, status_text)) + + team.buffer_prnt("[{}]: username: {}".format(user, u.username)) + team.buffer_prnt("[{}]: id: {}".format(user, u.identifier)) + + print_profile('title') + print_profile('email') + print_profile('phone') + print_profile('skype') + else: + team.buffer_prnt("[{}]: No such user".format(user)) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_or_ignore +@utf8_decode +def me_command_cb(data, current_buffer, args): + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + message = args.split(' ', 1)[1] + channel.send_message(message, subtype='me_message') + return w.WEECHAT_RC_OK_EAT + + +@utf8_decode +def command_register(data, current_buffer, args): + """ + /slack register [code] + Register a Slack team in wee-slack. + """ + CLIENT_ID = "2468770254.51917335286" + CLIENT_SECRET = "dcb7fe380a000cba0cca3169a5fe8d70" # Not really a secret. + REDIRECT_URI = "https%3A%2F%2Fwee-slack.github.io%2Fwee-slack%2Foauth%23" + if not args: + message = textwrap.dedent(""" + ### Connecting to a Slack team with OAuth ### + 1) Paste this link into a browser: https://slack.com/oauth/authorize?client_id={}&scope=client&redirect_uri={} + 2) Select the team you wish to access from wee-slack in your browser. If you want to add multiple teams, you will have to repeat this whole process for each team. + 3) Click "Authorize" in the browser. + If you get a message saying you are not authorized to install wee-slack, the team has restricted Slack app installation and you will have to request it from an admin. To do that, go to https://my.slack.com/apps/A1HSZ9V8E-wee-slack and click "Request to Install". + 4) The web page will show a command in the form `/slack register <code>`. Run this command in weechat. + """).strip().format(CLIENT_ID, REDIRECT_URI) + w.prnt("", message) + return w.WEECHAT_RC_OK_EAT + + uri = ( + "https://slack.com/api/oauth.access?" + "client_id={}&client_secret={}&redirect_uri={}&code={}" + ).format(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, args) + params = {'useragent': 'wee_slack {}'.format(SCRIPT_VERSION)} + w.hook_process_hashtable('url:', params, config.slack_timeout, "", "") + w.hook_process_hashtable("url:{}".format(uri), params, config.slack_timeout, "register_callback", "") + return w.WEECHAT_RC_OK_EAT + + +@utf8_decode +def register_callback(data, command, return_code, out, err): + if return_code != 0: + w.prnt("", "ERROR: problem when trying to get Slack OAuth token. Got return code {}. Err: {}".format(return_code, err)) + w.prnt("", "Check the network or proxy settings") + return w.WEECHAT_RC_OK_EAT + + if len(out) <= 0: + w.prnt("", "ERROR: problem when trying to get Slack OAuth token. Got 0 length answer. Err: {}".format(err)) + w.prnt("", "Check the network or proxy settings") + return w.WEECHAT_RC_OK_EAT + + d = json.loads(out) + if not d["ok"]: + w.prnt("", + "ERROR: Couldn't get Slack OAuth token: {}".format(d['error'])) + return w.WEECHAT_RC_OK_EAT + + if config.is_default('slack_api_token'): + w.config_set_plugin('slack_api_token', d['access_token']) + else: + # Add new token to existing set, joined by comma. + tok = config.get_string('slack_api_token') + w.config_set_plugin('slack_api_token', + ','.join([tok, d['access_token']])) + + w.prnt("", "Success! Added team \"%s\"" % (d['team_name'],)) + w.prnt("", "Please reload wee-slack with: /python reload slack") + w.prnt("", "If you want to add another team you can repeat this process from step 1 before reloading wee-slack.") + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_or_ignore +@utf8_decode +def msg_command_cb(data, current_buffer, args): + aargs = args.split(None, 2) + who = aargs[1].lstrip('@') + if who == "*": + who = EVENTROUTER.weechat_controller.buffers[current_buffer].name + else: + join_query_command_cb(data, current_buffer, '/query ' + who) + + if len(aargs) > 2: + message = aargs[2] + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + cmap = team.get_channel_map() + if who in cmap: + channel = team.channels[cmap[who]] + channel.send_message(message) + return w.WEECHAT_RC_OK_EAT + + +def print_team_items_info(team, header, items, extra_info_function): + team.buffer_prnt("{}:".format(header)) + if items: + max_name_length = max(len(item.name) for item in items) + for item in sorted(items, key=lambda item: item.name.lower()): + extra_info = extra_info_function(item) + team.buffer_prnt(" {:<{}}({})".format(item.name, max_name_length + 2, extra_info)) + return w.WEECHAT_RC_OK_EAT + + +def print_users_info(team, header, users): + def extra_info_function(user): + external_text = ", external" if user.is_external else "" + return user.presence + external_text + return print_team_items_info(team, header, users, extra_info_function) + + +@slack_buffer_required +@utf8_decode +def command_teams(data, current_buffer, args): + """ + /slack teams + List the connected Slack teams. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + teams = EVENTROUTER.teams.values() + extra_info_function = lambda team: "token: {}...".format(team.token[:15]) + return print_team_items_info(team, "Slack teams", teams, extra_info_function) + + +@slack_buffer_required +@utf8_decode +def command_channels(data, current_buffer, args): + """ + /slack channels + List the channels in the current team. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + channels = [channel for channel in team.channels.values() if channel.type not in ['im', 'mpim']] + def extra_info_function(channel): + if channel.active: + return "member" + elif getattr(channel, "is_archived", None): + return "archived" + else: + return "not a member" + return print_team_items_info(team, "Channels", channels, extra_info_function) + + +@slack_buffer_required +@utf8_decode +def command_users(data, current_buffer, args): + """ + /slack users + List the users in the current team. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + return print_users_info(team, "Users", team.users.values()) + + +@slack_buffer_required +@utf8_decode +def command_usergroups(data, current_buffer, args): + """ + /slack usergroups [handle] + List the usergroups in the current team + If handle is given show the members in the usergroup + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + usergroups = team.generate_usergroup_map() + usergroup_key = usergroups.get(args) + + if usergroup_key: + s = SlackRequest(team, "usergroups.users.list", {"usergroup": usergroup_key}, + metadata={'usergroup_handle': args}) + EVENTROUTER.receive(s) + elif args: + w.prnt('', 'ERROR: Unknown usergroup handle: {}'.format(args)) + return w.WEECHAT_RC_ERROR + else: + def extra_info_function(subteam): + is_member = 'member' if subteam.is_member else 'not a member' + return '{}, {}'.format(subteam.handle, is_member) + return print_team_items_info(team, "Usergroups", team.subteams.values(), extra_info_function) + return w.WEECHAT_RC_OK_EAT + +command_usergroups.completion = '%(usergroups)' + + +@slack_buffer_required +@utf8_decode +def command_talk(data, current_buffer, args): + """ + /slack talk <user>[,<user2>[,<user3>...]] + Open a chat with the specified user(s). + """ + if not args: + w.prnt('', 'Usage: /slack talk <user>[,<user2>[,<user3>...]]') + return w.WEECHAT_RC_ERROR + return join_query_command_cb(data, current_buffer, '/query ' + args) + +command_talk.completion = '%(nicks)' + + +@slack_buffer_or_ignore +@utf8_decode +def join_query_command_cb(data, current_buffer, args): + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + split_args = args.split(' ', 1) + if len(split_args) < 2 or not split_args[1]: + w.prnt('', 'Too few arguments for command "{}" (help on command: /help {})' + .format(split_args[0], split_args[0].lstrip('/'))) + return w.WEECHAT_RC_OK_EAT + query = split_args[1] + + # Try finding the channel by name + channel = team.channels.get(team.get_channel_map().get(query)) + + # If the channel doesn't exist, try finding a DM or MPDM instead + if not channel: + if query.startswith('#'): + w.prnt('', 'ERROR: Unknown channel: {}'.format(query)) + return w.WEECHAT_RC_OK_EAT + + # Get the IDs of the users + all_users = team.get_username_map() + users = set() + for username in query.split(','): + user = all_users.get(username.lstrip('@')) + if not user: + w.prnt('', 'ERROR: Unknown user: {}'.format(username)) + return w.WEECHAT_RC_OK_EAT + users.add(user) + + if users: + if len(users) > 1: + channel_type = 'mpim' + # Add the current user since MPDMs include them as a member + users.add(team.myidentifier) + else: + channel_type = 'im' + + channel = team.find_channel_by_members(users, channel_type=channel_type) + + # If the DM or MPDM doesn't exist, create it + if not channel: + s = SlackRequest(team, SLACK_API_TRANSLATOR[channel_type]['join'], + {'users': ','.join(users)}) + EVENTROUTER.receive(s) + + if channel: + channel.open() + if config.switch_buffer_on_join: + w.buffer_set(channel.channel_buffer, "display", "1") + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_showmuted(data, current_buffer, args): + """ + /slack showmuted + List the muted channels in the current team. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + muted_channels = [team.channels[key].name + for key in team.muted_channels if key in team.channels] + team.buffer_prnt("Muted channels: {}".format(', '.join(muted_channels))) + return w.WEECHAT_RC_OK_EAT + + +def get_msg_from_id(channel, msg_id): + if msg_id[0] == '$': + msg_id = msg_id[1:] + return channel.hashed_messages.get(msg_id) + + +@slack_buffer_required +@utf8_decode +def command_thread(data, current_buffer, args): + """ + /thread [message_id] + Open the thread for the message. + If no message id is specified the last thread in channel will be opened. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + + if not isinstance(channel, SlackChannelCommon): + print_error('/thread can not be used in the team buffer, only in a channel') + return w.WEECHAT_RC_ERROR + + if args: + msg = get_msg_from_id(channel, args) + if not msg: + w.prnt('', 'ERROR: Invalid id given, must be an existing id') + return w.WEECHAT_RC_OK_EAT + else: + for message in reversed(channel.messages.values()): + if type(message) == SlackMessage and message.number_of_replies(): + msg = message + break + else: + w.prnt('', 'ERROR: No threads found in channel') + return w.WEECHAT_RC_OK_EAT + + msg.open_thread(switch=config.switch_buffer_on_join) + return w.WEECHAT_RC_OK_EAT + +command_thread.completion = '%(threads)' + + +@slack_buffer_required +@utf8_decode +def command_reply(data, current_buffer, args): + """ + /reply [-alsochannel] [<count/message_id>] <message> + + When in a channel buffer: + /reply [-alsochannel] <count/message_id> <message> + Reply in a thread on the message. Specify either the message id or a count + upwards to the message from the last message. + + When in a thread buffer: + /reply [-alsochannel] <message> + Reply to the current thread. This can be used to send the reply to the + rest of the channel. + + In either case, -alsochannel also sends the reply to the parent channel. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + parts = args.split(None, 1) + if parts[0] == "-alsochannel": + args = parts[1] + broadcast = True + else: + broadcast = False + + if isinstance(channel, SlackThreadChannel): + text = args + msg = channel.parent_message + else: + try: + msg_id, text = args.split(None, 1) + except ValueError: + w.prnt('', 'Usage (when in a channel buffer): /reply [-alsochannel] <count/message_id> <message>') + return w.WEECHAT_RC_OK_EAT + msg = get_msg_from_id(channel, msg_id) + + if msg: + if isinstance(msg, SlackThreadMessage): + parent_id = str(msg.parent_message.ts) + else: + parent_id = str(msg.ts) + elif msg_id.isdigit() and int(msg_id) >= 1: + mkeys = channel.main_message_keys_reversed() + parent_id = str(next(islice(mkeys, int(msg_id) - 1, None))) + else: + w.prnt('', 'ERROR: Invalid id given, must be a number greater than 0 or an existing id') + return w.WEECHAT_RC_OK_EAT + + channel.send_message(text, request_dict_ext={'thread_ts': parent_id, 'reply_broadcast': broadcast}) + return w.WEECHAT_RC_OK_EAT + +command_reply.completion = '-alsochannel %(threads)||%(threads)' + + +@slack_buffer_required +@utf8_decode +def command_rehistory(data, current_buffer, args): + """ + /rehistory + Reload the history in the current channel. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + channel.clear_messages() + channel.get_history() + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_hide(data, current_buffer, args): + """ + /hide + Hide the current channel if it is marked as distracting. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + name = channel.formatted_name(style='long_default') + if name in config.distracting_channels: + w.buffer_set(channel.channel_buffer, "hidden", "1") + return w.WEECHAT_RC_OK_EAT + + +@utf8_decode +def slack_command_cb(data, current_buffer, args): + split_args = args.split(' ', 1) + cmd_name = split_args[0] + cmd_args = split_args[1] if len(split_args) > 1 else '' + cmd = EVENTROUTER.cmds.get(cmd_name or 'help') + if not cmd: + w.prnt('', 'Command not found: ' + cmd_name) + return w.WEECHAT_RC_OK + return cmd(data, current_buffer, cmd_args) + + +@utf8_decode +def command_help(data, current_buffer, args): + """ + /slack help [command] + Print help for /slack commands. + """ + if args: + cmd = EVENTROUTER.cmds.get(args) + if cmd: + cmds = {args: cmd} + else: + w.prnt('', 'Command not found: ' + args) + return w.WEECHAT_RC_OK + else: + cmds = EVENTROUTER.cmds + w.prnt('', '\n{}'.format(colorize_string('bold', 'Slack commands:'))) + + script_prefix = '{0}[{1}python{0}/{1}slack{0}]{1}'.format(w.color('green'), w.color('reset')) + + for _, cmd in sorted(cmds.items()): + name, cmd_args, description = parse_help_docstring(cmd) + w.prnt('', '\n{} {} {}\n\n{}'.format( + script_prefix, colorize_string('white', name), cmd_args, description)) + return w.WEECHAT_RC_OK + + +@slack_buffer_required +@utf8_decode +def command_distracting(data, current_buffer, args): + """ + /slack distracting + Add or remove the current channel from distracting channels. You can hide + or unhide these channels with /slack nodistractions. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + fullname = channel.formatted_name(style="long_default") + if fullname in config.distracting_channels: + config.distracting_channels.remove(fullname) + else: + config.distracting_channels.append(fullname) + w.config_set_plugin('distracting_channels', ','.join(config.distracting_channels)) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_slash(data, current_buffer, args): + """ + /slack slash /customcommand arg1 arg2 arg3 + Run a custom slack command. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + team = channel.team + + split_args = args.split(' ', 1) + command = split_args[0] + text = split_args[1] if len(split_args) > 1 else "" + text_linkified = linkify_text(text, team, only_users=True) + + s = SlackRequest(team, "chat.command", + {"command": command, "text": text_linkified, 'channel': channel.identifier}, + channel=channel, metadata={'command': command, 'command_args': text}) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_mute(data, current_buffer, args): + """ + /slack mute + Toggle mute on the current channel. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + team = channel.team + team.muted_channels ^= {channel.identifier} + muted_str = "Muted" if channel.identifier in team.muted_channels else "Unmuted" + team.buffer_prnt("{} channel {}".format(muted_str, channel.name)) + s = SlackRequest(team, "users.prefs.set", + {"name": "muted_channels", "value": ",".join(team.muted_channels)}, channel=channel) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_linkarchive(data, current_buffer, args): + """ + /slack linkarchive [message_id] + Place a link to the channel or message in the input bar. + Use cursor or mouse mode to get the id. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + url = 'https://{}/'.format(channel.team.domain) + + if isinstance(channel, SlackChannelCommon): + url += 'archives/{}/'.format(channel.identifier) + if args: + if args[0] == '$': + message_id = args[1:] + else: + message_id = args + message = channel.hashed_messages.get(message_id) + if message: + url += 'p{}{:0>6}'.format(message.ts.majorstr(), message.ts.minorstr()) + if isinstance(message, SlackThreadMessage): + url += "?thread_ts={}&cid={}".format(message.parent_message.ts, channel.identifier) + else: + w.prnt('', 'ERROR: Invalid id given, must be an existing id') + return w.WEECHAT_RC_OK_EAT + + w.command(current_buffer, "/input insert {}".format(url)) + return w.WEECHAT_RC_OK_EAT + +command_linkarchive.completion = '%(threads)' + + +@utf8_decode +def command_nodistractions(data, current_buffer, args): + """ + /slack nodistractions + Hide or unhide all channels marked as distracting. + """ + global hide_distractions + hide_distractions = not hide_distractions + channels = [channel for channel in EVENTROUTER.weechat_controller.buffers.values() + if channel in config.distracting_channels] + for channel in channels: + w.buffer_set(channel.channel_buffer, "hidden", str(int(hide_distractions))) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_upload(data, current_buffer, args): + """ + /slack upload <filename> + Uploads a file to the current buffer. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + weechat_dir = w.info_get("weechat_dir", "") + file_path = os.path.join(weechat_dir, os.path.expanduser(args)) + + if channel.type == 'team': + w.prnt('', "ERROR: Can't upload a file to the team buffer") + return w.WEECHAT_RC_ERROR + + if not os.path.isfile(file_path): + unescaped_file_path = file_path.replace(r'\ ', ' ') + if os.path.isfile(unescaped_file_path): + file_path = unescaped_file_path + else: + w.prnt('', 'ERROR: Could not find file: {}'.format(file_path)) + return w.WEECHAT_RC_ERROR + + post_data = { + 'channels': channel.identifier, + } + if isinstance(channel, SlackThreadChannel): + post_data['thread_ts'] = channel.parent_message.ts + + url = SlackRequest(channel.team, 'files.upload', post_data, channel=channel).request_string() + options = [ + '-s', + '-Ffile=@{}'.format(file_path), + url + ] + + proxy_string = ProxyWrapper().curl() + if proxy_string: + options.append(proxy_string) + + options_hashtable = {'arg{}'.format(i + 1): arg for i, arg in enumerate(options)} + w.hook_process_hashtable('curl', options_hashtable, config.slack_timeout, 'upload_callback', '') + return w.WEECHAT_RC_OK_EAT + +command_upload.completion = '%(filename)' + + +@utf8_decode +def upload_callback(data, command, return_code, out, err): + if return_code != 0: + w.prnt("", "ERROR: Couldn't upload file. Got return code {}. Error: {}".format(return_code, err)) + return w.WEECHAT_RC_OK_EAT + + try: + response = json.loads(out) + except JSONDecodeError: + w.prnt("", "ERROR: Couldn't process response from file upload. Got: {}".format(out)) + return w.WEECHAT_RC_OK_EAT + + if not response["ok"]: + w.prnt("", "ERROR: Couldn't upload file. Error: {}".format(response["error"])) + return w.WEECHAT_RC_OK_EAT + + +@utf8_decode +def away_command_cb(data, current_buffer, args): + all_servers, message = re.match('^/away( -all)? ?(.*)', args).groups() + if all_servers: + team_buffers = [team.channel_buffer for team in EVENTROUTER.teams.values()] + elif current_buffer in EVENTROUTER.weechat_controller.buffers: + team_buffers = [current_buffer] + else: + return w.WEECHAT_RC_OK + + for team_buffer in team_buffers: + if message: + command_away(data, team_buffer, args) + else: + command_back(data, team_buffer, args) + return w.WEECHAT_RC_OK + + +@slack_buffer_required +@utf8_decode +def command_away(data, current_buffer, args): + """ + /slack away + Sets your status as 'away'. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + s = SlackRequest(team, "users.setPresence", {"presence": "away"}) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK + + +@slack_buffer_required +@utf8_decode +def command_status(data, current_buffer, args): + """ + /slack status [<emoji> [<status_message>]|-delete] + Lets you set your Slack Status (not to be confused with away/here). + Prints current status if no arguments are given, unsets the status if -delete is given. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + + split_args = args.split(" ", 1) + if not split_args[0]: + profile = team.users[team.myidentifier].profile + team.buffer_prnt("Status: {} {}".format( + replace_string_with_emoji(profile.get("status_emoji", "")), + profile.get("status_text", ""))) + return w.WEECHAT_RC_OK + + emoji = "" if split_args[0] == "-delete" else split_args[0] + text = split_args[1] if len(split_args) > 1 else "" + new_profile = {"status_text": text, "status_emoji": emoji} + + s = SlackRequest(team, "users.profile.set", {"profile": new_profile}) + EVENTROUTER.receive(s) + return w.WEECHAT_RC_OK + +command_status.completion = "-delete|%(emoji)" + + +@utf8_decode +def line_event_cb(data, signal, hashtable): + buffer_pointer = hashtable["_buffer"] + line_timestamp = hashtable["_chat_line_date"] + line_time_id = hashtable["_chat_line_date_printed"] + channel = EVENTROUTER.weechat_controller.buffers.get(buffer_pointer) + + if line_timestamp and line_time_id and isinstance(channel, SlackChannelCommon): + ts = SlackTS("{}.{}".format(line_timestamp, line_time_id)) + + message_hash = channel.hash_message(ts) + if message_hash is None: + return w.WEECHAT_RC_OK + message_hash = "$" + message_hash + + if data == "message": + w.command(buffer_pointer, "/cursor stop") + w.command(buffer_pointer, "/input insert {}".format(message_hash)) + elif data == "delete": + w.command(buffer_pointer, "/input send {}s///".format(message_hash)) + elif data == "linkarchive": + w.command(buffer_pointer, "/cursor stop") + w.command(buffer_pointer, "/slack linkarchive {}".format(message_hash[1:])) + elif data == "reply": + w.command(buffer_pointer, "/cursor stop") + w.command(buffer_pointer, "/input insert /reply {}\\x20".format(message_hash)) + elif data == "thread": + w.command(buffer_pointer, "/cursor stop") + w.command(buffer_pointer, "/thread {}".format(message_hash)) + return w.WEECHAT_RC_OK + + +@slack_buffer_required +@utf8_decode +def command_back(data, current_buffer, args): + """ + /slack back + Sets your status as 'back'. + """ + team = EVENTROUTER.weechat_controller.buffers[current_buffer].team + s = SlackRequest(team, "users.setPresence", {"presence": "auto"}) + EVENTROUTER.receive(s) + set_own_presence_active(team) + return w.WEECHAT_RC_OK + + +@slack_buffer_required +@utf8_decode +def command_label(data, current_buffer, args): + """ + /label <name> + Rename a thread buffer. Note that this is not permanent. It will only last + as long as you keep the buffer and wee-slack open. + """ + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + if channel.type == 'thread': + new_name = " +" + args + channel.label = new_name + w.buffer_set(channel.channel_buffer, "short_name", new_name) + return w.WEECHAT_RC_OK + + +@utf8_decode +def set_unread_cb(data, current_buffer, command): + for channel in EVENTROUTER.weechat_controller.buffers.values(): + channel.mark_read() + return w.WEECHAT_RC_OK + + +@slack_buffer_or_ignore +@utf8_decode +def set_unread_current_buffer_cb(data, current_buffer, command): + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] + channel.mark_read() + return w.WEECHAT_RC_OK + + +###### NEW EXCEPTIONS + + +class InvalidType(Exception): + """ + Raised when we do type checking to ensure objects of the wrong + type are not used improperly. + """ + def __init__(self, type_str): + super(InvalidType, self).__init__(type_str) + +###### New but probably old and need to migrate + + +def closed_slack_debug_buffer_cb(data, buffer): + global slack_debug + slack_debug = None + return w.WEECHAT_RC_OK + + +def create_slack_debug_buffer(): + global slack_debug, debug_string + if slack_debug is None: + debug_string = None + slack_debug = w.buffer_new("slack-debug", "", "", "closed_slack_debug_buffer_cb", "") + w.buffer_set(slack_debug, "notify", "0") + w.buffer_set(slack_debug, "highlight_tags_restrict", "highlight_force") + + +def load_emoji(): + try: + DIR = w.info_get('weechat_dir', '') + with open('{}/weemoji.json'.format(DIR), 'r') as ef: + emojis = json.loads(ef.read()) + if 'emoji' in emojis: + print_error('The weemoji.json file is in an old format. Please update it.') + else: + emoji_unicode = {key: value['unicode'] for key, value in emojis.items()} + + emoji_skin_tones = {skin_tone['name']: skin_tone['unicode'] + for emoji in emojis.values() + for skin_tone in emoji.get('skinVariations', {}).values()} + + emoji_with_skin_tones = chain(emoji_unicode.items(), emoji_skin_tones.items()) + emoji_with_skin_tones_reverse = {v: k for k, v in emoji_with_skin_tones} + return emoji_unicode, emoji_with_skin_tones_reverse + except: + dbg("Couldn't load emoji list: {}".format(format_exc_only()), 5) + return {}, {} + + +def parse_help_docstring(cmd): + doc = textwrap.dedent(cmd.__doc__).strip().split('\n', 1) + cmd_line = doc[0].split(None, 1) + args = ''.join(cmd_line[1:]) + return cmd_line[0], args, doc[1].strip() + + +def setup_hooks(): + w.bar_item_new('slack_typing_notice', '(extra)typing_bar_item_cb', '') + w.bar_item_new('away', '(extra)away_bar_item_cb', '') + w.bar_item_new('slack_away', '(extra)away_bar_item_cb', '') + + w.hook_timer(5000, 0, 0, "ws_ping_cb", "") + w.hook_timer(1000, 0, 0, "typing_update_cb", "") + w.hook_timer(1000, 0, 0, "buffer_list_update_callback", "EVENTROUTER") + w.hook_timer(3000, 0, 0, "reconnect_callback", "EVENTROUTER") + w.hook_timer(1000 * 60 * 5, 0, 0, "slack_never_away_cb", "") + + w.hook_signal('buffer_closing', "buffer_closing_callback", "") + w.hook_signal('buffer_switch', "buffer_switch_callback", "EVENTROUTER") + w.hook_signal('window_switch', "buffer_switch_callback", "EVENTROUTER") + w.hook_signal('quit', "quit_notification_callback", "") + if config.send_typing_notice: + w.hook_signal('input_text_changed', "typing_notification_cb", "") + + command_help.completion = '|'.join(EVENTROUTER.cmds.keys()) + completions = '||'.join( + '{} {}'.format(name, getattr(cmd, 'completion', '')) + for name, cmd in EVENTROUTER.cmds.items()) + + w.hook_command( + # Command name and description + 'slack', 'Plugin to allow typing notification and sync of read markers for slack.com', + # Usage + '<command> [<command options>]', + # Description of arguments + 'Commands:\n' + + '\n'.join(sorted(EVENTROUTER.cmds.keys())) + + '\nUse /slack help <command> to find out more\n', + # Completions + completions, + # Function name + 'slack_command_cb', '') + + w.hook_command_run('/me', 'me_command_cb', '') + w.hook_command_run('/query', 'join_query_command_cb', '') + w.hook_command_run('/join', 'join_query_command_cb', '') + w.hook_command_run('/part', 'part_command_cb', '') + w.hook_command_run('/topic', 'topic_command_cb', '') + w.hook_command_run('/msg', 'msg_command_cb', '') + w.hook_command_run('/invite', 'invite_command_cb', '') + w.hook_command_run("/input complete_next", "complete_next_cb", "") + w.hook_command_run("/input set_unread", "set_unread_cb", "") + w.hook_command_run("/input set_unread_current_buffer", "set_unread_current_buffer_cb", "") + w.hook_command_run('/away', 'away_command_cb', '') + w.hook_command_run('/whois', 'whois_command_cb', '') + + for cmd_name in ['hide', 'label', 'rehistory', 'reply', 'thread']: + cmd = EVENTROUTER.cmds[cmd_name] + _, args, description = parse_help_docstring(cmd) + completion = getattr(cmd, 'completion', '') + w.hook_command(cmd_name, description, args, '', completion, 'command_' + cmd_name, '') + + w.hook_completion("irc_channel_topic", "complete topic for slack", "topic_completion_cb", "") + w.hook_completion("irc_channels", "complete channels for slack", "channel_completion_cb", "") + w.hook_completion("irc_privates", "complete dms/mpdms for slack", "dm_completion_cb", "") + w.hook_completion("nicks", "complete @-nicks for slack", "nick_completion_cb", "") + w.hook_completion("threads", "complete thread ids for slack", "thread_completion_cb", "") + w.hook_completion("usergroups", "complete @-usergroups for slack", "usergroups_completion_cb", "") + w.hook_completion("emoji", "complete :emoji: for slack", "emoji_completion_cb", "") + + w.key_bind("mouse", { + "@chat(python.*):button2": "hsignal:slack_mouse", + }) + w.key_bind("cursor", { + "@chat(python.*):D": "hsignal:slack_cursor_delete", + "@chat(python.*):L": "hsignal:slack_cursor_linkarchive", + "@chat(python.*):M": "hsignal:slack_cursor_message", + "@chat(python.*):R": "hsignal:slack_cursor_reply", + "@chat(python.*):T": "hsignal:slack_cursor_thread", + }) + + w.hook_hsignal("slack_mouse", "line_event_cb", "message") + w.hook_hsignal("slack_cursor_delete", "line_event_cb", "delete") + w.hook_hsignal("slack_cursor_linkarchive", "line_event_cb", "linkarchive") + w.hook_hsignal("slack_cursor_message", "line_event_cb", "message") + w.hook_hsignal("slack_cursor_reply", "line_event_cb", "reply") + w.hook_hsignal("slack_cursor_thread", "line_event_cb", "thread") + + # Hooks to fix/implement + # w.hook_signal('buffer_opened', "buffer_opened_cb", "") + # w.hook_signal('window_scrolled', "scrolled_cb", "") + # w.hook_timer(3000, 0, 0, "slack_connection_persistence_cb", "") + +##### END NEW + + +def dbg(message, level=0, main_buffer=False, fout=False): + """ + send debug output to the slack-debug buffer and optionally write to a file. + """ + # TODO: do this smarter + if level >= config.debug_level: + global debug_string + message = "DEBUG: {}".format(message) + if fout: + with open('/tmp/debug.log', 'a+') as log_file: + log_file.writelines(message + '\n') + if main_buffer: + w.prnt("", "slack: " + message) + else: + if slack_debug and (not debug_string or debug_string in message): + w.prnt(slack_debug, message) + + +###### Config code +class PluginConfig(object): + Setting = collections.namedtuple('Setting', ['default', 'desc']) + # Default settings. + # These are, initially, each a (default, desc) tuple; the former is the + # default value of the setting, in the (string) format that weechat + # expects, and the latter is the user-friendly description of the setting. + # At __init__ time these values are extracted, the description is used to + # set or update the setting description for use with /help, and the default + # value is used to set the default for any settings not already defined. + # Following this procedure, the keys remain the same, but the values are + # the real (python) values of the settings. + default_settings = { + 'auto_open_threads': Setting( + default='false', + desc='Automatically open threads when mentioned or in' + 'response to own messages.'), + 'background_load_all_history': Setting( + default='false', + desc='Load history for each channel in the background as soon as it' + ' opens, rather than waiting for the user to look at it.'), + 'channel_name_typing_indicator': Setting( + default='true', + desc='Change the prefix of a channel from # to > when someone is' + ' typing in it. Note that this will (temporarily) affect the sort' + ' order if you sort buffers by name rather than by number.'), + 'color_buflist_muted_channels': Setting( + default='darkgray', + desc='Color to use for muted channels in the buflist'), + 'color_deleted': Setting( + default='red', + desc='Color to use for deleted messages and files.'), + 'color_edited_suffix': Setting( + default='095', + desc='Color to use for (edited) suffix on messages that have been edited.'), + 'color_reaction_suffix': Setting( + default='darkgray', + desc='Color to use for the [:wave:(@user)] suffix on messages that' + ' have reactions attached to them.'), + 'color_reaction_suffix_added_by_you': Setting( + default='blue', + desc='Color to use for reactions that you have added.'), + 'color_thread_suffix': Setting( + default='lightcyan', + desc='Color to use for the [thread: XXX] suffix on messages that' + ' have threads attached to them. The special value "multiple" can' + ' be used to use a different color for each thread.'), + 'color_typing_notice': Setting( + default='yellow', + desc='Color to use for the typing notice.'), + 'colorize_private_chats': Setting( + default='false', + desc='Whether to use nick-colors in DM windows.'), + 'debug_mode': Setting( + default='false', + desc='Open a dedicated buffer for debug messages and start logging' + ' to it. How verbose the logging is depends on log_level.'), + 'debug_level': Setting( + default='3', + desc='Show only this level of debug info (or higher) when' + ' debug_mode is on. Lower levels -> more messages.'), + 'distracting_channels': Setting( + default='', + desc='List of channels to hide.'), + 'external_user_suffix': Setting( + default='*', + desc='The suffix appended to nicks to indicate external users.'), + 'files_download_location': Setting( + default='', + desc='If set, file attachments will be automatically downloaded' + ' to this location. "%h" will be replaced by WeeChat home,' + ' "~/.weechat" by default.'), + 'group_name_prefix': Setting( + default='&', + desc='The prefix of buffer names for groups (private channels).'), + 'map_underline_to': Setting( + default='_', + desc='When sending underlined text to slack, use this formatting' + ' character for it. The default ("_") sends it as italics. Use' + ' "*" to send bold instead.'), + 'muted_channels_activity': Setting( + default='personal_highlights', + desc="Control which activity you see from muted channels, either" + " none, personal_highlights, all_highlights or all. none: Don't" + " show any activity. personal_highlights: Only show personal" + " highlights, i.e. not @channel and @here. all_highlights: Show" + " all highlights, but not other messages. all: Show all activity," + " like other channels."), + 'notify_usergroup_handle_updated': Setting( + default='false', + desc="Control if you want to see notification when a usergroup's" + " handle has changed, either true or false."), + 'never_away': Setting( + default='false', + desc='Poke Slack every five minutes so that it never marks you "away".'), + 'record_events': Setting( + default='false', + desc='Log all traffic from Slack to disk as JSON.'), + 'render_bold_as': Setting( + default='bold', + desc='When receiving bold text from Slack, render it as this in weechat.'), + 'render_emoji_as_string': Setting( + default='false', + desc="Render emojis as :emoji_name: instead of emoji characters. Enable this" + " if your terminal doesn't support emojis, or set to 'both' if you want to" + " see both renderings. Note that even though this is" + " disabled by default, you need to place {}/blob/master/weemoji.json in your" + " weechat directory to enable rendering emojis as emoji characters." + .format(REPO_URL)), + 'render_italic_as': Setting( + default='italic', + desc='When receiving bold text from Slack, render it as this in weechat.' + ' If your terminal lacks italic support, consider using "underline" instead.'), + 'send_typing_notice': Setting( + default='true', + desc='Alert Slack users when you are typing a message in the input bar ' + '(Requires reload)'), + 'server_aliases': Setting( + default='', + desc='A comma separated list of `subdomain:alias` pairs. The alias' + ' will be used instead of the actual name of the slack (in buffer' + ' names, logging, etc). E.g `work:no_fun_allowed` would make your' + ' work slack show up as `no_fun_allowed` rather than `work.slack.com`.'), + 'shared_name_prefix': Setting( + default='%', + desc='The prefix of buffer names for shared channels.'), + 'short_buffer_names': Setting( + default='false', + desc='Use `foo.#channel` rather than `foo.slack.com.#channel` as the' + ' internal name for Slack buffers.'), + 'show_buflist_presence': Setting( + default='true', + desc='Display a `+` character in the buffer list for present users.'), + 'show_reaction_nicks': Setting( + default='false', + desc='Display the name of the reacting user(s) alongside each reactji.'), + 'slack_api_token': Setting( + default='INSERT VALID KEY HERE!', + desc='List of Slack API tokens, one per Slack instance you want to' + ' connect to. See the README for details on how to get these.'), + 'slack_timeout': Setting( + default='20000', + desc='How long (ms) to wait when communicating with Slack.'), + 'switch_buffer_on_join': Setting( + default='true', + desc='When /joining a channel, automatically switch to it as well.'), + 'thread_messages_in_channel': Setting( + default='false', + desc='When enabled shows thread messages in the parent channel.'), + 'unfurl_ignore_alt_text': Setting( + default='false', + desc='When displaying ("unfurling") links to channels/users/etc,' + ' ignore the "alt text" present in the message and instead use the' + ' canonical name of the thing being linked to.'), + 'unfurl_auto_link_display': Setting( + default='both', + desc='When displaying ("unfurling") links to channels/users/etc,' + ' determine what is displayed when the text matches the url' + ' without the protocol. This happens when Slack automatically' + ' creates links, e.g. from words separated by dots or email' + ' addresses. Set it to "text" to only display the text written by' + ' the user, "url" to only display the url or "both" (the default)' + ' to display both.'), + 'unhide_buffers_with_activity': Setting( + default='false', + desc='When activity occurs on a buffer, unhide it even if it was' + ' previously hidden (whether by the user or by the' + ' distracting_channels setting).'), + 'use_full_names': Setting( + default='false', + desc='Use full names as the nicks for all users. When this is' + ' false (the default), display names will be used if set, with a' + ' fallback to the full name if display name is not set.'), + } + + # Set missing settings to their defaults. Load non-missing settings from + # weechat configs. + def __init__(self): + self.settings = {} + # Set all descriptions, replace the values in the dict with the + # default setting value rather than the (setting,desc) tuple. + for key, (default, desc) in self.default_settings.items(): + w.config_set_desc_plugin(key, desc) + self.settings[key] = default + + # Migrate settings from old versions of Weeslack... + self.migrate() + # ...and then set anything left over from the defaults. + for key, default in self.settings.items(): + if not w.config_get_plugin(key): + w.config_set_plugin(key, default) + self.config_changed(None, None, None) + + def __str__(self): + return "".join([x + "\t" + str(self.settings[x]) + "\n" for x in self.settings.keys()]) + + def config_changed(self, data, key, value): + for key in self.settings: + self.settings[key] = self.fetch_setting(key) + if self.debug_mode: + create_slack_debug_buffer() + return w.WEECHAT_RC_OK + + def fetch_setting(self, key): + try: + return getattr(self, 'get_' + key)(key) + except AttributeError: + # Most settings are on/off, so make get_boolean the default + return self.get_boolean(key) + except: + # There was setting-specific getter, but it failed. + return self.settings[key] + + def __getattr__(self, key): + try: + return self.settings[key] + except KeyError: + raise AttributeError(key) + + def get_boolean(self, key): + return w.config_string_to_boolean(w.config_get_plugin(key)) + + def get_string(self, key): + return w.config_get_plugin(key) + + def get_int(self, key): + return int(w.config_get_plugin(key)) + + def is_default(self, key): + default = self.default_settings.get(key).default + return w.config_get_plugin(key) == default + + get_color_buflist_muted_channels = get_string + get_color_deleted = get_string + get_color_edited_suffix = get_string + get_color_reaction_suffix = get_string + get_color_reaction_suffix_added_by_you = get_string + get_color_thread_suffix = get_string + get_color_typing_notice = get_string + get_debug_level = get_int + get_external_user_suffix = get_string + get_files_download_location = get_string + get_group_name_prefix = get_string + get_map_underline_to = get_string + get_muted_channels_activity = get_string + get_render_bold_as = get_string + get_render_italic_as = get_string + get_shared_name_prefix = get_string + get_slack_timeout = get_int + get_unfurl_auto_link_display = get_string + + def get_distracting_channels(self, key): + return [x.strip() for x in w.config_get_plugin(key).split(',') if x] + + def get_server_aliases(self, key): + alias_list = w.config_get_plugin(key) + return dict(item.split(":") for item in alias_list.split(",") if ':' in item) + + def get_slack_api_token(self, key): + token = w.config_get_plugin("slack_api_token") + if token.startswith('${sec.data'): + return w.string_eval_expression(token, {}, {}, {}) + else: + return token + + def get_render_emoji_as_string(self, key): + s = w.config_get_plugin(key) + if s == 'both': + return s + return w.config_string_to_boolean(s) + + def migrate(self): + """ + This is to migrate the extension name from slack_extension to slack + """ + if not w.config_get_plugin("migrated"): + for k in self.settings.keys(): + if not w.config_is_set_plugin(k): + p = w.config_get("plugins.var.python.slack_extension.{}".format(k)) + data = w.config_string(p) + if data != "": + w.config_set_plugin(k, data) + w.config_set_plugin("migrated", "true") + + old_thread_color_config = w.config_get_plugin("thread_suffix_color") + new_thread_color_config = w.config_get_plugin("color_thread_suffix") + if old_thread_color_config and not new_thread_color_config: + w.config_set_plugin("color_thread_suffix", old_thread_color_config) + + +def config_server_buffer_cb(data, key, value): + for team in EVENTROUTER.teams.values(): + team.buffer_merge(value) + return w.WEECHAT_RC_OK + + +# to Trace execution, add `setup_trace()` to startup +# and to a function and sys.settrace(trace_calls) to a function +def setup_trace(): + global f + now = time.time() + f = open('{}/{}-trace.json'.format(RECORD_DIR, now), 'w') + + +def trace_calls(frame, event, arg): + global f + if event != 'call': + return + co = frame.f_code + func_name = co.co_name + if func_name == 'write': + # Ignore write() calls from print statements + return + func_line_no = frame.f_lineno + func_filename = co.co_filename + caller = frame.f_back + caller_line_no = caller.f_lineno + caller_filename = caller.f_code.co_filename + print('Call to %s on line %s of %s from line %s of %s' % \ + (func_name, func_line_no, func_filename, + caller_line_no, caller_filename), file=f) + f.flush() + return + + +def initiate_connection(token, retries=3, team=None): + return SlackRequest(team, + 'rtm.{}'.format('connect' if team else 'start'), + {"batch_presence_aware": 1}, + retries=retries, + token=token) + + +if __name__ == "__main__": + + w = WeechatWrapper(weechat) + + if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, + SCRIPT_DESC, "script_unloaded", ""): + + weechat_version = w.info_get("version_number", "") or 0 + if int(weechat_version) < 0x1030000: + w.prnt("", "\nERROR: Weechat version 1.3+ is required to use {}.\n\n".format(SCRIPT_NAME)) + else: + + global EVENTROUTER + EVENTROUTER = EventRouter() + + receive_httprequest_callback = EVENTROUTER.receive_httprequest_callback + receive_ws_callback = EVENTROUTER.receive_ws_callback + + # Global var section + slack_debug = None + config = PluginConfig() + config_changed_cb = config.config_changed + + typing_timer = time.time() + + hide_distractions = False + + w.hook_config("plugins.var.python." + SCRIPT_NAME + ".*", "config_changed_cb", "") + w.hook_config("irc.look.server_buffer", "config_server_buffer_cb", "") + w.hook_modifier("input_text_for_buffer", "input_text_for_buffer_cb", "") + + EMOJI, EMOJI_WITH_SKIN_TONES_REVERSE = load_emoji() + setup_hooks() + + # attach to the weechat hooks we need + + tokens = [token.strip() for token in config.slack_api_token.split(',')] + w.prnt('', 'Connecting to {} slack team{}.' + .format(len(tokens), '' if len(tokens) == 1 else 's')) + for t in tokens: + s = initiate_connection(t) + EVENTROUTER.receive(s) + if config.record_events: + EVENTROUTER.record() + EVENTROUTER.handle_next() + # END attach to the weechat hooks we need + + hdata = Hdata(w) diff --git a/.weechat/relay.conf b/.weechat/relay.conf new file mode 100644 index 0000000..f9ab68b --- /dev/null +++ b/.weechat/relay.conf @@ -0,0 +1,55 @@ +# +# weechat -- relay.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +auto_open_buffer = on +raw_messages = 256 + +[color] +client = cyan +status_active = lightblue +status_auth_failed = lightred +status_connecting = yellow +status_disconnected = lightred +status_waiting_auth = brown +text = default +text_bg = default +text_selected = white + +[network] +allow_empty_password = off +allowed_ips = "" +bind_address = "" +clients_purge_delay = 0 +compression_level = 6 +ipv6 = on +max_clients = 5 +password = "" +ssl_cert_key = "%h/ssl/relay.pem" +ssl_priorities = "NORMAL:-VERS-SSL3.0" +totp_secret = "" +totp_window = 0 +websocket_allowed_origins = "" + +[irc] +backlog_max_minutes = 1440 +backlog_max_number = 256 +backlog_since_last_disconnect = on +backlog_since_last_message = off +backlog_tags = "irc_privmsg" +backlog_time_format = "[%H:%M] " + +[weechat] +commands = "" + +[port] + +[path] diff --git a/.weechat/ruby.conf b/.weechat/ruby.conf new file mode 100644 index 0000000..f6de7fb --- /dev/null +++ b/.weechat/ruby.conf @@ -0,0 +1,14 @@ +# +# weechat -- ruby.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/script.conf b/.weechat/script.conf new file mode 100644 index 0000000..121f189 --- /dev/null +++ b/.weechat/script.conf @@ -0,0 +1,56 @@ +# +# weechat -- script.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +columns = "%s %n %V %v %u | %d | %t" +diff_color = on +diff_command = "auto" +display_source = on +quiet_actions = on +sort = "i,p,n" +translate_description = on +use_keys = on + +[color] +status_autoloaded = cyan +status_held = white +status_installed = lightcyan +status_obsolete = lightmagenta +status_popular = yellow +status_running = lightgreen +status_unknown = lightred +text = default +text_bg = default +text_bg_selected = red +text_date = default +text_date_selected = white +text_delimiters = default +text_description = default +text_description_selected = white +text_extension = default +text_extension_selected = white +text_name = cyan +text_name_selected = lightcyan +text_selected = white +text_tags = brown +text_tags_selected = yellow +text_version = magenta +text_version_loaded = default +text_version_loaded_selected = white +text_version_selected = lightmagenta + +[scripts] +autoload = on +cache_expire = 1440 +download_timeout = 30 +hold = "" +path = "%h/script" +url = "https://weechat.org/files/plugins.xml.gz" diff --git a/.weechat/script/plugins.xml.gz b/.weechat/script/plugins.xml.gz new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.weechat/script/plugins.xml.gz diff --git a/.weechat/script/url_hint.py b/.weechat/script/url_hint.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.weechat/script/url_hint.py diff --git a/.weechat/sec.conf b/.weechat/sec.conf new file mode 100644 index 0000000..8c60d03 --- /dev/null +++ b/.weechat/sec.conf @@ -0,0 +1,18 @@ +# +# weechat -- sec.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[crypt] +cipher = aes256 +hash_algo = sha256 +passphrase_file = "" +salt = on + +[data] diff --git a/.weechat/spell.conf b/.weechat/spell.conf new file mode 100644 index 0000000..f65348d --- /dev/null +++ b/.weechat/spell.conf @@ -0,0 +1,33 @@ +# +# weechat -- spell.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[color] +misspelled = lightred +suggestion = default +suggestion_delimiter_dict = cyan +suggestion_delimiter_word = cyan + +[check] +commands = "away,command,cycle,kick,kickban,me,msg,notice,part,query,quit,topic" +default_dict = "" +during_search = off +enabled = off +real_time = off +suggestions = -1 +word_min_length = 2 + +[dict] + +[look] +suggestion_delimiter_dict = " / " +suggestion_delimiter_word = "," + +[option] diff --git a/.weechat/tcl.conf b/.weechat/tcl.conf new file mode 100644 index 0000000..4c0e389 --- /dev/null +++ b/.weechat/tcl.conf @@ -0,0 +1,14 @@ +# +# weechat -- tcl.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +check_license = off +eval_keep_context = on diff --git a/.weechat/tox.conf b/.weechat/tox.conf new file mode 100644 index 0000000..440b06b --- /dev/null +++ b/.weechat/tox.conf @@ -0,0 +1,31 @@ +# +# weechat -- tox.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[profile] + +[profile_default] +autojoin = off +autojoin_delay = 5000 +autoload = off +downloading_path = "%h/tfer/%p/" +ipv6 = on +logging = off +max_friend_requests = 100 +passphrase +proxy_address +proxy_port +proxy_type = none +save_file = "%h/tox/%p" +udp = on + +[look] +friend_request_message = "Hi! Please add me on Tox!" +short_id_size = 8 diff --git a/.weechat/trigger.conf b/.weechat/trigger.conf new file mode 100644 index 0000000..2424666 --- /dev/null +++ b/.weechat/trigger.conf @@ -0,0 +1,67 @@ +# +# weechat -- trigger.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +enabled = on +monitor_strip_colors = off + +[color] +flag_command = lightgreen +flag_conditions = yellow +flag_post_action = lightblue +flag_regex = lightcyan +flag_return_code = lightmagenta +regex = white +replace = cyan +trigger = green +trigger_disabled = red + +[trigger] +beep.arguments = "" +beep.command = "/print -beep" +beep.conditions = "${tg_displayed} && (${tg_highlight} || ${tg_msg_pv})" +beep.enabled = on +beep.hook = print +beep.post_action = none +beep.regex = "" +beep.return_code = ok +cmd_pass.arguments = "5000|input_text_display;5000|history_add;5000|irc_command_auth" +cmd_pass.command = "" +cmd_pass.conditions = "" +cmd_pass.enabled = on +cmd_pass.hook = modifier +cmd_pass.post_action = none +cmd_pass.regex = "==^((/(msg|m|quote) +(-server +[^ ]+ +)?nickserv +(id|identify|set +password|ghost +[^ ]+|release +[^ ]+|regain +[^ ]+|recover +[^ ]+) +)|/oper +[^ ]+ +|/quote +pass +|/set +[^ ]*password[^ ]* +|/secure +(passphrase|decrypt|set +[^ ]+) +)(.*)==${re:1}${hide:*,${re:+}}" +cmd_pass.return_code = ok +cmd_pass_register.arguments = "5000|input_text_display;5000|history_add;5000|irc_command_auth" +cmd_pass_register.command = "" +cmd_pass_register.conditions = "" +cmd_pass_register.enabled = on +cmd_pass_register.hook = modifier +cmd_pass_register.post_action = none +cmd_pass_register.regex = "==^(/(msg|m|quote) +nickserv +register +)([^ ]+)(.*)==${re:1}${hide:*,${re:3}}${re:4}" +cmd_pass_register.return_code = ok +msg_auth.arguments = "5000|irc_message_auth" +msg_auth.command = "" +msg_auth.conditions = "" +msg_auth.enabled = on +msg_auth.hook = modifier +msg_auth.post_action = none +msg_auth.regex = "==^(.*(id|identify|set +password|register|ghost +[^ ]+|release +[^ ]+|regain +[^ ]+|recover +[^ ]+) +)(.*)==${re:1}${hide:*,${re:+}}" +msg_auth.return_code = ok +server_pass.arguments = "5000|input_text_display;5000|history_add" +server_pass.command = "" +server_pass.conditions = "" +server_pass.enabled = on +server_pass.hook = modifier +server_pass.post_action = none +server_pass.regex = "==^(/(server|connect) .*-(sasl_)?password=)([^ ]+)(.*)==${re:1}${hide:*,${re:4}}${re:5}" +server_pass.return_code = ok diff --git a/.weechat/urlgrab.conf b/.weechat/urlgrab.conf new file mode 100644 index 0000000..9649415 --- /dev/null +++ b/.weechat/urlgrab.conf @@ -0,0 +1,29 @@ +# +# weechat -- urlgrab.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[color] +color_bg_selected = green +color_buffer = red +color_buffer_selected = red +color_time = cyan +color_time_selected = cyan +color_url = blue +color_url_selected = blue + +[default] +copycmd = "xsel -i" +historysize = 10 +localcmd = "firefox '%s'" +method = "local" +output_main_buffer = on +remotecmd = "ssh -x localhost -i ~/.ssh/id_rsa -C "export DISPLAY=":0.0" && firefox '%s'"" +time_format = "%H:%M:%S" +url_log = "~/.weechat/urls.log" diff --git a/.weechat/weechat.conf b/.weechat/weechat.conf new file mode 100644 index 0000000..731e0a8 --- /dev/null +++ b/.weechat/weechat.conf @@ -0,0 +1,711 @@ +# +# weechat -- weechat.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[debug] + +[startup] +command_after_plugins = "" +command_before_plugins = "" +display_logo = on +display_version = on +sys_rlimit = "" + +[look] +align_end_of_lines = message +align_multiline_words = on +bar_more_down = "++" +bar_more_left = "<<" +bar_more_right = ">>" +bar_more_up = "--" +bare_display_exit_on_input = on +bare_display_time_format = "%H:%M" +buffer_auto_renumber = on +buffer_notify_default = all +buffer_position = end +buffer_search_case_sensitive = off +buffer_search_force_default = off +buffer_search_regex = off +buffer_search_where = prefix_message +buffer_time_format = "%H:%M:%S" +buffer_time_same = "" +color_basic_force_bold = off +color_inactive_buffer = on +color_inactive_message = on +color_inactive_prefix = on +color_inactive_prefix_buffer = on +color_inactive_time = off +color_inactive_window = on +color_nick_offline = off +color_pairs_auto_reset = 5 +color_real_white = off +command_chars = "" +command_incomplete = off +confirm_quit = off +confirm_upgrade = off +day_change = on +day_change_message_1date = "-- %a, %d %b %Y --" +day_change_message_2dates = "-- %%a, %%d %%b %%Y (%a, %d %b %Y) --" +eat_newline_glitch = off +emphasized_attributes = "" +highlight = "" +highlight_regex = "" +highlight_tags = "" +hotlist_add_conditions = "${away} || ${buffer.num_displayed} == 0 || ${info:relay_client_count,weechat,connected} > 0" +hotlist_buffer_separator = ", " +hotlist_count_max = 2 +hotlist_count_min_msg = 2 +hotlist_names_count = 3 +hotlist_names_length = 0 +hotlist_names_level = 12 +hotlist_names_merged_buffers = off +hotlist_prefix = "H: " +hotlist_remove = merged +hotlist_short_names = on +hotlist_sort = group_time_asc +hotlist_suffix = "" +hotlist_unique_numbers = on +input_cursor_scroll = 20 +input_share = none +input_share_overwrite = off +input_undo_max = 32 +item_away_message = on +item_buffer_filter = "*" +item_buffer_zoom = "!" +item_mouse_status = "M" +item_time_format = "%H:%M" +jump_current_to_previous_buffer = on +jump_previous_buffer_when_closing = on +jump_smart_back_to_buffer = on +key_bind_safe = on +key_grab_delay = 800 +mouse = off +mouse_timer_delay = 100 +nick_color_force = "" +nick_color_hash = djb2 +nick_color_stop_chars = "_|[" +nick_prefix = "" +nick_suffix = "" +paste_auto_add_newline = on +paste_bracketed = on +paste_bracketed_timer_delay = 10 +paste_max_lines = 1 +prefix_action = " *" +prefix_align = right +prefix_align_max = 0 +prefix_align_min = 0 +prefix_align_more = "+" +prefix_align_more_after = on +prefix_buffer_align = right +prefix_buffer_align_max = 0 +prefix_buffer_align_more = "+" +prefix_buffer_align_more_after = on +prefix_error = "=!=" +prefix_join = "-->" +prefix_network = "--" +prefix_quit = "<--" +prefix_same_nick = "" +prefix_same_nick_middle = "" +prefix_suffix = "|" +quote_nick_prefix = "<" +quote_nick_suffix = ">" +quote_time_format = "%H:%M:%S" +read_marker = line +read_marker_always_show = off +read_marker_string = "- " +save_config_on_exit = on +save_config_with_fsync = off +save_layout_on_exit = none +scroll_amount = 3 +scroll_bottom_after_switch = off +scroll_page_percent = 100 +search_text_not_found_alert = on +separator_horizontal = "-" +separator_vertical = "" +tab_width = 1 +time_format = "%a, %d %b %Y %T" +window_auto_zoom = off +window_separator_horizontal = on +window_separator_vertical = on +window_title = "" +word_chars_highlight = "!\u00A0,-,_,|,alnum" +word_chars_input = "!\u00A0,-,_,|,alnum" + +[palette] + +[color] +bar_more = lightmagenta +chat = default +chat_bg = default +chat_buffer = white +chat_channel = white +chat_day_change = cyan +chat_delimiters = green +chat_highlight = yellow +chat_highlight_bg = magenta +chat_host = cyan +chat_inactive_buffer = default +chat_inactive_window = default +chat_nick = lightcyan +chat_nick_colors = "cyan,magenta,green,brown,lightblue,default,lightcyan,lightmagenta,lightgreen,blue" +chat_nick_offline = default +chat_nick_offline_highlight = default +chat_nick_offline_highlight_bg = blue +chat_nick_other = cyan +chat_nick_prefix = green +chat_nick_self = white +chat_nick_suffix = green +chat_prefix_action = white +chat_prefix_buffer = brown +chat_prefix_buffer_inactive_buffer = default +chat_prefix_error = yellow +chat_prefix_join = lightgreen +chat_prefix_more = lightmagenta +chat_prefix_network = magenta +chat_prefix_quit = lightred +chat_prefix_suffix = green +chat_read_marker = magenta +chat_read_marker_bg = default +chat_server = brown +chat_tags = red +chat_text_found = yellow +chat_text_found_bg = lightmagenta +chat_time = default +chat_time_delimiters = brown +chat_value = cyan +chat_value_null = blue +emphasized = yellow +emphasized_bg = magenta +input_actions = lightgreen +input_text_not_found = red +item_away = yellow +nicklist_away = cyan +nicklist_group = green +separator = blue +status_count_highlight = magenta +status_count_msg = brown +status_count_other = default +status_count_private = green +status_data_highlight = lightmagenta +status_data_msg = yellow +status_data_other = default +status_data_private = lightgreen +status_filter = green +status_more = yellow +status_mouse = green +status_name = white +status_name_ssl = lightgreen +status_nicklist_count = default +status_number = yellow +status_time = default + +[completion] +base_word_until_cursor = on +command_inline = on +default_template = "%(nicks)|%(irc_channels)" +nick_add_space = on +nick_case_sensitive = off +nick_completer = ":" +nick_first_only = off +nick_ignore_chars = "[]`_-^" +partial_completion_alert = on +partial_completion_command = off +partial_completion_command_arg = off +partial_completion_count = on +partial_completion_other = off +partial_completion_templates = "config_options" + +[history] +display_default = 5 +max_buffer_lines_minutes = 0 +max_buffer_lines_number = 4096 +max_commands = 100 +max_visited_buffers = 50 + +[proxy] + +[network] +connection_timeout = 60 +gnutls_ca_file = "/etc/ssl/certs/ca-certificates.crt" +gnutls_handshake_timeout = 30 +proxy_curl = "" + +[plugin] +autoload = "*" +debug = off +extension = ".so,.dll" +path = "%h/plugins" +save_config_on_unload = on + +[bar] +buffers.color_bg = default +buffers.color_delim = default +buffers.color_fg = default +buffers.conditions = "" +buffers.filling_left_right = vertical +buffers.filling_top_bottom = columns_vertical +buffers.hidden = on +buffers.items = "buffers" +buffers.position = bottom +buffers.priority = 0 +buffers.separator = on +buffers.size = 0 +buffers.size_max = 0 +buffers.type = root +buflist.color_bg = default +buflist.color_delim = default +buflist.color_fg = default +buflist.conditions = "" +buflist.filling_left_right = vertical +buflist.filling_top_bottom = columns_vertical +buflist.hidden = off +buflist.items = "buflist" +buflist.position = left +buflist.priority = 0 +buflist.separator = on +buflist.size = 0 +buflist.size_max = 0 +buflist.type = root +fset.color_bg = default +fset.color_delim = cyan +fset.color_fg = default +fset.conditions = "${buffer.full_name} == fset.fset" +fset.filling_left_right = vertical +fset.filling_top_bottom = horizontal +fset.hidden = off +fset.items = "fset" +fset.position = top +fset.priority = 0 +fset.separator = on +fset.size = 3 +fset.size_max = 3 +fset.type = window +input.color_bg = default +input.color_delim = cyan +input.color_fg = default +input.conditions = "" +input.filling_left_right = vertical +input.filling_top_bottom = horizontal +input.hidden = off +input.items = "[mode_indicator]+[input_prompt]+(away),[input_search], [input_paste],input_text,[vi_buffer]" +input.position = bottom +input.priority = 1000 +input.separator = off +input.size = 1 +input.size_max = 0 +input.type = window +nicklist.color_bg = default +nicklist.color_delim = cyan +nicklist.color_fg = default +nicklist.conditions = "${nicklist}" +nicklist.filling_left_right = vertical +nicklist.filling_top_bottom = columns_vertical +nicklist.hidden = off +nicklist.items = "buffer_nicklist" +nicklist.position = right +nicklist.priority = 200 +nicklist.separator = on +nicklist.size = 0 +nicklist.size_max = 0 +nicklist.type = window +status.color_bg = blue +status.color_delim = cyan +status.color_fg = default +status.conditions = "" +status.filling_left_right = vertical +status.filling_top_bottom = horizontal +status.hidden = off +status.items = "[time],[buffer_last_number],[buffer_plugin],buffer_number+:+buffer_name+(buffer_modes)+{buffer_nicklist_count}+buffer_zoom+buffer_filter,scroll,[lag],[hotlist],completion,cmd_completion" +status.position = bottom +status.priority = 500 +status.separator = off +status.size = 1 +status.size_max = 0 +status.type = window +title.color_bg = blue +title.color_delim = cyan +title.color_fg = default +title.conditions = "" +title.filling_left_right = vertical +title.filling_top_bottom = horizontal +title.hidden = off +title.items = "buffer_title" +title.position = top +title.priority = 500 +title.separator = off +title.size = 1 +title.size_max = 0 +title.type = window +vi_line_numbers.color_bg = default +vi_line_numbers.color_delim = default +vi_line_numbers.color_fg = default +vi_line_numbers.conditions = "" +vi_line_numbers.filling_left_right = vertical +vi_line_numbers.filling_top_bottom = vertical +vi_line_numbers.hidden = off +vi_line_numbers.items = "line_numbers" +vi_line_numbers.position = left +vi_line_numbers.priority = 0 +vi_line_numbers.separator = off +vi_line_numbers.size = 0 +vi_line_numbers.size_max = 0 +vi_line_numbers.type = window + +[layout] + +[notify] +python.uh-car.slack.com.#astro-ph = all +python.uh-car.slack.com.#bayf_python_project = all +python.uh-car.slack.com.#codereview = all +python.uh-car.slack.com.#general = all +python.uh-car.slack.com.#journalclub = all +python.uh-car.slack.com.#lowmass = all +python.uh-car.slack.com.#random = all +python.uh-car.slack.com.#starformation = all +python.uh-car.slack.com.ChrisLovell,Vijay = all +python.uh-car.slack.com.JanForbrich = all +python.uh-car.slack.com.jansky = all +python.uh-car.slack.com.MaxL = all +python.uh-car.slack.com.Mubela = all +python.uh-car.slack.com.Slackbot = all +python.uh-car.slack.com.VitoGraffagnino = all + +[filter] + +[key] +ctrl-? = "/input delete_previous_char" +ctrl-A = "/input move_beginning_of_line" +ctrl-B = "/input move_previous_char" +ctrl-C_ = "/input insert \x1F" +ctrl-Cb = "/input insert \x02" +ctrl-Cc = "/input insert \x03" +ctrl-Ci = "/input insert \x1D" +ctrl-Co = "/input insert \x0F" +ctrl-Cv = "/input insert \x16" +ctrl-D = "/input delete_next_char" +ctrl-E = "/input move_end_of_line" +ctrl-F = "/input move_next_char" +ctrl-H = "/input delete_previous_char" +ctrl-I = "/input complete_next" +ctrl-J = "/input return" +ctrl-K = "/input delete_end_of_line" +ctrl-L = "/window refresh" +ctrl-M = "/input return" +ctrl-N = "/buffer +1" +ctrl-P = "/buffer -1" +ctrl-R = "/input search_text_here" +ctrl-Sctrl-U = "/input set_unread" +ctrl-T = "/input transpose_chars" +ctrl-U = "/input delete_beginning_of_line" +ctrl-W = "/input delete_previous_word" +ctrl-W= = "/window balance" +ctrl-Wh = "/window left" +ctrl-Wj = "/window down" +ctrl-Wk = "/window up" +ctrl-Wl = "/window right" +ctrl-Wq = "/window merge" +ctrl-Ws = "/window splith" +ctrl-Wv = "/window splitv" +ctrl-Wx = "/window swap" +ctrl-X = "/input switch_active_buffer" +ctrl-Y = "/input clipboard_paste" +meta-meta-OP = "/bar scroll buflist * b" +meta-meta-OQ = "/bar scroll buflist * e" +meta-meta2-11~ = "/bar scroll buflist * b" +meta-meta2-12~ = "/bar scroll buflist * e" +meta-meta2-1~ = "/window scroll_top" +meta-meta2-23~ = "/bar scroll nicklist * b" +meta-meta2-24~ = "/bar scroll nicklist * e" +meta-meta2-4~ = "/window scroll_bottom" +meta-meta2-5~ = "/window scroll_up" +meta-meta2-6~ = "/window scroll_down" +meta-meta2-7~ = "/window scroll_top" +meta-meta2-8~ = "/window scroll_bottom" +meta-meta2-A = "/buffer -1" +meta-meta2-B = "/buffer +1" +meta-meta2-C = "/buffer +1" +meta-meta2-D = "/buffer -1" +meta-- = "/filter toggle @" +meta-/ = "/input jump_last_buffer_displayed" +meta-0 = "/buffer *10" +meta-1 = "/buffer *1" +meta-2 = "/buffer *2" +meta-3 = "/buffer *3" +meta-4 = "/buffer *4" +meta-5 = "/buffer *5" +meta-6 = "/buffer *6" +meta-7 = "/buffer *7" +meta-8 = "/buffer *8" +meta-9 = "/buffer *9" +meta-< = "/input jump_previously_visited_buffer" +meta-= = "/filter toggle" +meta-> = "/input jump_next_visited_buffer" +meta-OA = "/input history_global_previous" +meta-OB = "/input history_global_next" +meta-OC = "/input move_next_word" +meta-OD = "/input move_previous_word" +meta-OF = "/input move_end_of_line" +meta-OH = "/input move_beginning_of_line" +meta-OP = "/bar scroll buflist * -100%" +meta-OQ = "/bar scroll buflist * +100%" +meta-Oa = "/input history_global_previous" +meta-Ob = "/input history_global_next" +meta-Oc = "/input move_next_word" +meta-Od = "/input move_previous_word" +meta2-11^ = "/bar scroll buflist * -100%" +meta2-11~ = "/bar scroll buflist * -100%" +meta2-12^ = "/bar scroll buflist * +100%" +meta2-12~ = "/bar scroll buflist * +100%" +meta2-15~ = "/buffer -1" +meta2-17~ = "/buffer +1" +meta2-18~ = "/window -1" +meta2-19~ = "/window +1" +meta2-1;3A = "/buffer -1" +meta2-1;3B = "/buffer +1" +meta2-1;3C = "/buffer +1" +meta2-1;3D = "/buffer -1" +meta2-1;3F = "/window scroll_bottom" +meta2-1;3H = "/window scroll_top" +meta2-1;3P = "/bar scroll buflist * b" +meta2-1;3Q = "/bar scroll buflist * e" +meta2-1;5A = "/input history_global_previous" +meta2-1;5B = "/input history_global_next" +meta2-1;5C = "/input move_next_word" +meta2-1;5D = "/input move_previous_word" +meta2-1;5P = "/bar scroll buflist * -100%" +meta2-1;5Q = "/bar scroll buflist * +100%" +meta2-1~ = "/input move_beginning_of_line" +meta2-200~ = "/input paste_start" +meta2-201~ = "/input paste_stop" +meta2-20~ = "/bar scroll title * -30%" +meta2-21~ = "/bar scroll title * +30%" +meta2-23;3~ = "/bar scroll nicklist * b" +meta2-23~ = "/bar scroll nicklist * -100%" +meta2-24;3~ = "/bar scroll nicklist * e" +meta2-24~ = "/bar scroll nicklist * +100%" +meta2-3~ = "/input delete_next_char" +meta2-4~ = "/input move_end_of_line" +meta2-5;3~ = "/window scroll_up" +meta2-5~ = "/window page_up" +meta2-6;3~ = "/window scroll_down" +meta2-6~ = "/window page_down" +meta2-7~ = "/input move_beginning_of_line" +meta2-8~ = "/input move_end_of_line" +meta2-A = "/input history_previous" +meta2-B = "/input history_next" +meta2-C = "/input move_next_char" +meta2-D = "/input move_previous_char" +meta2-F = "/input move_end_of_line" +meta2-G = "/window page_down" +meta2-H = "/input move_beginning_of_line" +meta2-I = "/window page_up" +meta2-Z = "/input complete_previous" +meta2-[E = "/buffer -1" +meta-_ = "/input redo" +meta-a = "/input jump_smart" +meta-b = "/input move_previous_word" +meta-d = "/input delete_next_word" +meta-f = "/input move_next_word" +meta-h = "/input hotlist_clear" +meta-j01 = "/buffer *1" +meta-j02 = "/buffer *2" +meta-j03 = "/buffer *3" +meta-j04 = "/buffer *4" +meta-j05 = "/buffer *5" +meta-j06 = "/buffer *6" +meta-j07 = "/buffer *7" +meta-j08 = "/buffer *8" +meta-j09 = "/buffer *9" +meta-j10 = "/buffer *10" +meta-j11 = "/buffer *11" +meta-j12 = "/buffer *12" +meta-j13 = "/buffer *13" +meta-j14 = "/buffer *14" +meta-j15 = "/buffer *15" +meta-j16 = "/buffer *16" +meta-j17 = "/buffer *17" +meta-j18 = "/buffer *18" +meta-j19 = "/buffer *19" +meta-j20 = "/buffer *20" +meta-j21 = "/buffer *21" +meta-j22 = "/buffer *22" +meta-j23 = "/buffer *23" +meta-j24 = "/buffer *24" +meta-j25 = "/buffer *25" +meta-j26 = "/buffer *26" +meta-j27 = "/buffer *27" +meta-j28 = "/buffer *28" +meta-j29 = "/buffer *29" +meta-j30 = "/buffer *30" +meta-j31 = "/buffer *31" +meta-j32 = "/buffer *32" +meta-j33 = "/buffer *33" +meta-j34 = "/buffer *34" +meta-j35 = "/buffer *35" +meta-j36 = "/buffer *36" +meta-j37 = "/buffer *37" +meta-j38 = "/buffer *38" +meta-j39 = "/buffer *39" +meta-j40 = "/buffer *40" +meta-j41 = "/buffer *41" +meta-j42 = "/buffer *42" +meta-j43 = "/buffer *43" +meta-j44 = "/buffer *44" +meta-j45 = "/buffer *45" +meta-j46 = "/buffer *46" +meta-j47 = "/buffer *47" +meta-j48 = "/buffer *48" +meta-j49 = "/buffer *49" +meta-j50 = "/buffer *50" +meta-j51 = "/buffer *51" +meta-j52 = "/buffer *52" +meta-j53 = "/buffer *53" +meta-j54 = "/buffer *54" +meta-j55 = "/buffer *55" +meta-j56 = "/buffer *56" +meta-j57 = "/buffer *57" +meta-j58 = "/buffer *58" +meta-j59 = "/buffer *59" +meta-j60 = "/buffer *60" +meta-j61 = "/buffer *61" +meta-j62 = "/buffer *62" +meta-j63 = "/buffer *63" +meta-j64 = "/buffer *64" +meta-j65 = "/buffer *65" +meta-j66 = "/buffer *66" +meta-j67 = "/buffer *67" +meta-j68 = "/buffer *68" +meta-j69 = "/buffer *69" +meta-j70 = "/buffer *70" +meta-j71 = "/buffer *71" +meta-j72 = "/buffer *72" +meta-j73 = "/buffer *73" +meta-j74 = "/buffer *74" +meta-j75 = "/buffer *75" +meta-j76 = "/buffer *76" +meta-j77 = "/buffer *77" +meta-j78 = "/buffer *78" +meta-j79 = "/buffer *79" +meta-j80 = "/buffer *80" +meta-j81 = "/buffer *81" +meta-j82 = "/buffer *82" +meta-j83 = "/buffer *83" +meta-j84 = "/buffer *84" +meta-j85 = "/buffer *85" +meta-j86 = "/buffer *86" +meta-j87 = "/buffer *87" +meta-j88 = "/buffer *88" +meta-j89 = "/buffer *89" +meta-j90 = "/buffer *90" +meta-j91 = "/buffer *91" +meta-j92 = "/buffer *92" +meta-j93 = "/buffer *93" +meta-j94 = "/buffer *94" +meta-j95 = "/buffer *95" +meta-j96 = "/buffer *96" +meta-j97 = "/buffer *97" +meta-j98 = "/buffer *98" +meta-j99 = "/buffer *99" +meta-k = "/input grab_key_command" +meta-l = "/window bare" +meta-m = "/mute mouse toggle" +meta-n = "/window scroll_next_highlight" +meta-p = "/window scroll_previous_highlight" +meta-r = "/input delete_line" +meta-s = "/mute aspell toggle" +meta-u = "/window scroll_unread" +meta-x = "/input zoom_merged_buffer" +meta-z = "/window zoom" +ctrl-^ = "/input jump_last_buffer_displayed" +ctrl-_ = "/input undo" + +[key_search] +ctrl-I = "/input search_switch_where" +ctrl-J = "/input search_stop_here" +ctrl-M = "/input search_stop_here" +ctrl-Q = "/input search_stop" +ctrl-R = "/input search_switch_regex" +meta2-A = "/input search_previous" +meta2-B = "/input search_next" +meta-c = "/input search_switch_case" + +[key_cursor] +ctrl-J = "/cursor stop" +ctrl-M = "/cursor stop" +meta-meta2-A = "/cursor move area_up" +meta-meta2-B = "/cursor move area_down" +meta-meta2-C = "/cursor move area_right" +meta-meta2-D = "/cursor move area_left" +meta2-1;3A = "/cursor move area_up" +meta2-1;3B = "/cursor move area_down" +meta2-1;3C = "/cursor move area_right" +meta2-1;3D = "/cursor move area_left" +meta2-A = "/cursor move up" +meta2-B = "/cursor move down" +meta2-C = "/cursor move right" +meta2-D = "/cursor move left" +@chat(python.*):D = "hsignal:slack_cursor_delete" +@chat(python.*):L = "hsignal:slack_cursor_linkarchive" +@chat(python.*):M = "hsignal:slack_cursor_message" +@chat(python.*):R = "hsignal:slack_cursor_reply" +@chat(python.*):T = "hsignal:slack_cursor_thread" +@item(buffer_nicklist):K = "/window ${_window_number};/kickban ${nick}" +@item(buffer_nicklist):b = "/window ${_window_number};/ban ${nick}" +@item(buffer_nicklist):k = "/window ${_window_number};/kick ${nick}" +@item(buffer_nicklist):q = "/window ${_window_number};/query ${nick};/cursor stop" +@item(buffer_nicklist):w = "/window ${_window_number};/whois ${nick}" +@chat:Q = "hsignal:chat_quote_time_prefix_message;/cursor stop" +@chat:m = "hsignal:chat_quote_message;/cursor stop" +@chat:q = "hsignal:chat_quote_prefix_message;/cursor stop" + +[key_mouse] +@bar(buffers):ctrl-wheeldown = "hsignal:buffers_mouse" +@bar(buffers):ctrl-wheelup = "hsignal:buffers_mouse" +@bar(buflist):ctrl-wheeldown = "hsignal:buflist_mouse" +@bar(buflist):ctrl-wheelup = "hsignal:buflist_mouse" +@bar(input):button2 = "/input grab_mouse_area" +@bar(nicklist):button1-gesture-down = "/bar scroll nicklist ${_window_number} +100%" +@bar(nicklist):button1-gesture-down-long = "/bar scroll nicklist ${_window_number} e" +@bar(nicklist):button1-gesture-up = "/bar scroll nicklist ${_window_number} -100%" +@bar(nicklist):button1-gesture-up-long = "/bar scroll nicklist ${_window_number} b" +@chat(fset.fset):button1 = "/window ${_window_number};/fset -go ${_chat_line_y}" +@chat(fset.fset):button2* = "hsignal:fset_mouse" +@chat(fset.fset):wheeldown = "/fset -down 5" +@chat(fset.fset):wheelup = "/fset -up 5" +@chat(python.*):button2 = "hsignal:slack_mouse" +@chat(script.scripts):button1 = "/window ${_window_number};/script go ${_chat_line_y}" +@chat(script.scripts):button2 = "/window ${_window_number};/script go ${_chat_line_y};/script installremove -q ${script_name_with_extension}" +@chat(script.scripts):wheeldown = "/script down 5" +@chat(script.scripts):wheelup = "/script up 5" +@item(buffer_nicklist):button1 = "/window ${_window_number};/query ${nick}" +@item(buffer_nicklist):button1-gesture-left = "/window ${_window_number};/kick ${nick}" +@item(buffer_nicklist):button1-gesture-left-long = "/window ${_window_number};/kickban ${nick}" +@item(buffer_nicklist):button2 = "/window ${_window_number};/whois ${nick}" +@item(buffer_nicklist):button2-gesture-left = "/window ${_window_number};/ban ${nick}" +@item(buffers):button1* = "hsignal:buffers_mouse" +@item(buffers):button2* = "hsignal:buffers_mouse" +@item(buflist):button1* = "hsignal:buflist_mouse" +@item(buflist):button2* = "hsignal:buflist_mouse" +@item(buflist2):button1* = "hsignal:buflist_mouse" +@item(buflist2):button2* = "hsignal:buflist_mouse" +@item(buflist3):button1* = "hsignal:buflist_mouse" +@item(buflist3):button2* = "hsignal:buflist_mouse" +@bar:wheeldown = "/bar scroll ${_bar_name} ${_window_number} +20%" +@bar:wheelup = "/bar scroll ${_bar_name} ${_window_number} -20%" +@chat:button1 = "/window ${_window_number}" +@chat:button1-gesture-left = "/window ${_window_number};/buffer -1" +@chat:button1-gesture-left-long = "/window ${_window_number};/buffer 1" +@chat:button1-gesture-right = "/window ${_window_number};/buffer +1" +@chat:button1-gesture-right-long = "/window ${_window_number};/input jump_last_buffer" +@chat:ctrl-wheeldown = "/window scroll_horiz -window ${_window_number} +10%" +@chat:ctrl-wheelup = "/window scroll_horiz -window ${_window_number} -10%" +@chat:wheeldown = "/window scroll_down -window ${_window_number}" +@chat:wheelup = "/window scroll_up -window ${_window_number}" +@*:button3 = "/cursor go ${_x},${_y}" diff --git a/.weechat/weecord.conf b/.weechat/weecord.conf new file mode 100644 index 0000000..d41f7f2 --- /dev/null +++ b/.weechat/weecord.conf @@ -0,0 +1,23 @@ +# +# weechat -- weecord.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[main] +autojoin_channels = "" +autostart = off +irc_mode = off +message_load_count = 25 +send_typing_events = off +token = "" +typing_messages = off +use_presence = off +user_typing_list_expanded = off +user_typing_list_max = 3 +watched_channels = "" diff --git a/.weechat/xfer.conf b/.weechat/xfer.conf new file mode 100644 index 0000000..d1dea52 --- /dev/null +++ b/.weechat/xfer.conf @@ -0,0 +1,48 @@ +# +# weechat -- xfer.conf +# +# WARNING: It is NOT recommended to edit this file by hand, +# especially if WeeChat is running. +# +# Use /set or similar command to change settings in WeeChat. +# +# For more info, see: https://weechat.org/doc/quickstart +# + +[look] +auto_open_buffer = on +progress_bar_size = 20 +pv_tags = "notify_private" + +[color] +status_aborted = lightred +status_active = lightblue +status_connecting = yellow +status_done = lightgreen +status_failed = lightred +status_waiting = lightcyan +text = default +text_bg = default +text_selected = white + +[network] +blocksize = 65536 +fast_send = on +own_ip = "" +port_range = "" +send_ack = on +speed_limit_recv = 0 +speed_limit_send = 0 +timeout = 300 + +[file] +auto_accept_chats = off +auto_accept_files = off +auto_accept_nicks = "" +auto_check_crc32 = off +auto_rename = on +auto_resume = on +convert_spaces = on +download_path = "%h/xfer" +upload_path = "~" +use_nick_in_filename = on |
