summaryrefslogtreecommitdiff
path: root/.weechat/python
diff options
context:
space:
mode:
Diffstat (limited to '.weechat/python')
l---------.weechat/python/autoload/notify_send.py1
l---------.weechat/python/autoload/vimode.py1
l---------.weechat/python/autoload/wee_slack.py1
-rw-r--r--.weechat/python/notify_send.py755
-rw-r--r--.weechat/python/vimode.py1877
-rw-r--r--.weechat/python/wee_slack.py5013
6 files changed, 7648 insertions, 0 deletions
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('&', '&amp;')
+ message = message.replace('<', '&lt;')
+ message = message.replace('>', '&gt;')
+ 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('&', '&amp;')
+ .replace('<', '&lt;')
+ .replace('>', '&gt;'))
+
+ 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("&lt;", "<") \
+ .replace("&gt;", ">") \
+ .replace("&amp;", "&")
+
+
+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)