diff options
-rw-r--r-- | .flake8 | 7 | ||||
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | .travis.yml | 13 | ||||
-rw-r--r-- | README.md | 119 | ||||
-rw-r--r-- | _pytest/conftest.py | 18 | ||||
-rw-r--r-- | _pytest/test_everything.py | 2 | ||||
-rw-r--r-- | _pytest/test_linkifytext.py | 9 | ||||
-rw-r--r-- | _pytest/test_presencechange.py | 2 | ||||
-rw-r--r-- | _pytest/test_process_message.py | 2 | ||||
-rw-r--r-- | _pytest/test_processreply.py | 2 | ||||
-rw-r--r-- | _pytest/test_processteamjoin.py | 2 | ||||
-rw-r--r-- | _pytest/test_sendmessage.py | 2 | ||||
-rw-r--r-- | _pytest/test_topic_command.py | 96 | ||||
-rw-r--r-- | _pytest/test_unfurl.py | 59 | ||||
-rw-r--r-- | _pytest/test_unwrap_attachments.py | 150 | ||||
-rw-r--r-- | _pytest/test_utf8_helpers.py | 72 | ||||
-rw-r--r-- | wee_slack.py | 1917 | ||||
-rw-r--r-- | weemoji.json | 1349 |
18 files changed, 3192 insertions, 632 deletions
@@ -0,0 +1,7 @@ +[flake8] +select = E901,E999,F821,F822,F823 +count = True +max-complexity = 10 +max-line-length = 120 +show-source = True +statistics = True diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8673f9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.cache/ +*.sublime-* diff --git a/.travis.yml b/.travis.yml index 6739e1c..a163da4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,19 @@ language: python python: - "2.7" +cache: pip + install: - - 'pip install websocket-client pytest' + - pip install flake8 pytest websocket-client + +before_script: + # stop the build if there are Python syntax errors or undefined names + - flake8 . + # exit-zero treats all errors as warnings. + - flake8 . --exit-zero --select=C,E,F,W -script: python -m pytest +script: + - python -m pytest notifications: irc: "chat.freenode.net#wee-slack-dev" @@ -1,29 +1,24 @@ - - wee-slack ========= -**News:** - 1.0-RC1 is here. It is a pretty massive refactor, and fixes many of the current issues listed on github. Because there was no good way to do this, it breaks some/many existing PRs. _(please report bugs in #wee-slack on freenode)_ - A WeeChat native client for Slack.com. Provides supplemental features only available in the web/mobile clients such as: synchronizing read markers, typing notification, threads (and more)! Connects via the Slack API, and maintains a persistent websocket for notification of events. -![animated screenshot](https://github.com/rawdigits/wee-slack/raw/master/docs/slack.gif) +![animated screenshot](https://github.com/wee-slack/wee-slack/raw/master/docs/slack.gif) Features -------- - * **New** [Threads](#threads) support! - * Slash commands (including custom ones!) - * Upload to slack capabilities! - * Emoji reactions! + * [Threads](#threads) support + * [Slack Status](#status) support + * Slash commands (including custom ones) + * Upload to slack capabilities + * Emoji reactions * Edited messages work just like the official clients, where the original message changes and has (edited) appended. * Unfurled urls dont generate a new message, but replace the original with more info as it is received. * Regex style message editing (s/oldtext/newtext/) - * Caches message history, making startup MUCH faster * Smarter redraw of dynamic buffer info (much lower CPU %) * beta UTF-8 support * Doesn't use IRC gateway. Connects directly with Slack via API/Websocket - * Multiple Teams supported! Just add multiple api tokens separated by commas + * Multiple Teams supported. Just add multiple api tokens separated by commas * Replays history automatically during startup. (and sets read marker to the correct position in history) * Open channels synchronized with Slack. When you open/close a channel on another client it is reflected in wee-slack * Colorized nicks in buffer list when used with buffers.pl @@ -33,7 +28,7 @@ Features * Away/back status handling * Expands/shows metadata for things like tweets/links * Displays edited messages (slack.com irc mode currently doesn't show these) - * *Super fun* debug mode. See what the websocket is saying with `/slack debug` + * *Super fun* debug mode. See what the websocket is saying In Development -------------- @@ -75,7 +70,7 @@ pkg install py27-websocket-client py27-six #### 2. copy wee_slack.py to ~/.weechat/python/autoload ``` -wget https://raw.githubusercontent.com/rawdigits/wee-slack/master/wee_slack.py +wget https://raw.githubusercontent.com/wee-slack/wee-slack/master/wee_slack.py cp wee_slack.py ~/.weechat/python/autoload ``` @@ -87,23 +82,48 @@ weechat **NOTE:** If weechat is already running, the script can be loaded using ``/python load python/autoload/wee_slack.py`` #### 4. Add your Slack API key(s) + +Log in to Slack: + ``` -/set plugins.var.python.slack_extension.slack_api_token [YOUR_SLACK_TOKEN] +/slack register +``` + +This command prints a link you should open in your browser to authorize WeeChat +with Slack. Once you've accomplished this, copy the "code" portion of the URL in +the browser and pass it to this command: + +``` +/slack register [YOUR_SLACK_TOKEN] +``` + +Your Slack team is now added, and you can complete setup by restarting the +wee-slack plugin. + +``` +/python reload slack +``` + +Alternatively, you can click the "Request token" button at the +[Slack legacy token page](https://api.slack.com/custom-integrations/legacy-tokens), +and paste it directly into your settings: + +``` +/set plugins.var.python.slack.slack_api_token [YOUR_SLACK_TOKEN] ``` -^^ (find this at https://api.slack.com/custom-integrations/legacy-tokens using the "Request token" button) If you don't want to store your API token in plaintext you can use the secure features of weechat: ``` /secure passphrase this is a super secret password /secure set slack_token [YOUR_SLACK_TOKEN] -/set plugins.var.python.slack_extension.slack_api_token ${sec.data.slack_token} +/set plugins.var.python.slack.slack_api_token ${sec.data.slack_token} ``` ##### Optional: If you would like to connect to multiple groups, use the above command with multiple tokens separated by commas. (NO SPACES) ``` -/set plugins.var.python.slack_extension.slack_api_token [token1],[token2],[token3] +/set plugins.var.python.slack.slack_api_token [token1],[token2],[token3] ``` ### 5. $PROFIT$ @@ -118,13 +138,12 @@ Commands Join a channel: ``` /join [channel] -/slack join [channel] ``` -Start a direct chat with someone: +Start a direct chat with someone or multiple users: ``` -/query [username] -/slack talk [username] +/query <username>[,<username2>[,<username3>...]] +/slack talk <username>[,<username2>[,<username3>...]] ``` List channels: @@ -155,6 +174,11 @@ Modify previous message: s/old text/new text/ ``` +Modify 3rd previous message: +``` +3s/old text/new text/ +``` + Replace all instances of text in previous message: ``` s/old text/new text/g @@ -171,11 +195,6 @@ Add a reaction to the nth last message. The number can be omitted and defaults t 3+:smile: ``` -Set all read markers to a specific time: -``` -/slack setallreadmarkers (time in epoch) -``` - Upload a file to the current slack buffer: ``` /slack upload [file_path] @@ -186,9 +205,10 @@ Run a Slack slash command. Simply prepend `/slack slash` to what you'd type in t /slack slash /desiredcommand arg1 arg2 arg3 ``` -Debug mode: +To send a command as a normal message instead of performing the action, prefix it with a slash or a space, like so: ``` -/slack debug +//slack + s/a/b/ ``` #### Threads @@ -209,27 +229,37 @@ Label a thread with a memorable name. The above command will open a channel call ``` _Note: labels do not persist once a thread buffer is closed_ -Optional settings ------------------ +#### Status -Turn off colorized nicks: +Set your Slack status on a given team: ``` -/set plugins.var.python.slack_extension.colorize_nicks 0 +/slack status [:emoji:] [Status message] ``` -Turn on colorized messages (messages match nick color): +Example: ``` -/set plugins.var.python.slack_extension.colorize_nicks 1 +/slack status :ghost: Boo! ``` +#### Emoji tab completions + +To enable tab completion of emojis, copy or symlink the `weemoji.json` file to your weechat config directory (e.g. `~/.weechat`). Then append `|%(emoji)` to the `weechat.completion.default_template` config option, e.g. like this: + +``` +/set weechat.completion.default_template "%(nicks)|%(irc_channels)|%(emoji)" +``` + +Optional settings +----------------- + Set channel prefix to something other than my-slack-subdomain.slack.com (e.g. when using buffers.pl): ``` -/set plugins.var.python.slack_extension.server_alias.my-slack-subdomain "mysub" +/set plugins.var.python.slack.server_aliases "my-slack-subdomain:mysub,other-domain:coolbeans" ``` Show who added each reaction. Makes reactions appear like `[:smile:(@nick1,@nick2)]` instead of `[:smile:2]`. ``` -/set plugins.var.python.slack_extension.show_reaction_nicks on +/set plugins.var.python.slack.show_reaction_nicks on ``` Show typing notification in main bar (slack_typing_notice): @@ -242,6 +272,21 @@ Show channel name in hotlist after activity /set weechat.look.hotlist_names_level 14 ``` +Debugging +-------------- + +Enable debug mode and change debug level (default 3, decrease to increase logging and vice versa): +``` +/set plugins.var.python.slack.debug_mode on +/set plugins.var.python.slack.debug_level 2 +``` + +Dump the JSON responses in `/tmp/weeslack-debug/`. Requires a script reload. +``` +/set plugins.var.python.slack.record_events true +``` + + Support -------------- diff --git a/_pytest/conftest.py b/_pytest/conftest.py index 7a6f39a..74ed537 100644 --- a/_pytest/conftest.py +++ b/_pytest/conftest.py @@ -26,12 +26,13 @@ def mock_websocket(): return fakewebsocket() @pytest.fixture -def realish_eventrouter(): +def realish_eventrouter(mock_weechat): e = EventRouter() context = e.store_context(SlackRequest('xoxoxoxox', "rtm.start", {"meh": "blah"})) rtmstartdata = open('_pytest/data/http/rtm.start.json', 'r').read() e.receive_httprequest_callback(context, 1, 0, rtmstartdata, 4) - e.handle_next() + while len(e.queue): + e.handle_next() #e.sc is just shortcuts to these items e.sc = {} e.sc["team_id"] = e.teams.keys()[0] @@ -48,7 +49,9 @@ class FakeWeechat(): this is the thing that acts as "w." everywhere.. basically mock out all of the weechat calls here i guess """ - WEECHAT_RC_OK = True + WEECHAT_RC_ERROR = 0 + WEECHAT_RC_OK = 1 + WEECHAT_RC_OK_EAT = 2 def __init__(self): pass @@ -71,6 +74,14 @@ class FakeWeechat(): return "0x8a8a8a8b" def prefix(self, type): return "" + def config_get_plugin(self, key): + return "" + def config_get(self, key): + return "" + def config_string(self, key): + return "" + def color(self, name): + return "" def __getattr__(self, name): def method(*args): pass @@ -87,6 +98,7 @@ def mock_weechat(): wee_slack.slack_debug = "debug_buffer_ptr" wee_slack.STOP_TALKING_TO_SLACK = False wee_slack.proc = {} + wee_slack.weechat_version = 0x10500000 pass diff --git a/_pytest/test_everything.py b/_pytest/test_everything.py index a121541..c85fc15 100644 --- a/_pytest/test_everything.py +++ b/_pytest/test_everything.py @@ -4,7 +4,7 @@ import json #from wee_slack import render from wee_slack import ProcessNotImplemented -def test_process_message(monkeypatch, realish_eventrouter, mock_websocket): +def test_everything(realish_eventrouter, mock_websocket): eventrouter = realish_eventrouter diff --git a/_pytest/test_linkifytext.py b/_pytest/test_linkifytext.py index f9da3f9..56bf1b5 100644 --- a/_pytest/test_linkifytext.py +++ b/_pytest/test_linkifytext.py @@ -4,3 +4,12 @@ from wee_slack import linkify_text # linkify_text('@ryan') # assert False + + +def test_linkifytext_does_partial_html_entity_encoding(realish_eventrouter): + team = realish_eventrouter.teams.values()[0] + channel = team.channels.values()[0] + + text = linkify_text('& < > \' "', team, channel) + + assert text == '& < > \' "' diff --git a/_pytest/test_presencechange.py b/_pytest/test_presencechange.py index b4202fa..4e02640 100644 --- a/_pytest/test_presencechange.py +++ b/_pytest/test_presencechange.py @@ -1,5 +1,5 @@ -def test_PresenceChange(monkeypatch, realish_eventrouter, mock_websocket): +def test_PresenceChange(realish_eventrouter, mock_websocket): e = realish_eventrouter diff --git a/_pytest/test_process_message.py b/_pytest/test_process_message.py index e2447f7..2e0b31e 100644 --- a/_pytest/test_process_message.py +++ b/_pytest/test_process_message.py @@ -2,7 +2,7 @@ import json from wee_slack import render -def test_process_message(monkeypatch, realish_eventrouter, mock_websocket): +def test_process_message(realish_eventrouter, mock_websocket): e = realish_eventrouter diff --git a/_pytest/test_processreply.py b/_pytest/test_processreply.py index a725f23..041a1db 100644 --- a/_pytest/test_processreply.py +++ b/_pytest/test_processreply.py @@ -1,6 +1,6 @@ #from wee_slack import process_reply -def test_process_reply(monkeypatch, realish_eventrouter, mock_websocket): +def test_process_reply(realish_eventrouter, mock_websocket): e = realish_eventrouter diff --git a/_pytest/test_processteamjoin.py b/_pytest/test_processteamjoin.py index 00a8b4c..c7c199f 100644 --- a/_pytest/test_processteamjoin.py +++ b/_pytest/test_processteamjoin.py @@ -3,7 +3,7 @@ import json from wee_slack import ProcessNotImplemented -def test_process_reply(monkeypatch, mock_websocket, realish_eventrouter): +def test_process_team_join(mock_websocket, realish_eventrouter): eventrouter = realish_eventrouter diff --git a/_pytest/test_sendmessage.py b/_pytest/test_sendmessage.py index a87942d..42c22a6 100644 --- a/_pytest/test_sendmessage.py +++ b/_pytest/test_sendmessage.py @@ -1,5 +1,5 @@ -def test_send_message(monkeypatch, realish_eventrouter, mock_websocket): +def test_send_message(realish_eventrouter, mock_websocket): e = realish_eventrouter t = e.teams.keys()[0] diff --git a/_pytest/test_topic_command.py b/_pytest/test_topic_command.py new file mode 100644 index 0000000..9d9a35e --- /dev/null +++ b/_pytest/test_topic_command.py @@ -0,0 +1,96 @@ +import wee_slack +from wee_slack import parse_topic_command, topic_command_cb +from mock import patch + + +def test_parse_topic_without_arguments(): + channel_name, topic = parse_topic_command('/topic') + + assert channel_name is None + assert topic is None + + +def test_parse_topic_with_text(): + channel_name, topic = parse_topic_command('/topic some topic text') + + assert channel_name is None + assert topic == 'some topic text' + + +def test_parse_topic_with_delete(): + channel_name, topic = parse_topic_command('/topic -delete') + + assert channel_name is None + assert topic == '' + + +def test_parse_topic_with_channel(): + channel_name, topic = parse_topic_command('/topic #general') + + assert channel_name == 'general' + assert topic is None + + +def test_parse_topic_with_channel_and_text(): + channel_name, topic = parse_topic_command( + '/topic #general some topic text') + + assert channel_name == 'general' + assert topic == 'some topic text' + + +def test_parse_topic_with_channel_and_delete(): + channel_name, topic = parse_topic_command('/topic #general -delete') + + assert channel_name == 'general' + assert topic == '' + + +def test_call_topic_without_arguments(realish_eventrouter): + team = realish_eventrouter.teams.values()[-1] + channel = team.channels.values()[-1] + current_buffer = channel.channel_buffer + wee_slack.EVENTROUTER = realish_eventrouter + + command = '/topic' + + with patch('wee_slack.w.prnt') as fake_prnt: + result = topic_command_cb(None, current_buffer, command) + fake_prnt.assert_called_with( + channel.channel_buffer, + 'Topic for {} is "{}"'.format(channel.name, channel.topic), + ) + assert result == wee_slack.w.WEECHAT_RC_OK_EAT + + +def test_call_topic_with_unknown_channel(realish_eventrouter): + team = realish_eventrouter.teams.values()[-1] + channel = team.channels.values()[-1] + current_buffer = channel.channel_buffer + wee_slack.EVENTROUTER = realish_eventrouter + + command = '/topic #nonexisting' + + with patch('wee_slack.w.prnt') as fake_prnt: + result = topic_command_cb(None, current_buffer, command) + fake_prnt.assert_called_with( + team.channel_buffer, + "#nonexisting: No such channel", + ) + assert result == wee_slack.w.WEECHAT_RC_OK_EAT + + +def test_call_topic_with_channel_and_string(realish_eventrouter): + team = realish_eventrouter.teams.values()[-1] + channel = team.channels.values()[-1] + current_buffer = channel.channel_buffer + wee_slack.EVENTROUTER = realish_eventrouter + + command = '/topic #general new topic' + + result = topic_command_cb(None, current_buffer, command) + request = realish_eventrouter.queue[-1] + assert request.request == 'channels.setTopic' + assert request.post_data == { + 'channel': 'C407ABS94', 'token': 'xoxoxoxox', 'topic': 'new topic'} + assert result == wee_slack.w.WEECHAT_RC_OK_EAT diff --git a/_pytest/test_unfurl.py b/_pytest/test_unfurl.py index b631888..40674b4 100644 --- a/_pytest/test_unfurl.py +++ b/_pytest/test_unfurl.py @@ -10,17 +10,17 @@ slack = wee_slack 'output': "foo", }, { - 'input': "<@U2147483697|@othernick>: foo", - 'output': "@testuser: foo", + 'input': "<@U407ABLLW|@othernick>: foo", + 'output': "@alice: foo", 'ignore_alt_text': True, }, { - 'input': "foo <#C2147483705|#otherchannel> foo", + 'input': "foo <#C407ABS94|otherchannel> foo", 'output': "foo #otherchannel foo", }, { - 'input': "foo <#C2147483705> foo", - 'output': "foo #test-chan foo", + 'input': "foo <#C407ABS94> foo", + 'output': "foo #general foo", }, { 'input': "url: <https://example.com|example> suffix", @@ -31,23 +31,44 @@ slack = wee_slack 'output': "url: https://example.com (example with spaces) suffix", }, { - 'input': "<@U2147483697|@othernick> multiple unfurl <https://example.com|example with spaces>", + 'input': "url: <https://example.com|example.com> suffix", + 'output': "url: example.com suffix", + 'auto_link_display': 'text', + }, + { + 'input': "url: <https://example.com|different text> suffix", + 'output': "url: https://example.com (different text) suffix", + 'auto_link_display': 'text', + }, + { + 'input': "url: <https://example.com|different text> suffix", + 'output': "url: https://example.com (different text) suffix", + 'auto_link_display': 'url', + }, + { + 'input': "url: <https://example.com|example.com> suffix", + 'output': "url: https://example.com suffix", + 'auto_link_display': 'url', + }, + { + 'input': "<@U407ABLLW|@othernick> multiple unfurl <https://example.com|example with spaces>", 'output': "@othernick multiple unfurl https://example.com (example with spaces)", }, { - 'input': "try the #test-chan channel", - 'output': "try the #test-chan channel", + 'input': "try the #general channel", + 'output': "try the #general channel", + }, + { + 'input': "<@U407ABLLW> I think 3 > 2", + 'output': "@alice I think 3 > 2", }, )) -def test_unfurl_refs(case): - pass - #print myslack - #slack.servers = myslack.server - #slack.channels = myslack.channel - #slack.users = myslack.user - #slack.message_cache = {} - #slack.servers[0].users = myslack.user - #print myslack.channel[0].identifier - - #assert slack.unfurl_refs(case['input'], ignore_alt_text=case.get('ignore_alt_text', False)) == case['output'] +def test_unfurl_refs(case, realish_eventrouter): + slack.EVENTROUTER = realish_eventrouter + result = slack.unfurl_refs( + case['input'], + ignore_alt_text=case.get('ignore_alt_text', False), + auto_link_display=case.get('auto_link_display', 'both'), + ) + assert result == case['output'] diff --git a/_pytest/test_unwrap_attachments.py b/_pytest/test_unwrap_attachments.py new file mode 100644 index 0000000..ee5bd8b --- /dev/null +++ b/_pytest/test_unwrap_attachments.py @@ -0,0 +1,150 @@ +import wee_slack +import pytest + + +@pytest.mark.parametrize('case', ( + { + 'input_message': {'attachments': [{ + 'title': 'Title', + }]}, + 'input_text_before': "Text before", + 'output': "\n".join([ + "", + "Title", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'text': 'Attachment text', + 'title_link': 'http://title.link', + 'from_url': 'http://from.url', + 'fallback': 'Fallback', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "Title (http://title.link)", + "http://from.url", + "Attachment text", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'text': 'Attachment text', + 'title_link': 'http://link?a=1&b=2', + 'from_url': 'http://link?a=1&b=2', + }]}, + 'input_text_before': "http://link?a=1&b=2", + 'output': "\n".join([ + "", + "Title", + "Attachment text", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'text': 'Attachment text', + 'title_link': 'http://link', + 'from_url': 'http://link', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "Title (http://link)", + "Attachment text", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'text': 'Attachment text\n\n\nWith multiple lines', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "Title", + "Attachment text\nWith multiple lines", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'author_name': 'Author', + 'pretext': 'Pretext', + 'text': 'Attachment text', + 'title_link': 'http://title.link', + 'from_url': 'http://from.url', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "Pretext", + "Author: Title (http://title.link)", + "http://from.url", + "Attachment text", + ]), + }, + { + 'input_message': {'attachments': [{ + 'author_name': 'Author', + 'text': 'Attachment text', + 'title_link': 'http://title.link', + 'from_url': 'http://from.url', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "http://from.url", + "Author: Attachment text", + ]), + }, + { + 'input_message': {'attachments': [{ + 'fallback': 'Fallback', + }]}, + 'input_text_before': "", + 'output': "Fallback", + }, + { + 'input_message': {'attachments': [{ + 'title': 'Title', + 'fields': [{ + 'title': 'First field title', + 'value': 'First field value', + }, { + 'title': '', + 'value': 'Second field value', + }], + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "Title", + "First field title First field value", + "Second field value", + ]), + }, + { + 'input_message': {'attachments': [{ + 'title': 'First attachment title', + 'text': 'First attachment text', + 'title_link': 'http://title.link.1', + 'from_url': 'http://from.url.1', + }, { + 'title': 'Second attachment title', + 'text': 'Second attachment text', + 'title_link': 'http://title.link.2', + 'from_url': 'http://from.url.2', + }]}, + 'input_text_before': "", + 'output': "\n".join([ + "First attachment title (http://title.link.1)", + "http://from.url.1", + "First attachment text", + "Second attachment title (http://title.link.2)", + "http://from.url.2", + "Second attachment text", + ]), + }, +)) +def test_unwrap_attachments(case): + result = wee_slack.unwrap_attachments( + case['input_message'], case['input_text_before']) + assert result == case['output'] diff --git a/_pytest/test_utf8_helpers.py b/_pytest/test_utf8_helpers.py new file mode 100644 index 0000000..33c66ce --- /dev/null +++ b/_pytest/test_utf8_helpers.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +from collections import OrderedDict +from wee_slack import decode_from_utf8, encode_to_utf8, utf8_decode + + +def test_decode_preserves_string_without_utf8(): + assert u'test' == decode_from_utf8(b'test') + +def test_decode_preserves_unicode_strings(): + assert u'æøå' == decode_from_utf8(u'æøå') + +def test_decode_preserves_mapping_type(): + value_dict = {'a': 'x', 'b': 'y', 'c': 'z'} + value_ord_dict = OrderedDict(value_dict) + assert type(value_dict) == type(decode_from_utf8(value_dict)) + assert type(value_ord_dict) == type(decode_from_utf8(value_ord_dict)) + +def test_decode_preserves_iterable_type(): + value_set = {'a', 'b', 'c'} + value_tuple = ('a', 'b', 'c') + assert type(value_set) == type(decode_from_utf8(value_set)) + assert type(value_tuple) == type(decode_from_utf8(value_tuple)) + +def test_decodes_utf8_string_to_unicode(): + assert u'æøå' == decode_from_utf8(b'æøå') + +def test_decodes_utf8_dict_to_unicode(): + assert {u'æ': u'å', u'ø': u'å'} == decode_from_utf8({b'æ': b'å', b'ø': b'å'}) + +def test_decodes_utf8_list_to_unicode(): + assert [u'æ', u'ø', u'å'] == decode_from_utf8([b'æ', b'ø', b'å']) + +def test_encode_preserves_string_without_utf8(): + assert b'test' == encode_to_utf8(u'test') + +def test_encode_preserves_byte_strings(): + assert b'æøå' == encode_to_utf8(b'æøå') + +def test_encode_preserves_mapping_type(): + value_dict = {'a': 'x', 'b': 'y', 'c': 'z'} + value_ord_dict = OrderedDict(value_dict) + assert type(value_dict) == type(encode_to_utf8(value_dict)) + assert type(value_ord_dict) == type(encode_to_utf8(value_ord_dict)) + +def test_encode_preserves_iterable_type(): + value_set = {'a', 'b', 'c'} + value_tuple = ('a', 'b', 'c') + assert type(value_set) == type(encode_to_utf8(value_set)) + assert type(value_tuple) == type(encode_to_utf8(value_tuple)) + +def test_encodes_utf8_string_to_unicode(): + assert b'æøå' == encode_to_utf8(u'æøå') + +def test_encodes_utf8_dict_to_unicode(): + assert {b'æ': b'å', b'ø': b'å'} == encode_to_utf8({u'æ': u'å', u'ø': u'å'}) + +def test_encodes_utf8_list_to_unicode(): + assert [b'æ', b'ø', b'å'] == encode_to_utf8([u'æ', u'ø', u'å']) + +@utf8_decode +def method_with_utf8_decode(*args, **kwargs): + return (args, kwargs) + +def test_utf8_decode(): + args = (b'æ', b'ø', b'å') + kwargs = {b'æ': b'å', b'ø': b'å'} + + result_args, result_kwargs = method_with_utf8_decode(*args, **kwargs) + + assert result_args == decode_from_utf8(args) + assert result_kwargs == decode_from_utf8(kwargs) diff --git a/wee_slack.py b/wee_slack.py index 5cfdeb4..6ebba2e 100644 --- a/wee_slack.py +++ b/wee_slack.py @@ -1,8 +1,12 @@ -#-*- coding: utf-8 -*- -# +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals +from collections import OrderedDict from functools import wraps +from itertools import islice +import textwrap import time import json import pickle @@ -12,51 +16,61 @@ import re import urllib import sys import traceback -#import collections +import collections import ssl import random import string +try: + from cStringIO import StringIO +except: + from StringIO import StringIO from websocket import create_connection, WebSocketConnectionClosedException # hack to make tests possible.. better way? try: - import weechat as w + import weechat except: pass SCRIPT_NAME = "slack" SCRIPT_AUTHOR = "Ryan Huber <rhuber@gmail.com>" -SCRIPT_VERSION = "1.99" +SCRIPT_VERSION = "2.0.0" SCRIPT_LICENSE = "MIT" SCRIPT_DESC = "Extends weechat for typing notification/search/etc on slack.com" BACKLOG_SIZE = 200 SCROLLBACK_SIZE = 500 -CACHE_VERSION = "4" - RECORD_DIR = "/tmp/weeslack-debug" SLACK_API_TRANSLATOR = { "channel": { "history": "channels.history", - "join": "channels.join", - "leave": "channels.leave", + "join": "conversations.join", + "leave": "conversations.leave", "mark": "channels.mark", "info": "channels.info", }, "im": { "history": "im.history", - "join": "im.open", - "leave": "im.close", + "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": "channels.join", - "leave": "groups.leave", + "join": "conversations.join", + "leave": "conversations.leave", "mark": "groups.mark", + "info": "groups.info" }, "thread": { "history": None, @@ -70,6 +84,7 @@ SLACK_API_TRANSLATOR = { ###### 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 @@ -81,6 +96,7 @@ def slack_buffer_or_ignore(f): 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 @@ -93,6 +109,17 @@ def slack_buffer_required(f): 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" @@ -102,16 +129,84 @@ if hasattr(ssl, "get_default_verify_paths") and callable(ssl.get_default_verify_ if ssl_defaults.cafile is not None: sslopt_ca_certs = {'ca_certs': ssl_defaults.cafile} +EMOJI = [] + +###### Unicode handling + + +def encode_to_utf8(data): + if 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.iteritems())) + elif isinstance(data, collections.Iterable): + return type(data)(map(encode_to_utf8, data)) + else: + return data + + +def decode_from_utf8(data): + if 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.iteritems())) + 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) + + +##### Helpers + +def get_nick_color_name(nick): + info_name_prefix = "irc_" if int(weechat_version) < 0x1050000 else "" + return w.info_get(info_name_prefix + "nick_color_name", nick) + + ##### BEGIN NEW IGNORED_EVENTS = [ - "hello", - #"pref_change", - #"reconnect_url", + # "pref_change", + # "reconnect_url", ] ###### New central Event router + class EventRouter(object): def __init__(self): @@ -188,7 +283,7 @@ class EventRouter(object): """ data = self.context.get(identifier, None) if data: - #dbg("retrieved eontext {} ".format(identifier)) + # dbg("retrieved context {} ".format(identifier)) return data def delete_context(self, identifier): @@ -196,7 +291,7 @@ class EventRouter(object): Requests can span multiple requests, so we may need to delete this as a last step """ if identifier in self.context: - #dbg("deleted eontext {} ".format(identifier)) + # dbg("deleted eontext {} ".format(identifier)) del self.context[identifier] def shutdown(self): @@ -236,7 +331,7 @@ class EventRouter(object): """ try: # Read the data from the websocket associated with this team. - data = self.teams[team_hash].ws.recv() + data = decode_from_utf8(self.teams[team_hash].ws.recv()) message_json = json.loads(data) metadata = WeeSlackMetadata({ "team": team_hash, @@ -246,9 +341,12 @@ class EventRouter(object): self.record_event(message_json, 'type', 'websocket') self.receive_json(json.dumps(message_json)) except WebSocketConnectionClosedException: - #TODO: handle reconnect here + # TODO: handle reconnect here self.teams[team_hash].set_disconnected() return w.WEECHAT_RC_OK + except ssl.SSLWantReadError: + # Expected to happen occasionally on SSL websockets. + return w.WEECHAT_RC_OK except Exception: dbg("socket issue: {}\n".format(traceback.format_exc())) return w.WEECHAT_RC_OK @@ -270,18 +368,14 @@ class EventRouter(object): return if return_code == 0: if len(out) > 0: - if request_metadata.response_id in self.reply_buffer: - #dbg("found response id in reply_buffer", True) - self.reply_buffer[request_metadata.response_id] += out - else: - #dbg("didn't find response id in reply_buffer", True) - self.reply_buffer[request_metadata.response_id] = "" - self.reply_buffer[request_metadata.response_id] += out + 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]) + j = json.loads(self.reply_buffer[request_metadata.response_id].getvalue()) except: pass - #dbg("Incomplete json, awaiting more", True) + # dbg("Incomplete json, awaiting more", True) try: j["wee_slack_process_method"] = request_metadata.request_normalized j["wee_slack_request_metadata"] = pickle.dumps(request_metadata) @@ -303,8 +397,8 @@ class EventRouter(object): self.delete_context(data) else: if request_metadata.response_id not in self.reply_buffer: - self.reply_buffer[request_metadata.response_id] = "" - self.reply_buffer[request_metadata.response_id] += out + self.reply_buffer[request_metadata.response_id] = StringIO() + self.reply_buffer[request_metadata.response_id].write(out) def receive_json(self, data): """ @@ -315,6 +409,7 @@ class EventRouter(object): dbg("RECEIVED JSON of len {}".format(len(data))) message_json = json.loads(data) self.queue.append(message_json) + def receive(self, dataobj): """ complete @@ -324,6 +419,7 @@ class EventRouter(object): """ dbg("RECEIVED FROM QUEUE") self.queue.append(dataobj) + def receive_slow(self, dataobj): """ complete @@ -333,6 +429,7 @@ class EventRouter(object): """ dbg("RECEIVED FROM QUEUE") self.slow_queue.append(dataobj) + def handle_next(self): """ complete @@ -341,10 +438,10 @@ class EventRouter(object): useful metadata and context to events as they are processed. """ if len(self.slow_queue) > 0 and ((self.slow_queue_timer + 1) < time.time()): - #for q in self.slow_queue[0]: + # for q in self.slow_queue[0]: dbg("from slow queue", 0) self.queue.append(self.slow_queue.pop()) - #self.slow_queue = [] + # self.slow_queue = [] self.slow_queue_timer = time.time() if len(self.queue) > 0: j = self.queue.pop(0) @@ -378,7 +475,7 @@ class EventRouter(object): meta = j.get("wee_slack_metadata", None) if meta: try: - if isinstance(meta, str): + if isinstance(meta, basestring): dbg("string of metadata") team = meta.get("team", None) if team: @@ -401,6 +498,7 @@ class EventRouter(object): else: raise ProcessNotImplemented(function_name) + def handle_next(*args): """ complete @@ -416,33 +514,38 @@ def handle_next(*args): 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, str): + 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 """ - if isinstance(buffer_ptr, str): + if isinstance(buffer_ptr, basestring): try: self.buffers[buffer_ptr].destroy_buffer(update_remote) if close_buffer: @@ -452,22 +555,28 @@ class WeechatController(object): dbg("Tried to close unknown buffer") else: raise InvalidType(type(buffer_ptr)) + def get_channel_from_buffer_ptr(self, buffer_ptr): return self.buffers.get(buffer_ptr, None) + 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 @@ -480,21 +589,25 @@ def local_process_async_slack_api_request(request, event_router): 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 + # 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 receive_httprequest_callback(data, command, return_code, out, err): """ complete This is a dirty hack. There must be a better way. """ - #def url_processor_cb(data, command, return_code, out, err): + # def url_processor_cb(data, command, return_code, out, err): EVENTROUTER.receive_httprequest_callback(data, command, return_code, out, err) return w.WEECHAT_RC_OK + +@utf8_decode def receive_ws_callback(*args): """ complete @@ -505,10 +618,14 @@ def receive_ws_callback(*args): EVENTROUTER.receive_ws_callback(args[0]) 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): """ complete @@ -520,6 +637,8 @@ def buffer_closing_callback(signal, sig_type, data): eval(signal).weechat_controller.unregister_buffer(data, True, False) return w.WEECHAT_RC_OK + +@utf8_decode def buffer_input_callback(signal, buffer_ptr, data): """ incomplete @@ -530,15 +649,17 @@ def buffer_input_callback(signal, buffer_ptr, data): eventrouter = eval(signal) channel = eventrouter.weechat_controller.get_channel_from_buffer_ptr(buffer_ptr) if not channel: - return w.WEECHAT_RC_OK_EAT + return w.WEECHAT_RC_ERROR - reaction = re.match("^\s*(\d*)(\+|-):(.*):\s*$", data) + reaction = re.match("^(\d*)(\+|-):(.*):\s*$", data) + substitute = re.match("^(\d*)s/", data) if reaction: if reaction.group(2) == "+": channel.send_add_reaction(int(reaction.group(1) or 1), reaction.group(3)) elif reaction.group(2) == "-": channel.send_remove_reaction(int(reaction.group(1) or 1), reaction.group(3)) - elif data.startswith('s/'): + elif substitute: + msgno = int(substitute.group(1) or 1) try: old, new, flags = re.split(r'(?<!\\)/', data)[1:] except ValueError: @@ -548,12 +669,29 @@ def buffer_input_callback(signal, buffer_ptr, data): # rid of escapes. new = new.replace(r'\/', '/') old = old.replace(r'\/', '/') - channel.edit_previous_message(old.decode("utf-8"), new.decode("utf-8"), flags) + channel.edit_nth_previous_message(msgno, 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_ERROR + # 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): """ incomplete @@ -577,6 +715,8 @@ def buffer_switch_callback(signal, sig_type, data): eventrouter.weechat_controller.set_previous_buffer(data) return w.WEECHAT_RC_OK + +@utf8_decode def buffer_list_update_callback(data, somecount): """ incomplete @@ -587,7 +727,7 @@ def buffer_list_update_callback(data, somecount): user presence via " name" <-> "+name". """ eventrouter = eval(data) - #global buffer_list_update + # global buffer_list_update for b in eventrouter.weechat_controller.iter_buffers(): b[1].refresh() @@ -599,9 +739,13 @@ def buffer_list_update_callback(data, somecount): # 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(signal, sig_type, data): msg = w.buffer_get_string(data, "input") if len(msg) > 8 and msg[:1] != "/": @@ -610,17 +754,21 @@ def typing_notification_cb(signal, sig_type, data): if typing_timer + 4 < now: current_buffer = w.current_buffer() channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer, None) - if channel: + 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 t in EVENTROUTER.teams.values(): @@ -630,6 +778,8 @@ def slack_never_away_cb(data, remaining_calls): channel.team.send_to_websocket(request, expect_reply=False) return w.WEECHAT_RC_OK + +@utf8_decode def typing_bar_item_cb(data, current_buffer, args): """ Privides a bar item indicating who is typing in the current channel AND @@ -663,6 +813,8 @@ def typing_bar_item_cb(data, current_buffer, args): return typing + +@utf8_decode def nick_completion_cb(data, completion_item, current_buffer, completion): """ Adds all @-prefixed nicks to completion list @@ -676,9 +828,11 @@ def nick_completion_cb(data, completion_item, current_buffer, completion): for m in current_channel.members: u = current_channel.team.users.get(m, None) if u: - w.hook_completion_list_add(completion, "@" + u.slack_name, 1, w.WEECHAT_LIST_POS_SORT) + w.hook_completion_list_add(completion, "@" + u.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 @@ -689,10 +843,12 @@ def emoji_completion_cb(data, completion_item, current_buffer, completion): if current_channel is None: return w.WEECHAT_RC_OK - for e in EMOJI['emoji']: + for e in current_channel.team.emoji_completions: w.hook_completion_list_add(completion, ":" + e + ":", 0, 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 @@ -704,7 +860,7 @@ def complete_next_cb(data, current_buffer, command): current_buffer = w.current_buffer() current_channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer, None) - #channel = channels.find(current_buffer) + # channel = channels.find(current_buffer) if not hasattr(current_channel, 'members') or current_channel is None or current_channel.members is None: return w.WEECHAT_RC_OK @@ -731,7 +887,7 @@ def complete_next_cb(data, current_buffer, command): for m in current_channel.members: u = current_channel.team.users.get(m, None) - if u and u.slack_name == word: + if u and u.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:]) @@ -739,10 +895,12 @@ def complete_next_cb(data, current_buffer, command): 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 @@ -753,15 +911,16 @@ def stop_talking_to_slack(): EVENTROUTER.shutdown() return w.WEECHAT_RC_OK - ##### New Classes + class SlackRequest(object): """ complete 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, token, request, post_data={}, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) @@ -774,31 +933,37 @@ class SlackRequest(object): post_data["token"] = token self.post_data = post_data self.params = {'useragent': 'wee_slack {}'.format(SCRIPT_VERSION)} - self.url = 'https://{}/api/{}?{}'.format(self.domain, request, urllib.urlencode(post_data)) + self.url = 'https://{}/api/{}?{}'.format(self.domain, request, urllib.urlencode(encode_to_utf8(post_data))) self.response_id = sha.sha("{}{}".format(self.url, self.start_time)).hexdigest() self.retries = kwargs.get('retries', 3) # def __repr__(self): # return "URL: {} Tries: {} ID: {}".format(self.url, self.tries, self.response_id) + def request_string(self): return "{}".format(self.url) + def tried(self): self.tries += 1 self.response_id = sha.sha("{}{}".format(self.url, time.time())).hexdigest() + def should_try(self): return self.tries < self.retries + def retry_ready(self): return (self.start_time + (self.tries**2)) < time.time() + class SlackTeam(object): """ incomplete Team object under which users and channels live.. Does lots. """ + def __init__(self, eventrouter, token, websocket_url, subdomain, nick, myidentifier, users, bots, channels, **kwargs): self.ws_url = websocket_url self.connected = False self.connecting = False - #self.ws = None + # self.ws = None self.ws_counter = 0 self.ws_replies = {} self.eventrouter = eventrouter @@ -819,7 +984,6 @@ class SlackTeam(object): self.users = users self.bots = bots self.team_hash = SlackTeam.generate_team_hash(self.nick, self.subdomain) - #self.team_hash = str(sha.sha("{}{}".format(self.nick, self.subdomain)).hexdigest()) self.name = self.domain self.channel_buffer = None self.got_history = True @@ -833,21 +997,36 @@ class SlackTeam(object): 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() + + def __repr__(self): + return "domain={} nick={}".format(self.subdomain, self.nick) + def __eq__(self, compare_str): if compare_str == self.token or compare_str == self.domain or compare_str == self.subdomain: return True else: return False + + def load_emoji_completions(self): + self.emoji_completions = list(EMOJI) + if self.emoji_completions: + s = SlackRequest(self.token, "emoji.list", {}, team_hash=self.team_hash) + self.eventrouter.receive(s) + def add_channel(self, channel): self.channels[channel["id"]] = channel channel.set_related_server(self) -# def connect_request_generate(self): -# return SlackRequest(self.token, 'rtm.start', {}) - #def close_all_buffers(self): + + # def connect_request_generate(self): + # return SlackRequest(self.token, 'rtm.start', {}) + + # def close_all_buffers(self): # for channel in self.channels: # self.eventrouter.weechat_controller.unregister_buffer(channel.channel_buffer, update_remote=False, close_buffer=True) # #also close this server buffer # self.eventrouter.weechat_controller.unregister_buffer(self.channel_buffer, update_remote=False, close_buffer=True) + def create_buffer(self): if not self.channel_buffer: if config.short_buffer_names: @@ -861,133 +1040,178 @@ class SlackTeam(object): self.channel_buffer = w.buffer_new("{}".format(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) if w.config_string(w.config_get('irc.look.server_buffer')) == 'merge_with_core': w.buffer_merge(self.channel_buffer, w.buffer_search_main()) - w.buffer_set(self.channel_buffer, "nicklist", "1") + def set_muted_channels(self, muted_str): self.muted_channels = {x for x in muted_str.split(',')} + def set_highlight_words(self, highlight_str): self.highlight_words = {x for x in highlight_str.split(',')} if len(self.highlight_words) > 0: for v in self.channels.itervalues(): v.set_highlights() + def formatted_name(self, **kwargs): return self.domain + def buffer_prnt(self, data): - w.prnt_date_tags(self.channel_buffer, SlackTS().major, tag("backlog"), data) + w.prnt_date_tags(self.channel_buffer, SlackTS().major, tag("team"), data) + def get_channel_map(self): return {v.slack_name: k for k, v in self.channels.iteritems()} + def get_username_map(self): - return {v.slack_name: k for k, v in self.users.iteritems()} + return {v.name: k for k, v in self.users.iteritems()} + def get_team_hash(self): return self.team_hash + @staticmethod def generate_team_hash(nick, subdomain): return str(sha.sha("{}{}".format(nick, subdomain)).hexdigest()) + def refresh(self): self.rename() + def rename(self): pass - #def attach_websocket(self, ws): + + # def attach_websocket(self, ws): # self.ws = ws + def is_user_present(self, user_id): user = self.users.get(user_id) if user.presence == 'active': return True else: return False - def mark_read(self): + + def mark_read(self, ts=None, update_remote=True, force=False): pass + def connect(self): if not self.connected and not self.connecting: self.connecting = True if self.ws_url: try: ws = create_connection(self.ws_url, sslopt=sslopt_ca_certs) - w.hook_fd(ws.sock._sock.fileno(), 1, 0, 0, "receive_ws_callback", self.get_team_hash()) + self.hook = w.hook_fd(ws.sock._sock.fileno(), 1, 0, 0, "receive_ws_callback", self.get_team_hash()) ws.sock.setblocking(0) self.ws = ws - #self.attach_websocket(ws) + # self.attach_websocket(ws) self.set_connected() self.connecting = False except Exception as e: - dbg("websocket connection error: {}".format(e)) + dbg("websocket connection error: {}".format(decode_from_utf8(e))) self.connecting = False return False else: - #The fast reconnect failed, so start over-ish + # The fast reconnect failed, so start over-ish for chan in self.channels: self.channels[chan].got_history = False - s = SlackRequest(self.token, 'rtm.start', {}, retries=999) + s = initiate_connection(self.token, retries=999) self.eventrouter.receive(s) self.connecting = False - #del self.eventrouter.teams[self.get_team_hash()] + # del self.eventrouter.teams[self.get_team_hash()] self.set_reconnect_url(None) + def set_connected(self): self.connected = True + 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): if self.ws_counter > 999: self.ws_counter = 0 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(message) + self.ws.send(encode_to_utf8(message)) dbg("Sent {}...".format(message[:100])) except: print "WS ERROR" dbg("Unexpected error: {}\nSent: {}".format(sys.exc_info()[0], data)) self.set_connected() + 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 = self.users.keys()[0:750] + self.send_to_websocket({ + "type": "presence_sub", + "ids": users, + }, expect_reply=False) + class SlackChannel(object): """ Represents an individual slack channel. """ + def __init__(self, eventrouter, **kwargs): - # We require these two things for a vaid object, + # 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.members = set(kwargs.get('members', set())) self.eventrouter = eventrouter self.slack_name = kwargs["name"] - self.slack_topic = kwargs.get("topic", {"value": ""}) self.slack_purpose = kwargs.get("purpose", {"value": ""}) + self.topic = kwargs.get("topic", {}).get("value", "") self.identifier = kwargs["id"] self.last_read = SlackTS(kwargs.get("last_read", SlackTS())) - #print self.last_read self.channel_buffer = None self.team = kwargs.get('team', None) self.got_history = False - self.messages = {} + 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 + # short name relates to the localvar we change for typing indication self.current_short_name = self.name - self.update_nicklist() + self.set_members(kwargs.get('members', [])) + self.unread_count_display = 0 + 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) + 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") @@ -996,12 +1220,28 @@ class SlackChannel(object): w.buffer_set(self.channel_buffer, "short_name", new_name) return True return False - def formatted_name(self, style="default", typing=False, **kwargs): - if config.channel_name_typing_indicator: - if not typing: - prepend = "#" + + 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) + for c in range(self.unread_count_display): + if self.type == "im": + w.buffer_set(self.channel_buffer, "hotlist", "2") else: - prepend = ">" + 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": + prepend = config.group_name_prefix else: prepend = "#" select = { @@ -1012,57 +1252,59 @@ class SlackChannel(object): "long_base": "{}.{}".format(self.team.preferred_name, self.slack_name), } return select[style] - def render_topic(self, topic=None): + + def render_topic(self): if self.channel_buffer: - if not topic: - if self.slack_topic['value'] != "": - encoded_topic = self.slack_topic['value'].encode('utf-8') - else: - encoded_topic = self.slack_purpose['value'].encode('utf-8') + if self.topic != "": + topic = self.topic else: - encoded_topic = topic.encode('utf-8') - self.encoded_topic = topic.encode('utf-8') - w.buffer_set(self.channel_buffer, "title", encoded_topic) + topic = self.slack_purpose['value'] + w.buffer_set(self.channel_buffer, "title", topic) + + def set_topic(self, value): + self.topic = value + self.render_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.token, SLACK_API_TRANSLATOR[self.type]["join"], {"name": self.name}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["join"], {"channel": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) self.create_buffer() self.active = True self.get_history() - if "info" in SLACK_API_TRANSLATOR[self.type]: - s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["info"], {"name": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) - self.eventrouter.receive(s) - #self.create_buffer() + # self.create_buffer() + def check_should_open(self, force=False): - try: - if self.is_archived: - return - except: - pass + if hasattr(self, "is_archived") and self.is_archived: + return + if force: self.create_buffer() - else: - for reason in ["is_member", "is_open", "unread_count_display"]: - try: - if eval("self." + reason): - self.create_buffer() - if config.background_load_all_history: - self.get_history(slow_queue=True) - except: - pass + 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 set_highlights(self): - #highlight my own name and any set highlights + # highlight my own name and any set highlights if self.channel_buffer: - highlights = self.team.highlight_words.union({'@' + self.team.nick, "!here", "!channel", "!everyone"}) + highlights = self.team.highlight_words.union({'@' + self.team.nick, self.team.myidentifier, "!here", "!channel", "!everyone"}) h_str = ",".join(highlights) w.buffer_set(self.channel_buffer, "highlight_words", h_str) + def create_buffer(self): """ incomplete (muted doesn't work) @@ -1077,58 +1319,56 @@ class SlackChannel(object): 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.render_topic() self.eventrouter.weechat_controller.set_refresh_buffer_list(True) if self.channel_buffer: - #if self.team.server_alias: - #w.buffer_set(self.channel_buffer, "localvar_set_server", self.team.server_alias) - #else: + # if self.team.server_alias: + # w.buffer_set(self.channel_buffer, "localvar_set_server", self.team.server_alias) + # else: w.buffer_set(self.channel_buffer, "localvar_set_server", self.team.preferred_name) - #else: + # else: # self.eventrouter.weechat_controller.register_buffer(self.channel_buffer, self) - try: - for c in range(self.unread_count_display): - if self.type == "im": - w.buffer_set(self.channel_buffer, "hotlist", "2") - else: - w.buffer_set(self.channel_buffer, "hotlist", "1") - else: - pass - #dbg("no unread in {}".format(self.name)) - except: - pass - self.update_nicklist() - #dbg("exception no unread count") - #if self.unread_count != 0 and not self.muted: - # w.buffer_set(self.channel_buffer, "hotlist", "1") + + if "info" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["info"], {"channel": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + self.eventrouter.receive(s) + + if self.type == "im": + if "join" in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["join"], {"users": self.user, "return_im": True}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + self.eventrouter.receive(s) + def destroy_buffer(self, update_remote): if self.channel_buffer is not None: self.channel_buffer = None - self.messages = {} + self.messages = OrderedDict() self.hashed_messages = {} self.got_history = False - #if update_remote and not eventrouter.shutting_down: + # if update_remote and not eventrouter.shutting_down: self.active = False if update_remote and not self.eventrouter.shutting_down: s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["leave"], {"channel": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) + + def buffer_prnt(self, nick, text, timestamp=str(time.time()), tagset=None, tag_nick=None, **kwargs): - data = "{}\t{}".format(nick, text) + data = "{}\t{}".format(format_nick(nick), text) ts = SlackTS(timestamp) last_read = SlackTS(self.last_read) - #without this, DMs won't open automatically + # 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 messages - we will update the read marker as we print these backlog = True if ts <= last_read else False if tagset: tags = tag(tagset, user=tag_nick) self.new_messages = True - #we have to infer the tagset because we weren't told + # we have to infer the tagset because we weren't told elif ts <= last_read: tags = tag("backlog", user=tag_nick) elif self.type in ["im", "mpdm"]: @@ -1151,28 +1391,31 @@ class SlackChannel(object): self.mark_read(ts, update_remote=False, force=True) except: dbg("Problem processing buffer_prnt") + def send_message(self, message, request_dict_ext={}): - #team = self.eventrouter.teams[self.team] + # team = self.eventrouter.teams[self.team] message = linkify_text(message, self.team, self) dbg(message) request = {"type": "message", "channel": self.identifier, "text": message, "_team": self.team.team_hash, "user": self.team.myidentifier} request.update(request_dict_ext) self.team.send_to_websocket(request) self.mark_read(update_remote=False, force=True) + 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 - if len(self.messages.keys()) > SCROLLBACK_SIZE: - mk = self.messages.keys() - mk.sort() - for k in mk[:SCROLLBACK_SIZE]: - msg_to_delete = self.messages[k] - if msg_to_delete.hash: - del self.hashed_messages[msg_to_delete.hash] - del self.messages[k] + + 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 change_message(self, ts, text=None, suffix=None): ts = SlackTS(ts) if ts in self.messages: @@ -1184,8 +1427,9 @@ class SlackChannel(object): text = m.render(force=True) modify_buffer_line(self.channel_buffer, text, ts.major, ts.minor) return True - def edit_previous_message(self, old, new, flags): - message = self.my_last_message() + + def edit_nth_previous_message(self, n, old, new, flags): + message = self.my_last_message(n) if new == "" and old == "": s = SlackRequest(self.team.token, "chat.delete", {"channel": self.identifier, "ts": message['ts']}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) @@ -1195,18 +1439,23 @@ class SlackChannel(object): num_replace = 0 new_message = re.sub(old, new, message["text"], num_replace) if new_message != message["text"]: - s = SlackRequest(self.team.token, "chat.update", {"channel": self.identifier, "ts": message['ts'], "text": new_message.encode("utf-8")}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + s = SlackRequest(self.team.token, "chat.update", {"channel": self.identifier, "ts": message['ts'], "text": new_message}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) - def my_last_message(self): - for message in reversed(self.sorted_message_keys()): - m = self.messages[message] + + def my_last_message(self, msgno): + for key in self.main_message_keys_reversed(): + m = self.messages[key] if "user" in m.message_json and "text" in m.message_json and m.message_json["user"] == self.team.myidentifier: - return m.message_json + msgno -= 1 + if msgno == 0: + return m.message_json + 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 + # we have probably reconnected. flush the buffer if self.team.connected: w.buffer_clear(self.channel_buffer) self.buffer_prnt('', 'getting channel history...', tagset='backlog') @@ -1216,32 +1465,37 @@ class SlackChannel(object): else: self.eventrouter.receive_slow(s) self.got_history = True + def send_add_reaction(self, msg_number, reaction): self.send_change_reaction("reactions.add", msg_number, reaction) + def send_remove_reaction(self, msg_number, reaction): self.send_change_reaction("reactions.remove", msg_number, reaction) + def send_change_reaction(self, method, msg_number, reaction): if 0 < msg_number < len(self.messages): - timestamp = self.sorted_message_keys()[-msg_number] + keys = self.main_message_keys_reversed() + timestamp = next(islice(keys, msg_number - 1, None)) data = {"channel": self.identifier, "timestamp": timestamp, "name": reaction} s = SlackRequest(self.team.token, method, data) self.eventrouter.receive(s) - def sorted_message_keys(self): - keys = [] - for k in self.messages: - if type(self.messages[k]) == SlackMessage: - keys.append(k) - return sorted(keys) + + 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, None) 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 @@ -1255,6 +1509,7 @@ class SlackChannel(object): 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. @@ -1266,9 +1521,10 @@ class SlackChannel(object): else: del self.typing[user] return typing + def mark_read(self, ts=None, update_remote=True, force=False): if not ts: - ts = SlackTS() + ts = next(self.main_message_keys_reversed(), SlackTS()) if self.new_messages or force: if self.channel_buffer: w.buffer_set(self.channel_buffer, "unread", "") @@ -1277,41 +1533,45 @@ class SlackChannel(object): s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["mark"], {"channel": self.identifier, "ts": ts}, team_hash=self.team.team_hash, channel_identifier=self.identifier) 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 + # 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): - #pass - #filter(lambda u: u != user_id, self.members) 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"]: + if self.type not in ["channel", "group", "mpim"]: 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 - #TODO: put this back for mithrandir - #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) + 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) if user and len(self.members) < 1000: user = self.team.users[user] - nick = w.nicklist_search_nick(self.channel_buffer, "", user.slack_name) + if 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 self.team.is_user_present(user.identifier): + nick_group = here if user.identifier in self.members: - w.nicklist_add_nick(self.channel_buffer, "", user.name, user.color_name, "", "", 1) - #w.nicklist_add_nick(self.channel_buffer, here, user.name, user.color_name, "", "", 1) + 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: @@ -1321,13 +1581,17 @@ class SlackChannel(object): user = self.team.users[user] if user.deleted: continue - w.nicklist_add_nick(self.channel_buffer, "", user.name, user.color_name, "", "", 1) - #w.nicklist_add_nick(self.channel_buffer, here, user.name, user.color_name, "", "", 1) + nick_group = afk + if 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 Exception as e: - dbg("DEBUG: {} {} {}".format(self.identifier, self.name, e)) + dbg("DEBUG: {} {} {}".format(self.identifier, self.name, decode_from_utf8(e))) 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 hash_message(self, ts): ts = SlackTS(ts) @@ -1337,15 +1601,15 @@ class SlackChannel(object): if ts in self.messages and not self.messages[ts].hash: message = self.messages[ts] tshash = calc_hash(message) - l = 3 - shorthash = tshash[:l] + hl = 3 + shorthash = tshash[:hl] while any(x.startswith(shorthash) for x in self.hashed_messages): - l += 1 - shorthash = tshash[:l] + 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)[:l] + 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)) @@ -1361,6 +1625,7 @@ 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 @@ -1368,20 +1633,27 @@ class SlackDMChannel(SlackChannel): self.type = 'im' self.update_color() self.set_name(self.slack_name) - self.slack_topic = {"value": create_user_status_string(users[dmuser].profile)} + self.topic = create_user_status_string(users[dmuser].profile) + 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 = w.info_get('irc_nick_color_name', self.name.encode('utf-8')) + self.color_name = get_nick_color_name(self.name) self.color = w.color(self.color_name) else: self.color = "" self.color_name = "" + def formatted_name(self, style="default", typing=False, present=True, enable_color=False, **kwargs): if config.colorize_private_chats and enable_color: print_color = self.color @@ -1399,18 +1671,20 @@ class SlackDMChannel(SlackChannel): "long_base": "{}.{}".format(self.team.preferred_name, self.slack_name), } return print_color + select[style] + def open(self, update_remote=True): self.create_buffer() - #self.active = True + # self.active = True self.get_history() if "info" in SLACK_API_TRANSLATOR[self.type]: s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["info"], {"name": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) if update_remote: if "join" in SLACK_API_TRANSLATOR[self.type]: - s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["join"], {"user": self.user}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["join"], {"users": self.user, "return_im": True}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) self.create_buffer() + 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) @@ -1419,6 +1693,7 @@ class SlackDMChannel(SlackChannel): w.buffer_set(self.channel_buffer, "short_name", new_name) return True return False + def refresh(self): return self.rename() @@ -1427,45 +1702,56 @@ class SlackGroupChannel(SlackChannel): """ A group channel is a private discussion group. """ + def __init__(self, eventrouter, **kwargs): super(SlackGroupChannel, self).__init__(eventrouter, **kwargs) - self.name = "#" + kwargs['name'] self.type = "group" self.set_name(self.slack_name) + def set_name(self, slack_name): - self.name = "#" + slack_name - #def formatted_name(self, prepend="#", enable_color=True, basic=False): + self.name = config.group_name_prefix + slack_name + + # def formatted_name(self, prepend="#", enable_color=True, basic=False): # return prepend + self.slack_name + 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, **kwargs): super(SlackMPDMChannel, self).__init__(eventrouter, **kwargs) n = kwargs.get('name') self.set_name(n) - self.type = "group" - def open(self, update_remote=False): + 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.token, SLACK_API_TRANSLATOR[self.type]["info"], {"name": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["info"], {"channel": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) self.eventrouter.receive(s) - #self.create_buffer() + if update_remote and 'join' in SLACK_API_TRANSLATOR[self.type]: + s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]['join'], {'users': ','.join(self.members)}, team_hash=self.team.team_hash, channel_identifier=self.identifier) + self.eventrouter.receive(s) + # self.create_buffer() + + @staticmethod + def adjust_name(n): + return "|".join("-".join(n.split("-")[1:-1]).split("--")) + def set_name(self, n): - self.name = "|".join("-".join(n.split("-")[1:-1]).split("--")) + self.name = self.adjust_name(n) + def formatted_name(self, style="default", typing=False, **kwargs): - adjusted_name = "|".join("-".join(self.slack_name.split("-")[1:-1]).split("--")) - if config.channel_name_typing_indicator: - if not typing: - prepend = "#" - else: - prepend = ">" + adjusted_name = self.adjust_name(self.slack_name) + if typing and config.channel_name_typing_indicator: + prepend = ">" else: - prepend = "#" + prepend = "@" select = { "default": adjusted_name, "sidebar": prepend + adjusted_name, @@ -1475,28 +1761,31 @@ class SlackMPDMChannel(SlackChannel): } return select[style] -# def formatted_name(self, **kwargs): -# return self.name def rename(self): pass + class SlackThreadChannel(object): """ 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.channel_buffer = None - #self.identifier = "" - #self.name = "#" + kwargs['name'] + # self.identifier = "" + # self.name = "#" + kwargs['name'] self.type = "thread" self.got_history = False self.label = None - #self.set_name(self.slack_name) - #def set_name(self, slack_name): + self.members = self.parent_message.channel.members + self.team = self.parent_message.team + # self.set_name(self.slack_name) + # def set_name(self, slack_name): # self.name = "#" + slack_name + def formatted_name(self, style="default", **kwargs): hash_or_ts = self.parent_message.hash or self.parent_message.ts styles = { @@ -1505,65 +1794,68 @@ class SlackThreadChannel(object): "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, **kwargs): - data = "{}\t{}".format(nick, text) + data = "{}\t{}".format(format_nick(nick), text) ts = SlackTS(timestamp) if self.channel_buffer: - #backlog messages - we will update the read marker as we print these - #backlog = False - #if ts <= SlackTS(self.last_read): + # backlog messages - we will update the read marker as we print these + # backlog = False + # if ts <= SlackTS(self.last_read): # tags = tag("backlog") # backlog = True - #elif self.type in ["im", "mpdm"]: + # elif self.type in ["im", "mpdm"]: # tags = tag("dm") # self.new_messages = True - #else: + # else: tags = tag("default") - #self.new_messages = True + # self.new_messages = True w.prnt_date_tags(self.channel_buffer, ts.major, tags, data) modify_print_time(self.channel_buffer, ts.minorstr(), ts.major) - #if backlog: + # if backlog: # self.mark_read(ts, update_remote=False, force=True) + def get_history(self): self.got_history = True for message in self.parent_message.submessages: - #message = SlackMessage(message_json, team, channel) + # message = SlackMessage(message_json, team, channel) text = message.render() - #print text + # print text suffix = '' if 'edited' in message.message_json: suffix = ' (edited)' - #try: + # try: # channel.unread_count += 1 - #except: + # except: # channel.unread_count = 1 self.buffer_prnt(message.sender, text + suffix, message.ts) def send_message(self, message): - #team = self.eventrouter.teams[self.team] - message = linkify_text(message, self.parent_message.team, self) + # team = self.eventrouter.teams[self.team] + message = linkify_text(message, self.team, self) dbg(message) - request = {"type": "message", "channel": self.parent_message.channel.identifier, "text": message, "_team": self.parent_message.team.team_hash, "user": self.parent_message.team.myidentifier, "thread_ts": str(self.parent_message.ts)} - self.parent_message.team.send_to_websocket(request) + request = {"type": "message", "channel": self.parent_message.channel.identifier, "text": message, "_team": self.team.team_hash, "user": self.team.myidentifier, "thread_ts": str(self.parent_message.ts)} + self.team.send_to_websocket(request) self.mark_read(update_remote=False, force=True) def open(self, update_remote=True): self.create_buffer() self.active = True self.get_history() - #if "info" in SLACK_API_TRANSLATOR[self.type]: + # if "info" in SLACK_API_TRANSLATOR[self.type]: # s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["info"], {"name": self.identifier}, team_hash=self.team.team_hash, channel_identifier=self.identifier) # self.eventrouter.receive(s) - #if update_remote: + # if update_remote: # if "join" in SLACK_API_TRANSLATOR[self.type]: # s = SlackRequest(self.team.token, SLACK_API_TRANSLATOR[self.type]["join"], {"name": self.name}, team_hash=self.team.team_hash, channel_identifier=self.identifier) # self.eventrouter.receive(s) @@ -1582,16 +1874,17 @@ class SlackThreadChannel(object): 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, "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.parent_message.render() ) - w.buffer_set(self.channel_buffer, "title", topic.encode('utf-8')) + w.buffer_set(self.channel_buffer, "title", topic) - #self.eventrouter.weechat_controller.set_refresh_buffer_list(True) + # self.eventrouter.weechat_controller.set_refresh_buffer_list(True) - #try: + # try: # if self.unread_count != 0: # for c in range(1, self.unread_count): # if self.type == "im": @@ -1601,50 +1894,66 @@ class SlackThreadChannel(object): # else: # pass # #dbg("no unread in {}".format(self.name)) - #except: + # except: # pass - #dbg("exception no unread count") - #if self.unread_count != 0 and not self.muted: + # dbg("exception no unread count") + # if self.unread_count != 0 and not self.muted: # w.buffer_set(self.channel_buffer, "hotlist", "1") + def destroy_buffer(self, update_remote): if self.channel_buffer is not None: self.channel_buffer = None self.got_history = False - #if update_remote and not eventrouter.shutting_down: + # if update_remote and not eventrouter.shutting_down: self.active = False + class SlackUser(object): """ Represends an individual slack user. Also where you set their name formatting. """ + def __init__(self, **kwargs): - # We require these two things for a vaid object, + # We require these two things for a valid object, # the rest we can just learn from slack self.identifier = kwargs["id"] - self.slack_name = kwargs["name"] - self.name = kwargs["name"] + self.profile = {} # in case it's not in kwargs for key, value in kwargs.items(): setattr(self, key, value) + + if self.profile.get("display_name"): + self.slack_name = self.profile["display_name"] + self.name = self.profile["display_name"].replace(' ', '') + else: + # No display name set. Fall back to the deprecated username field. + self.slack_name = kwargs["name"] + self.name = self.slack_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 self.color = w.color(self.color_name) + def update_color(self): # This will automatically be none/"" if the user has disabled nick # colourization. - self.color_name = w.info_get('nick_color_name', self.name.encode('utf-8')) + self.color_name = get_nick_color_name(self.name) self.color = w.color(self.color_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): if enable_color: return self.color + prepend + self.name else: return prepend + self.name + class SlackBot(SlackUser): """ Basically the same as a user, but split out to identify and for future @@ -1653,6 +1962,7 @@ class SlackBot(SlackUser): def __init__(self, **kwargs): super(SlackBot, self).__init__(**kwargs) + class SlackMessage(object): """ Represents a single slack message and associated context/metadata. @@ -1676,8 +1986,8 @@ class SlackMessage(object): self.sender, self.sender_plain = senders[0], senders[1] self.suffix = '' self.ts = SlackTS(message_json['ts']) - text = self.message_json.get('text', '') - if text.startswith('_') and text.endswith('_') and not 'subtype' in message_json: + text = self.message_json.get('text') + if text and text.startswith('_') and text.endswith('_') and 'subtype' not in message_json: message_json['text'] = text[1:-1] message_json['subtype'] = 'me_message' if message_json.get('subtype') == 'me_message' and not message_json['text'].startswith(self.sender): @@ -1685,46 +1995,49 @@ class SlackMessage(object): def __hash__(self): return hash(self.ts) + def render(self, force=False): if len(self.submessages) > 0: return "{} {} {}".format(render(self.message_json, self.team, self.channel, force), self.suffix, "{}[ Thread: {} Replies: {} ]".format(w.color(config.thread_suffix_color), self.hash or self.ts, len(self.submessages))) return "{} {}".format(render(self.message_json, self.team, self.channel, force), self.suffix) + def change_text(self, new_text): self.message_json["text"] = new_text dbg(self.message_json) + def change_suffix(self, new_suffix): self.suffix = new_suffix dbg(self.message_json) - def get_sender(self, utf8=True): - name = u"" - name_plain = u"" - if 'bot_id' in self.message_json and self.message_json['bot_id'] is not None: - name = u"{} :]".format(self.team.bots[self.message_json["bot_id"]].formatted_name()) - name_plain = u"{}".format(self.team.bots[self.message_json["bot_id"]].formatted_name(enable_color=False)) - elif 'user' in self.message_json: + + def get_sender(self): + name = "" + name_plain = "" + if 'user' in self.message_json: if self.message_json['user'] == self.team.myidentifier: - name = self.team.users[self.team.myidentifier].name - name_plain = self.team.users[self.team.myidentifier].name + u = self.team.users[self.team.myidentifier] elif self.message_json['user'] in self.team.users: u = self.team.users[self.message_json['user']] - if u.is_bot: - name = u"{} :]".format(u.formatted_name()) - else: - name = u"{}".format(u.formatted_name()) - name_plain = u"{}".format(u.formatted_name(enable_color=False)) + name = "{}".format(u.formatted_name()) + name_plain = "{}".format(u.formatted_name(enable_color=False)) elif 'username' in self.message_json: - name = u"-{}-".format(self.message_json["username"]) - name_plain = u"{}".format(self.message_json["username"]) + u = self.message_json["username"] + if self.message_json.get("subtype") == "bot_message": + name = "{} :]".format(u) + name_plain = "{}".format(u) + else: + name = "-{}-".format(u) + name_plain = "{}".format(u) elif 'service_name' in self.message_json: - name = u"-{}-".format(self.message_json["service_name"]) - name_plain = u"{}".format(self.message_json["service_name"]) - else: - name = u"" - name_plain = u"" - if utf8: - return (name.encode('utf-8'), name_plain.encode('utf-8')) + 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)) else: - return (name, name_plain) + name = "" + name_plain = "" + return (name, name_plain) + def add_reaction(self, reaction, user): m = self.message_json.get('reactions', None) if m: @@ -1734,9 +2047,10 @@ class SlackMessage(object): r["users"].append(user) found = True if not found: - self.message_json["reactions"].append({u"name": reaction, u"users": [user]}) + self.message_json["reactions"].append({"name": reaction, "users": [user]}) else: - self.message_json["reactions"] = [{u"name": reaction, u"users": [user]}] + self.message_json["reactions"] = [{"name": reaction, "users": [user]}] + def remove_reaction(self, reaction, user): m = self.message_json.get('reactions', None) if m: @@ -1746,10 +2060,11 @@ class SlackMessage(object): else: pass + class SlackThreadMessage(SlackMessage): + def __init__(self, parent_id, *args): super(SlackThreadMessage, self).__init__(*args) - #super(SlackBot, self).__init__(**kwargs) self.parent_id = parent_id @@ -1757,18 +2072,23 @@ class WeeSlackMetadata(object): """ A simple container that we pickle/unpickle to hold data. """ + def __init__(self, meta): self.meta = meta + def jsonify(self): return self.meta + 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: @@ -1790,106 +2110,145 @@ class SlackTS(object): return 1 elif s == other: return 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): """ This handles the main entry call to slack, rtm.start """ - if login_data["ok"]: + metadata = pickle.loads(login_data["wee_slack_request_metadata"]) - metadata = pickle.loads(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"])) + 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): + # 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(**item) - #users.append(SlackUser(**item)) + users = {} + for item in login_data["users"]: + users[item["id"]] = SlackUser(**item) - bots = {} - for item in login_data["bots"]: - bots[item["id"]] = SlackBot(**item) + bots = {} + for item in login_data["bots"]: + bots[item["id"]] = SlackBot(**item) - channels = {} - for item in login_data["channels"]: - channels[item["id"]] = SlackChannel(eventrouter, **item) + channels = {} + for item in login_data["channels"]: + channels[item["id"]] = SlackChannel(eventrouter, **item) - for item in login_data["ims"]: - channels[item["id"]] = SlackDMChannel(eventrouter, users, **item) + for item in login_data["ims"]: + channels[item["id"]] = SlackDMChannel(eventrouter, users, **item) - for item in login_data["groups"]: - if item["name"].startswith('mpdm-'): - channels[item["id"]] = SlackMPDMChannel(eventrouter, **item) - else: - channels[item["id"]] = SlackGroupChannel(eventrouter, **item) - - t = SlackTeam( - eventrouter, - metadata.token, - login_data['url'], - login_data["team"]["domain"], - login_data["self"]["name"], - login_data["self"]["id"], - users, - bots, - channels, - muted_channels=login_data["self"]["prefs"]["muted_channels"], - highlight_words=login_data["self"]["prefs"]["highlight_words"], - ) - eventrouter.register_team(t) + for item in login_data["groups"]: + if item["name"].startswith('mpdm-'): + channels[item["id"]] = SlackMPDMChannel(eventrouter, **item) + else: + channels[item["id"]] = SlackGroupChannel(eventrouter, **item) + + t = SlackTeam( + eventrouter, + metadata.token, + login_data['url'], + login_data["team"]["domain"], + login_data["self"]["name"], + login_data["self"]["id"], + 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.connect() + + t.buffer_prnt('Connected to Slack') + t.buffer_prnt('{:<20} {}'.format("Websocket URL", login_data["url"])) + t.buffer_prnt('{:<20} {}'.format("User name", login_data["self"]["name"])) + t.buffer_prnt('{:<20} {}'.format("User ID", login_data["self"]["id"])) + t.buffer_prnt('{:<20} {}'.format("Team name", login_data["team"]["name"])) + t.buffer_prnt('{:<20} {}'.format("Team domain", login_data["team"]["domain"])) + t.buffer_prnt('{:<20} {}'.format("Team id", login_data["team"]["id"])) + + dbg("connected to {}".format(t.domain)) + + +def handle_emojilist(emoji_json, eventrouter, **kwargs): + if emoji_json["ok"]: + request_metadata = pickle.loads(emoji_json["wee_slack_request_metadata"]) + team = eventrouter.teams[request_metadata.team_hash] + team.emoji_completions.extend(emoji_json["emoji"].keys()) + + +def handle_channelsinfo(channel_json, eventrouter, **kwargs): + request_metadata = pickle.loads(channel_json["wee_slack_request_metadata"]) + team = eventrouter.teams[request_metadata.team_hash] + channel = team.channels[request_metadata.channel_identifier] + channel.set_unread_count_display(channel_json['channel']['unread_count_display']) + channel.set_members(channel_json['channel']['members']) + +def handle_groupsinfo(group_json, eventrouter, **kwargs): + request_metadata = pickle.loads(group_json["wee_slack_request_metadata"]) + team = eventrouter.teams[request_metadata.team_hash] + group = team.channels[request_metadata.channel_identifier] + unread_count_display = group_json['group']['unread_count_display'] + group_id = group_json['group']['id'] + group.set_unread_count_display(unread_count_display) + +def handle_conversationsopen(conversation_json, eventrouter, object_name='channel', **kwargs): + request_metadata = pickle.loads(conversation_json["wee_slack_request_metadata"]) + # Set unread count if the channel isn't new (channel_identifier exists) + if hasattr(request_metadata, 'channel_identifier'): + channel_id = request_metadata.channel_identifier + team = eventrouter.teams[request_metadata.team_hash] + conversation = team.channels[channel_id] + unread_count_display = conversation_json[object_name]['unread_count_display'] + conversation.set_unread_count_display(unread_count_display) + + +def handle_mpimopen(mpim_json, eventrouter, object_name='group', **kwargs): + handle_conversationsopen(mpim_json, eventrouter, object_name, **kwargs) - else: - t = eventrouter.teams.get(th) - t.set_reconnect_url(login_data['url']) - t.connect() - - #web_socket_url = login_data['url'] - #try: - # ws = create_connection(web_socket_url, sslopt=sslopt_ca_certs) - # w.hook_fd(ws.sock._sock.fileno(), 1, 0, 0, "receive_ws_callback", t.get_team_hash()) - # #ws_hook = w.hook_fd(ws.sock._sock.fileno(), 1, 0, 0, "receive_ws_callback", pickle.dumps(t)) - # ws.sock.setblocking(0) - # t.attach_websocket(ws) - # t.set_connected() - #except Exception as e: - # dbg("websocket connection error: {}".format(e)) - # return False - - t.buffer_prnt('Connected to Slack') - t.buffer_prnt('{:<20} {}'.format(u"Websocket URL", login_data["url"])) - t.buffer_prnt('{:<20} {}'.format(u"User name", login_data["self"]["name"])) - t.buffer_prnt('{:<20} {}'.format(u"User ID", login_data["self"]["id"])) - t.buffer_prnt('{:<20} {}'.format(u"Team name", login_data["team"]["name"])) - t.buffer_prnt('{:<20} {}'.format(u"Team domain", login_data["team"]["domain"])) - t.buffer_prnt('{:<20} {}'.format(u"Team id", login_data["team"]["id"])) - - dbg("connected to {}".format(t.domain)) - - #self.identifier = self.domain def handle_groupshistory(message_json, eventrouter, **kwargs): handle_history(message_json, eventrouter, **kwargs) + def handle_channelshistory(message_json, eventrouter, **kwargs): handle_history(message_json, eventrouter, **kwargs) + def handle_imhistory(message_json, eventrouter, **kwargs): handle_history(message_json, eventrouter, **kwargs) + +def handle_mpimhistory(message_json, eventrouter, **kwargs): + handle_history(message_json, eventrouter, **kwargs) + + def handle_history(message_json, eventrouter, **kwargs): request_metadata = pickle.loads(message_json["wee_slack_request_metadata"]) kwargs['team'] = eventrouter.teams[request_metadata.team_hash] @@ -1907,25 +2266,40 @@ def handle_history(message_json, eventrouter, **kwargs): ###### New/converted process_ and subprocess_ methods +def process_hello(message_json, eventrouter, **kwargs): + kwargs['team'].subscribe_users_presence() + def process_reconnect_url(message_json, eventrouter, **kwargs): kwargs['team'].set_reconnect_url(message_json['url']) + def process_manual_presence_change(message_json, eventrouter, **kwargs): process_presence_change(message_json, eventrouter, **kwargs) def process_presence_change(message_json, eventrouter, **kwargs): - kwargs["user"].presence = message_json["presence"] + if "user" in kwargs: + # TODO: remove once it's stable + user = kwargs["user"] + team = kwargs["team"] + team.update_member_presence(user, message_json["presence"]) + if "users" in message_json: + team = kwargs["team"] + for user_id in message_json["users"]: + user = team.users[user_id] + team.update_member_presence(user, message_json["presence"]) + def process_pref_change(message_json, eventrouter, **kwargs): team = kwargs["team"] - if message_json['name'] == u'muted_channels': + if message_json['name'] == 'muted_channels': team.set_muted_channels(message_json['value']) - elif message_json['name'] == u'highlight_words': + 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, **kwargs): """ Currently only used to update status, but lots here we could do. @@ -1937,6 +2311,7 @@ def process_user_change(message_json, eventrouter, **kwargs): dmchannel = team.get_channel_map()[user["name"]] team.channels[dmchannel].render_topic(topic=create_user_status_string(profile)) + def process_user_typing(message_json, eventrouter, **kwargs): channel = kwargs["channel"] team = kwargs["team"] @@ -1944,19 +2319,22 @@ def process_user_typing(message_json, eventrouter, **kwargs): channel.set_typing(team.users.get(message_json["user"]).name) w.bar_item_update("slack_typing_notice") + def process_team_join(message_json, eventrouter, **kwargs): user = message_json['user'] team = kwargs["team"] team.users[user["id"]] = SlackUser(**user) + def process_pong(message_json, eventrouter, **kwargs): pass + def process_message(message_json, eventrouter, store=True, **kwargs): channel = kwargs["channel"] team = kwargs["team"] - #try: - # send these subtype messages elsewhere + # try: + # send these subtype messages elsewhere known_subtypes = [ 'thread_message', 'message_replied', @@ -1965,8 +2343,8 @@ def process_message(message_json, eventrouter, store=True, **kwargs): 'channel_join', 'channel_leave', 'channel_topic', - #'group_join', - #'group_leave', + # 'group_join', + # 'group_leave', ] if "thread_ts" in message_json and "reply_count" not in message_json: message_json["subtype"] = "thread_message" @@ -2005,16 +2383,15 @@ def process_message(message_json, eventrouter, store=True, **kwargs): if store: channel.store_message(message, team) dbg("NORMAL REPLY {}".format(message_json)) - #except: - # channel.buffer_prnt("WEE-SLACK-ERROR", json.dumps(message_json).encode('utf-8'), message_json["ts"], **kwargs) + # except: + # channel.buffer_prnt("WEE-SLACK-ERROR", json.dumps(message_json), message_json["ts"], **kwargs) # traceback.print_exc() + def subprocess_thread_message(message_json, eventrouter, channel, team): - #print ("THREADED: " + str(message_json)) + # print ("THREADED: " + str(message_json)) parent_ts = message_json.get('thread_ts', None) if parent_ts: - #parent_ts = SlackTS(parent_ts) - parent_ts = parent_ts parent_message = channel.messages.get(SlackTS(parent_ts), None) if parent_message: message = SlackThreadMessage(parent_ts, message_json, team, channel) @@ -2024,7 +2401,7 @@ def subprocess_thread_message(message_json, eventrouter, channel, team): channel.change_message(parent_ts) text = message.render() - #channel.buffer_prnt(message.sender, text, message.ts, **kwargs) + # channel.buffer_prnt(message.sender, text, message.ts, **kwargs) if parent_message.thread_channel: parent_message.thread_channel.buffer_prnt(message.sender, text, message.ts) @@ -2040,11 +2417,12 @@ def subprocess_thread_message(message_json, eventrouter, channel, team): # else: # dbg("COULDN'T find orig message {}".format(message_json['thread_ts']), main_buffer=True) - #if threadinfo[0]: + # if threadinfo[0]: # channel.messages[threadinfo[1]].become_thread() # message_json["item"]["ts"], message_json) - #channel.change_message(message_json["thread_ts"], None, message_json["text"]) - #channel.become_thread(message_json["item"]["ts"], message_json) + # channel.change_message(message_json["thread_ts"], None, message_json["text"]) + # channel.become_thread(message_json["item"]["ts"], message_json) + def subprocess_channel_join(message_json, eventrouter, channel, team): joinprefix = w.prefix("join") @@ -2052,23 +2430,25 @@ def subprocess_channel_join(message_json, eventrouter, channel, team): channel.buffer_prnt(joinprefix, message.render(), message_json["ts"], tagset='joinleave') channel.user_joined(message_json['user']) + def subprocess_channel_leave(message_json, eventrouter, channel, team): leaveprefix = w.prefix("quit") message = SlackMessage(message_json, team, channel, override_sender=leaveprefix) channel.buffer_prnt(leaveprefix, message.render(), message_json["ts"], tagset='joinleave') channel.user_left(message_json['user']) - #channel.update_nicklist(message_json['user']) - #channel.update_nicklist() + # channel.update_nicklist(message_json['user']) + # channel.update_nicklist() + def subprocess_message_replied(message_json, eventrouter, channel, team): pass - #print ("REPLIED: " + str(message_json)) + def subprocess_message_changed(message_json, eventrouter, channel, team): m = message_json.get("message", None) if m: new_message = m - #message = SlackMessage(new_message, team, channel) + # message = SlackMessage(new_message, team, channel) if "attachments" in m: message_json["attachments"] = m["attachments"] if "text" in m: @@ -2083,23 +2463,22 @@ def subprocess_message_changed(message_json, eventrouter, channel, team): else: message_json["fallback"] = m["fallback"] - text_before = (len(new_message['text']) > 0) - new_message["text"] += unwrap_attachments(message_json, text_before) + new_message["text"] += unwrap_attachments(message_json, new_message["text"]) if "edited" in new_message: channel.change_message(new_message["ts"], new_message["text"], ' (edited)') else: channel.change_message(new_message["ts"], new_message["text"]) + def subprocess_message_deleted(message_json, eventrouter, channel, team): channel.change_message(message_json["deleted_ts"], "(deleted)", '') + def subprocess_channel_topic(message_json, eventrouter, channel, team): - text = unfurl_refs(message_json["text"], ignore_alt_text=False) - if type(text) != unicode: - text = text.decode('utf-8', 'ignore') - text = text.encode('utf-8') + text = unhtmlescape(unfurl_refs(message_json["text"], ignore_alt_text=False)) channel.buffer_prnt(w.prefix("network").rstrip(), text, message_json["ts"], tagset="muted") - channel.render_topic(message_json["topic"]) + channel.set_topic(unhtmlescape(message_json["topic"])) + def process_reply(message_json, eventrouter, **kwargs): dbg('processing reply') @@ -2116,10 +2495,8 @@ def process_reply(message_json, eventrouter, **kwargs): c = original_message_json.get('channel', None) channel = team.channels[c] m = SlackMessage(original_message_json, team, channel) - # m = Message(message_json, server=server) - #dbg(m, True) - #if "type" in message_json: + # if "type" in message_json: # if message_json["type"] == "message" and "channel" in message_json.keys(): # message_json["ts"] = message_json["ts"] # channels.find(message_json["channel"]).store_message(m, from_me=True) @@ -2132,6 +2509,7 @@ def process_reply(message_json, eventrouter, **kwargs): except KeyError: dbg("Unexpected reply {}".format(message_json)) + def process_channel_marked(message_json, eventrouter, **kwargs): """ complete @@ -2142,29 +2520,39 @@ def process_channel_marked(message_json, eventrouter, **kwargs): channel.mark_read(ts=ts, force=True, update_remote=False) else: dbg("tried to mark something weird {}".format(message_json)) + + def process_group_marked(message_json, eventrouter, **kwargs): process_channel_marked(message_json, eventrouter, **kwargs) + + def process_im_marked(message_json, eventrouter, **kwargs): process_channel_marked(message_json, eventrouter, **kwargs) + + def process_mpim_marked(message_json, eventrouter, **kwargs): process_channel_marked(message_json, eventrouter, **kwargs) + def process_channel_joined(message_json, eventrouter, **kwargs): item = message_json["channel"] kwargs['team'].channels[item["id"]].update_from_message_json(item) kwargs['team'].channels[item["id"]].open() + def process_channel_created(message_json, eventrouter, **kwargs): item = message_json["channel"] c = SlackChannel(eventrouter, team=kwargs["team"], **item) kwargs['team'].channels[item["id"]] = c kwargs['team'].buffer_prnt('Channel created: {}'.format(c.slack_name)) + def process_channel_rename(message_json, eventrouter, **kwargs): item = message_json["channel"] channel = kwargs['team'].channels[item["id"]] channel.slack_name = message_json['channel']['name'] + def process_im_created(message_json, eventrouter, **kwargs): team = kwargs['team'] item = message_json["channel"] @@ -2172,17 +2560,20 @@ def process_im_created(message_json, eventrouter, **kwargs): team.channels[item["id"]] = c kwargs['team'].buffer_prnt('IM channel created: {}'.format(c.name)) + def process_im_open(message_json, eventrouter, **kwargs): channel = kwargs['channel'] item = message_json kwargs['team'].channels[item["channel"]].check_should_open(True) w.buffer_set(channel.channel_buffer, "hotlist", "2") + def process_im_close(message_json, eventrouter, **kwargs): item = message_json cbuf = kwargs['team'].channels[item["channel"]].channel_buffer eventrouter.weechat_controller.unregister_buffer(cbuf, False, True) + def process_group_joined(message_json, eventrouter, **kwargs): item = message_json["channel"] if item["name"].startswith("mpdm-"): @@ -2192,8 +2583,9 @@ def process_group_joined(message_json, eventrouter, **kwargs): kwargs['team'].channels[item["id"]] = c kwargs['team'].channels[item["id"]].open() + def process_reaction_added(message_json, eventrouter, **kwargs): - channel = kwargs['team'].channels[message_json["item"]["channel"]] + channel = kwargs['team'].channels.get(message_json["item"].get("channel")) if message_json["item"].get("type") == "message": ts = SlackTS(message_json['item']["ts"]) @@ -2204,8 +2596,9 @@ def process_reaction_added(message_json, eventrouter, **kwargs): else: dbg("reaction to item type not supported: " + str(message_json)) + def process_reaction_removed(message_json, eventrouter, **kwargs): - channel = kwargs['team'].channels[message_json["item"]["channel"]] + channel = kwargs['team'].channels.get(message_json["item"].get("channel")) if message_json["item"].get("type") == "message": ts = SlackTS(message_json['item']["ts"]) @@ -2216,8 +2609,25 @@ def process_reaction_removed(message_json, eventrouter, **kwargs): else: dbg("Reaction to item type not supported: " + str(message_json)) + +def process_emoji_changed(message_json, eventrouter, **kwargs): + team = kwargs['team'] + team.load_emoji_completions() + ###### New module/global methods +def render_formatting(text): + text = re.sub(r'(^| )\*([^*]+)\*([^a-zA-Z0-9_]|$)', + r'\1{}\2{}\3'.format(w.color(config.render_bold_as), + w.color('-' + config.render_bold_as)), + text) + text = re.sub(r'(^| )_([^_]+)_([^a-zA-Z0-9_]|$)', + r'\1{}\2{}\3'.format(w.color(config.render_italic_as), + w.color('-' + config.render_italic_as)), + text) + return text + + def render(message_json, team, channel, force=False): # If we already have a rendered version in the object, just return that. if not force and message_json.get("_rendered_text", ""): @@ -2231,50 +2641,48 @@ def render(message_json, team, channel, force=False): if message_json['text'] is not None: text = message_json["text"] else: - text = u"" + text = "" else: - text = u"" + text = "" - text = unfurl_refs(text, ignore_alt_text=config.unfurl_ignore_alt_text) + text = unfurl_refs(text) - text_before = (len(text) > 0) - text += unfurl_refs(unwrap_attachments(message_json, text_before), ignore_alt_text=config.unfurl_ignore_alt_text) + text += unfurl_refs(unwrap_attachments(message_json, text)) text = text.lstrip() - text = text.replace("\t", " ") - text = text.replace("<", "<") - text = text.replace(">", ">") - text = text.replace("&", "&") - text = re.sub(r'(^| )\*([^*]+)\*([^a-zA-Z0-9_]|$)', - r'\1{}\2{}\3'.format(w.color('bold'), w.color('-bold')), text) - text = re.sub(r'(^| )_([^_]+)_([^a-zA-Z0-9_]|$)', - r'\1{}\2{}\3'.format(w.color('underline'), w.color('-underline')), text) - - if type(text) is not unicode: - text = text.decode('UTF-8', 'replace') - text = text.encode('utf-8') + text = unhtmlescape(text.replace("\t", " ")) + if message_json.get('mrkdwn', True): + text = render_formatting(text) # if self.threads: # text += " [Replies: {} Thread ID: {} ] ".format(len(self.threads), self.thread_id) # #for thread in self.threads: text += create_reaction_string(message_json.get("reactions", "")) - message_json["_rendered_text"] = text - return text + def linkify_text(message, team, channel): # 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() - message = message.replace('\x02', '*').replace('\x1F', '_').split(' ') + message = (message + # Replace IRC formatting chars with Slack formatting chars. + .replace('\x02', '*') + .replace('\x1D', '_') + .replace('\x1F', config.map_underline_to) + # Escape chars that have special meaning to Slack. Note that we do not + # (and should not) perform full HTML entity-encoding here. + # See https://api.slack.com/docs/message-formatting for details. + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .split(' ')) for item in enumerate(message): targets = re.match('^\s*([@#])([\w.-]+[\w. -])(\W*)', item[1]) - #print targets if targets and targets.groups()[0] == '@': - #print targets.groups() named = targets.groups() if named[1] in ["group", "channel", "here"]: message[item[0]] = "<!{}>".format(named[1]) @@ -2292,10 +2700,11 @@ def linkify_text(message, team, channel): except: message[item[0]] = "#{}{}".format(named[1], named[2]) - #dbg(message) + # dbg(message) return " ".join(message) -def unfurl_refs(text, ignore_alt_text=False): + +def unfurl_refs(text, ignore_alt_text=None, auto_link_display=None): """ input : <@U096Q7CQM|someuser> has joined the channel ouput : someuser has joined the channel @@ -2305,13 +2714,21 @@ def unfurl_refs(text, ignore_alt_text=False): # - <#C2147483705|#otherchannel> # - <@U2147483697|@othernick> # Test patterns lives in ./_pytest/test_unfurl.py - matches = re.findall(r"(<[@#]?(?:[^<]*)>)", text) + + if ignore_alt_text is None: + ignore_alt_text = config.unfurl_ignore_alt_text + if auto_link_display is None: + auto_link_display = config.unfurl_auto_link_display + + matches = re.findall(r"(<[@#]?(?:[^>]*)>)", text) for m in matches: # Replace them with human readable strings - text = text.replace(m, unfurl_ref(m[1:-1], ignore_alt_text)) + text = text.replace( + m, unfurl_ref(m[1:-1], ignore_alt_text, auto_link_display)) return text -def unfurl_ref(ref, ignore_alt_text=False): + +def unfurl_ref(ref, ignore_alt_text, auto_link_display): id = ref.split('|')[0] display_text = ref if ref.find('|') > -1: @@ -2324,17 +2741,32 @@ def unfurl_ref(ref, ignore_alt_text=False): display_text = ref.split('|')[1] else: url, desc = ref.split('|', 1) - display_text = u"{} ({})".format(url, desc) + match_url = r"^\w+:(//)?{}$".format(re.escape(desc)) + url_matches_desc = re.match(match_url, url) + if url_matches_desc and auto_link_display == "text": + display_text = desc + elif url_matches_desc and auto_link_display == "url": + display_text = url + else: + display_text = "{} ({})".format(url, desc) else: display_text = resolve_ref(ref) return display_text + +def unhtmlescape(text): + return text.replace("<", "<") \ + .replace(">", ">") \ + .replace("&", "&") + + def unwrap_attachments(message_json, text_before): - attachment_text = '' + text_before_unescaped = unhtmlescape(text_before) + attachment_texts = [] a = message_json.get("attachments", None) if a: if text_before: - attachment_text = u'\n' + attachment_texts.append('') for attachment in a: # Attachments should be rendered roughly like: # @@ -2349,14 +2781,18 @@ def unwrap_attachments(message_json, text_before): if 'pretext' in attachment: t.append(attachment['pretext']) title = attachment.get('title', None) - title_link = attachment.get('title_link', None) + 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(prepend_title_text + title) + t.append('%s%s' % (prepend_title_text, title,)) prepend_title_text = '' - t.append(attachment.get("from_url", "")) + 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", None) if atext: @@ -2373,39 +2809,41 @@ def unwrap_attachments(message_json, text_before): fallback = attachment.get("fallback", None) if t == [] and fallback: t.append(fallback) - attachment_text += "\n".join([x.strip() for x in t if x]) - return attachment_text + attachment_texts.append("\n".join([x.strip() for x in t if x])) + return "\n".join(attachment_texts) def resolve_ref(ref): - #TODO: This hack to use eventrouter needs to go - #this resolver should probably move to the slackteam or eventrouter itself - #global EVENTROUTER + # TODO: This hack to use eventrouter needs to go + # this resolver should probably move to the slackteam or eventrouter itself + # global EVENTROUTER if 'EVENTROUTER' in globals(): e = EVENTROUTER if ref.startswith('@U') or ref.startswith('@W'): for t in e.teams.keys(): if ref[1:] in e.teams[t].users: - #try: + # try: return "@{}".format(e.teams[t].users[ref[1:]].name) - #except: + # except: # dbg("NAME: {}".format(ref)) elif ref.startswith('#C'): for t in e.teams.keys(): if ref[1:] in e.teams[t].channels: - #try: + # try: return "{}".format(e.teams[t].channels[ref[1:]].name) - #except: + # except: # dbg("CHANNEL: {}".format(ref)) # Something else, just return as-is return ref + def create_user_status_string(profile): status_emoji = profile.get("status_emoji") if profile.get("status_emoji") else "None" status_text = profile.get("status_text") if profile.get("status_text") else "None" return "[{}] {}".format(status_emoji, status_text) + def create_reaction_string(reactions): count = 0 if not isinstance(reactions, list): @@ -2426,6 +2864,7 @@ def create_reaction_string(reactions): reaction_string = '' return reaction_string + def modify_buffer_line(buffer, new_line, timestamp, time_id): # get a pointer to this buffer's lines own_lines = w.hdata_pointer(w.hdata_get('buffer'), buffer, 'own_lines') @@ -2435,6 +2874,8 @@ def modify_buffer_line(buffer, new_line, timestamp, time_id): # hold the structure of a line and of line data struct_hdata_line = w.hdata_get('line') struct_hdata_line_data = w.hdata_get('line_data') + # keep track of the number of lines with the matching time and id + number_of_matching_lines = 0 while line_pointer: # get a pointer to the data in line_pointer via layout of struct_hdata_line @@ -2445,13 +2886,32 @@ def modify_buffer_line(buffer, new_line, timestamp, time_id): # prefix = w.hdata_string(struct_hdata_line_data, data, 'prefix') if timestamp == int(line_timestamp) and int(time_id) == line_time_id: - # w.prnt("", "found matching time date is {}, time is {} ".format(timestamp, line_timestamp)) - w.hdata_update(struct_hdata_line_data, data, {"message": new_line}) + number_of_matching_lines += 1 + elif number_of_matching_lines > 0: + # since number_of_matching_lines is non-zero, we have + # already reached the message and can stop traversing break - else: - pass + else: + dbg(('Encountered line without any data while trying to modify ' + 'line. This is not handled, so aborting modification.')) + return w.WEECHAT_RC_ERROR # move backwards one line and try again - exit the while if you hit the end line_pointer = w.hdata_move(struct_hdata_line, line_pointer, -1) + + # split the message into at most the number of existing lines + lines = new_line.split('\n', number_of_matching_lines - 1) + # updating a line with a string containing newlines causes the lines to + # be broken when viewed in bare display mode + lines = [line.replace('\n', ' | ') for line in lines] + # pad the list with empty strings until the number of elements equals + # number_of_matching_lines + lines += [''] * (number_of_matching_lines - len(lines)) + + if line_pointer: + for line in lines: + line_pointer = w.hdata_move(struct_hdata_line, line_pointer, 1) + data = w.hdata_pointer(struct_hdata_line, line_pointer, 'data') + w.hdata_update(struct_hdata_line_data, data, {"message": line}) return w.WEECHAT_RC_OK @@ -2460,6 +2920,7 @@ def modify_print_time(buffer, new_id, time): This overloads the time printed field to let us store the slack per message unique id that comes after the "." in a slack ts """ + # get a pointer to this buffer's lines own_lines = w.hdata_pointer(w.hdata_get('buffer'), buffer, 'own_lines') if own_lines: @@ -2469,13 +2930,35 @@ def modify_print_time(buffer, new_id, time): struct_hdata_line = w.hdata_get('line') struct_hdata_line_data = w.hdata_get('line_data') - # get a pointer to the data in line_pointer via layout of struct_hdata_line - data = w.hdata_pointer(struct_hdata_line, line_pointer, 'data') - if data: - w.hdata_update(struct_hdata_line_data, data, {"date_printed": new_id}) + prefix = '' + while not prefix and line_pointer: + # get a pointer to the data in line_pointer via layout of struct_hdata_line + data = w.hdata_pointer(struct_hdata_line, line_pointer, 'data') + if data: + prefix = w.hdata_string(struct_hdata_line_data, data, 'prefix') + w.hdata_update(struct_hdata_line_data, data, {"date_printed": new_id}) + else: + dbg('Encountered line without any data while setting message id.') + return w.WEECHAT_RC_ERROR + # move backwards one line and repeat, so all the lines of the message are set + # exit when you reach a prefix, which means you have reached the + # first line of the message, or if you hit the end + line_pointer = w.hdata_move(struct_hdata_line, line_pointer, -1) return w.WEECHAT_RC_OK + +def format_nick(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_prefix_color = w.color(nick_prefix_color_name) + + 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')) + nick_suffix_color = w.color(nick_suffix_color_name) + return nick_prefix_color + nick_prefix + w.color("reset") + nick + nick_suffix_color + nick_suffix + w.color("reset") + + def tag(tagset, user=None): if user: user.replace(" ", "_") @@ -2483,27 +2966,28 @@ def tag(tagset, user=None): else: default_tag = 'nick_unknown' tagsets = { - #when replaying something old - "backlog": "no_highlight,notify_none,logger_backlog_end", - #when posting messages to a muted channel - "muted": "no_highlight,notify_none,logger_backlog_end", - #when my nick is in the message - "highlightme": "notify_highlight,log1", - #when receiving a direct message - "dm": "notify_private,notify_message,log1,irc_privmsg", - "dmfromme": "notify_none,log1,irc_privmsg", - #when this is a join/leave, attach for smart filter ala: - #if user in [x.strip() for x in w.prefix("join"), w.prefix("quit")] - "joinleave": "irc_smart_filter,no_highlight", - #catchall ? - "default": "notify_message,log1", + # messages in the team/server buffer, e.g. "new channel created" + "team": "no_highlight,log3", + # when replaying something old + "backlog": "irc_privmsg,no_highlight,notify_none,logger_backlog", + # when posting messages to a muted channel + "muted": "irc_privmsg,no_highlight,notify_none,log1", + # when receiving a direct message + "dm": "irc_privmsg,notify_private,log1", + "dmfromme": "irc_privmsg,no_highlight,notify_none,log1", + # when this is a join/leave, attach for smart filter ala: + # if user in [x.strip() for x in w.prefix("join"), w.prefix("quit")] + "joinleave": "irc_smart_filter,no_highlight,log4", + # catchall ? + "default": "irc_privmsg,notify_message,log1", } - return default_tag + "," + tagsets[tagset] - + return "{},slack_{},{}".format(default_tag, tagset, tagsets[tagset]) ###### New/converted command_ commands + @slack_buffer_or_ignore +@utf8_decode def part_command_cb(data, current_buffer, args): e = EVENTROUTER args = args.split() @@ -2518,102 +3002,247 @@ def part_command_cb(data, current_buffer, args): e.weechat_controller.unregister_buffer(current_buffer, update_remote=True, close_buffer=True) return w.WEECHAT_RC_OK_EAT -@slack_buffer_or_ignore -def topic_command_cb(data, current_buffer, args): - n = len(args.split()) - if n < 2: - channel = channels.find(current_buffer) - if channel: - w.prnt(current_buffer, 'Topic for {} is "{}"'.format(channel.name, channel.topic)) - return w.WEECHAT_RC_OK_EAT - elif command_topic(data, current_buffer, args.split(None, 1)[1]): - return w.WEECHAT_RC_OK_EAT - else: - return w.WEECHAT_RC_ERROR -@slack_buffer_required -def command_topic(data, current_buffer, args): +def parse_topic_command(command): + args = command.split()[1:] + channel_name = None + topic = None + + if args: + if args[0].startswith('#'): + channel_name = args[0][1:] + 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 - /slack topic [<channel>] [<topic>|-delete] + /topic [<channel>] [<topic>|-delete] """ - e = EVENTROUTER - team = e.weechat_controller.buffers[current_buffer].team - #server = servers.find(current_domain_name()) - args = args.split(' ') - if len(args) > 2 and args[1].startswith('#'): - cmap = team.get_channel_map() - channel_name = args[1][1:] - channel = team.channels[cmap[channel_name]] - topic = " ".join(args[2:]) + + 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 = e.weechat_controller.buffers[current_buffer] - topic = " ".join(args[1:]) + channel = EVENTROUTER.weechat_controller.buffers[current_buffer] - if channel: - if topic == "-delete": - topic = '' + 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.topic)) + else: s = SlackRequest(team.token, "channels.setTopic", {"channel": channel.identifier, "topic": topic}, team_hash=team.team_hash) 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 <display_name> + """ + + 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: + team.buffer_prnt("[{}]: {}".format(user, u.real_name)) + if u.profile.get("status_text"): + team.buffer_prnt("[{}]: {} {}".format(user, u.profile.status_emoji, u.profile.status_text)) + team.buffer_prnt("[{}]: Real name: {}".format(user, u.profile.get('real_name_normalized', ''))) + team.buffer_prnt("[{}]: Title: {}".format(user, u.profile.get('title', ''))) + team.buffer_prnt("[{}]: Email: {}".format(user, u.profile.get('email', ''))) + team.buffer_prnt("[{}]: Phone: {}".format(user, u.profile.get('phone', ''))) else: - return w.WEECHAT_RC_ERROR_EAT + 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): message = "_{}_".format(args.split(' ', 1)[1]) buffer_input_callback("EVENTROUTER", current_buffer, message) return w.WEECHAT_RC_OK_EAT + +def command_register(data, current_buffer, args): + CLIENT_ID = "2468770254.51917335286" + CLIENT_SECRET = "dcb7fe380a000cba0cca3169a5fe8d70" # Not really a secret. + if args == 'register': + message = textwrap.dedent(""" + #### Retrieving a Slack token via OAUTH #### + + 1) Paste this into a browser: https://slack.com/oauth/authorize?client_id=2468770254.51917335286&scope=client + 2) Select the team you wish to access from wee-slack in your browser. + 3) Click "Authorize" in the browser **IMPORTANT: the redirect will fail, this is expected** + 4) Copy the "code" portion of the URL to your clipboard + 5) Return to weechat and run `/slack register [code]` + """) + w.prnt("", message) + return + + try: + _, oauth_code = args.split() + except ValueError: + w.prnt("", + "ERROR: wrong number of arguments given for register command") + return + + uri = ( + "https://slack.com/api/oauth.access?" + "client_id={}&client_secret={}&code={}" + ).format(CLIENT_ID, CLIENT_SECRET, oauth_code) + ret = urllib.urlopen(uri).read() + d = json.loads(ret) + if not d["ok"]: + w.prnt("", + "ERROR: Couldn't get Slack OAuth token: {}".format(d['error'])) + return + + 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: /script reload slack") + + @slack_buffer_or_ignore +@utf8_decode def msg_command_cb(data, current_buffer, args): dbg("msg_command_cb") aargs = args.split(None, 2) who = aargs[1] - command_talk(data, current_buffer, who) + if who == "*": + who = EVENTROUTER.weechat_controller.buffers[current_buffer].slack_name + else: + command_talk(data, current_buffer, "talk " + 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[channel]] + channel = team.channels[cmap[who]] channel.send_message(message) return w.WEECHAT_RC_OK_EAT + +@slack_buffer_required +@utf8_decode +def command_channels(data, current_buffer, args): + e = EVENTROUTER + team = e.weechat_controller.buffers[current_buffer].team + + team.buffer_prnt("Channels:") + for channel in team.get_channel_map(): + team.buffer_prnt(" {}".format(channel)) + return w.WEECHAT_RC_OK_EAT + + +@slack_buffer_required +@utf8_decode +def command_users(data, current_buffer, args): + e = EVENTROUTER + team = e.weechat_controller.buffers[current_buffer].team + + team.buffer_prnt("Users:") + for user in team.users.values(): + team.buffer_prnt(" {:<25}({})".format(user.name, user.presence)) + return w.WEECHAT_RC_OK_EAT + + @slack_buffer_or_ignore +@utf8_decode def command_talk(data, current_buffer, args): """ - Open a chat with the specified user - /slack talk [user] + Open a chat with the specified user(s) + /slack talk <user>[,<user2>[,<user3>...]] """ + e = EVENTROUTER team = e.weechat_controller.buffers[current_buffer].team channel_name = args.split(' ')[1] - c = team.get_channel_map() - if channel_name not in c: - u = team.get_username_map() - if channel_name in u: - s = SlackRequest(team.token, "im.open", {"user": u[channel_name]}, team_hash=team.team_hash) - EVENTROUTER.receive(s) - dbg("found user") - #refresh channel map here - c = team.get_channel_map() if channel_name.startswith('#'): channel_name = channel_name[1:] - if channel_name in c: - chan = team.channels[c[channel_name]] + + # Try finding the channel by name + chan = team.channels.get(team.get_channel_map().get(channel_name)) + + # If the channel doesn't exist, try finding a DM or MPDM instead + if not chan: + # Get the IDs of the users + u = team.get_username_map() + users = set() + for user in channel_name.split(','): + if user.startswith('@'): + user = user[1:] + if user in u: + users.add(u[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' + + # Try finding the channel by type and members + for channel in team.channels.itervalues(): + if (channel.type == channel_type and + channel.get_members() == users): + chan = channel + break + + # If the DM or MPDM doesn't exist, create it + if not chan: + s = SlackRequest(team.token, SLACK_API_TRANSLATOR[channel_type]['join'], {'users': ','.join(users)}, team_hash=team.team_hash) + EVENTROUTER.receive(s) + + if chan: chan.open() if config.switch_buffer_on_join: w.buffer_set(chan.channel_buffer, "display", "1") return w.WEECHAT_RC_OK_EAT return w.WEECHAT_RC_OK_EAT + def command_showmuted(data, current_buffer, args): current = w.current_buffer() w.prnt(EVENTROUTER.weechat_controller.buffers[current].team.channel_buffer, str(EVENTROUTER.weechat_controller.buffers[current].team.muted_channels)) + +@utf8_decode def thread_command_callback(data, current_buffer, args): current = w.current_buffer() channel = EVENTROUTER.weechat_controller.buffers.get(current) @@ -2628,19 +3257,22 @@ def thread_command_callback(data, current_buffer, args): tc = SlackThreadChannel(EVENTROUTER, pm) pm.thread_channel = tc tc.open() - #tc.create_buffer() + # tc.create_buffer() + if config.switch_buffer_on_join: + w.buffer_set(tc.channel_buffer, "display", "1") return w.WEECHAT_RC_OK_EAT elif args[0] == '/reply': count = int(args[1]) msg = " ".join(args[2:]) - mkeys = channel.sorted_message_keys() - mkeys.reverse() - parent_id = str(mkeys[count - 1]) + mkeys = channel.main_message_keys_reversed() + parent_id = str(next(islice(mkeys, count - 1, None))) channel.send_message(msg, request_dict_ext={"thread_ts": parent_id}) return w.WEECHAT_RC_OK_EAT w.prnt(current, "Invalid thread command.") return w.WEECHAT_RC_OK_EAT + +@utf8_decode def rehistory_command_callback(data, current_buffer, args): current = w.current_buffer() channel = EVENTROUTER.weechat_controller.buffers.get(current) @@ -2649,7 +3281,9 @@ def rehistory_command_callback(data, current_buffer, args): channel.get_history() return w.WEECHAT_RC_OK_EAT + @slack_buffer_required +@utf8_decode def hide_command_callback(data, current_buffer, args): c = EVENTROUTER.weechat_controller.buffers.get(current_buffer, None) if c: @@ -2658,6 +3292,8 @@ def hide_command_callback(data, current_buffer, args): w.buffer_set(c.channel_buffer, "hidden", "1") return w.WEECHAT_RC_OK_EAT + +@utf8_decode def slack_command_cb(data, current_buffer, args): a = args.split(' ', 1) if len(a) > 1: @@ -2671,6 +3307,7 @@ def slack_command_cb(data, current_buffer, args): w.prnt("", "Command not found: " + function_name) return w.WEECHAT_RC_OK + @slack_buffer_required def command_distracting(data, current_buffer, args): channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer, None) @@ -2682,9 +3319,11 @@ def command_distracting(data, current_buffer, args): config.distracting_channels.pop(config.distracting_channels.index(fullname)) save_distracting_channels() + def save_distracting_channels(): w.config_set_plugin('distracting_channels', ','.join(config.distracting_channels)) + @slack_buffer_required def command_slash(data, current_buffer, args): """ @@ -2696,8 +3335,8 @@ def command_slash(data, current_buffer, args): if channel: team = channel.team - if args is None: - server.buffer_prnt("Usage: /slack slash /someslashcommand [arguments...].") + if args == 'slash': + w.prnt("", "Usage: /slack slash /someslashcommand [arguments...].") return split_args = args.split(None, 2) @@ -2707,6 +3346,7 @@ def command_slash(data, current_buffer, args): s = SlackRequest(team.token, "chat.command", {"command": command, "text": text, 'channel': channel.identifier}, team_hash=team.team_hash, channel_identifier=channel.identifier) EVENTROUTER.receive(s) + @slack_buffer_required def command_mute(data, current_buffer, args): current = w.current_buffer() @@ -2719,9 +3359,10 @@ def command_mute(data, current_buffer, args): s = SlackRequest(team.token, "users.prefs.set", {"name": "muted_channels", "value": ",".join(team.muted_channels)}, team_hash=team.team_hash, channel_identifier=channel_id) EVENTROUTER.receive(s) + @slack_buffer_required def command_openweb(data, current_buffer, args): - #if done from server buffer, open slack for reals + # if done from server buffer, open slack for reals channel = EVENTROUTER.weechat_controller.buffers[current_buffer] if isinstance(channel, SlackTeam): url = "https://{}".format(channel.team.domain) @@ -2730,22 +3371,24 @@ def command_openweb(data, current_buffer, args): url = "https://{}/archives/{}/p{}000000".format(channel.team.domain, channel.slack_name, now.majorstr()) w.prnt_date_tags(channel.team.channel_buffer, SlackTS().major, "openweb,logger_backlog_end,notify_none", url) + def command_nodistractions(data, current_buffer, args): global hide_distractions hide_distractions = not hide_distractions if config.distracting_channels != ['']: for channel in config.distracting_channels: dbg('hiding channel {}'.format(channel)) - #try: + # try: for c in EVENTROUTER.weechat_controller.buffers.itervalues(): if c == channel: dbg('found channel {} to hide'.format(channel)) w.buffer_set(c.channel_buffer, "hidden", str(int(hide_distractions))) - #except: + # except: # dbg("Can't hide channel {} .. removing..".format(channel), main_buffer=True) # config.distracting_channels.pop(config.distracting_channels.index(channel)) # save_distracting_channels() + @slack_buffer_required def command_upload(data, current_buffer, args): channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) @@ -2759,8 +3402,10 @@ def command_upload(data, current_buffer, args): command = 'curl -F file=@{} -F channels={} -F token={} {}'.format(file_path, channel.identifier, team.token, url) w.hook_process(command, config.slack_timeout, '', '') + +@utf8_decode def away_command_cb(data, current_buffer, args): - #TODO: reimplement all.. maybe + # TODO: reimplement all.. maybe (all, message) = re.match("^/away(?:\s+(-all))?(?:\s+(.+))?", args).groups() if message is None: command_back(data, current_buffer, args) @@ -2768,6 +3413,7 @@ def away_command_cb(data, current_buffer, args): command_away(data, current_buffer, args) return w.WEECHAT_RC_OK + @slack_buffer_required def command_away(data, current_buffer, args): """ @@ -2775,21 +3421,44 @@ def command_away(data, current_buffer, args): /slack away """ team = EVENTROUTER.weechat_controller.buffers[current_buffer].team - s = SlackRequest(team.token, "presence.set", {"presence": "away"}, team_hash=team.team_hash) + s = SlackRequest(team.token, "users.setPresence", {"presence": "away"}, team_hash=team.team_hash) EVENTROUTER.receive(s) @slack_buffer_required +def command_status(data, current_buffer, args): + """ + Lets you set your Slack Status (not to be confused with away/here) + /slack status [emoji] [status_message] + """ + e = EVENTROUTER + channel = e.weechat_controller.buffers.get(current_buffer, None) + if channel: + team = channel.team + + split_args = args.split(None, 2) + emoji = split_args[1] if len(split_args) > 1 else "" + text = split_args[2] if len(split_args) > 2 else "" + + profile = {"status_text":text,"status_emoji":emoji} + + s = SlackRequest(team.token, "users.profile.set", {"profile": profile}, team_hash=team.team_hash) + EVENTROUTER.receive(s) + + +@slack_buffer_required def command_back(data, current_buffer, args): """ Sets your status as 'back' /slack back """ team = EVENTROUTER.weechat_controller.buffers[current_buffer].team - s = SlackRequest(team.token, "presence.set", {"presence": "active"}, team_hash=team.team_hash) + s = SlackRequest(team.token, "users.setPresence", {"presence": "auto"}, team_hash=team.team_hash) EVENTROUTER.receive(s) + @slack_buffer_required +@utf8_decode def label_command_cb(data, current_buffer, args): channel = EVENTROUTER.weechat_controller.buffers.get(current_buffer) if channel and channel.type == 'thread': @@ -2798,12 +3467,29 @@ def label_command_cb(data, current_buffer, args): channel.label = new_name w.buffer_set(channel.channel_buffer, "short_name", new_name) + +@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.get(current_buffer) + channel.mark_read() + return w.WEECHAT_RC_OK + + def command_p(data, current_buffer, args): args = args.split(' ', 1)[1] w.prnt("", "{}".format(eval(args))) ###### NEW EXCEPTIONS + class ProcessNotImplemented(Exception): """ Raised when we try to call process_(something), but @@ -2812,6 +3498,7 @@ class ProcessNotImplemented(Exception): def __init__(self, function_name): super(ProcessNotImplemented, self).__init__(function_name) + class InvalidType(Exception): """ Raised when we do type checking to ensure objects of the wrong @@ -2822,6 +3509,7 @@ class InvalidType(Exception): ###### New but probably old and need to migrate + def closed_slack_debug_buffer_cb(data, buffer): global slack_debug slack_debug = None @@ -2837,18 +3525,16 @@ def create_slack_debug_buffer(): slack_debug = w.buffer_new("slack-debug", "", "", "closed_slack_debug_buffer_cb", "") w.buffer_set(slack_debug, "notify", "0") + def load_emoji(): try: - global EMOJI DIR = w.info_get("weechat_dir", "") - #no idea why this does't work w/o checking the type?! - dbg(type(DIR), 0) - ef = open('{}/weemoji.json'.format(DIR), 'r') - EMOJI = json.loads(ef.read()) - ef.close() - except: - dbg("Unexpected error: {}".format(sys.exc_info()), 5) - return w.WEECHAT_RC_OK + with open('{}/weemoji.json'.format(DIR), 'r') as ef: + return json.loads(ef.read())["emoji"] + except Exception as e: + dbg("Couldn't load emoji list: {}".format(e), 5) + return [] + def setup_hooks(): cmds = {k[8:]: v for k, v in globals().items() if k.startswith("command_")} @@ -2863,8 +3549,9 @@ def setup_hooks(): w.hook_signal('buffer_closing', "buffer_closing_callback", "EVENTROUTER") w.hook_signal('buffer_switch', "buffer_switch_callback", "EVENTROUTER") w.hook_signal('window_switch', "buffer_switch_callback", "EVENTROUTER") - w.hook_signal('quit', "quit_notification_cb", "") - w.hook_signal('input_text_changed', "typing_notification_cb", "") + w.hook_signal('quit', "quit_notification_callback", "") + if config.send_typing_notice: + w.hook_signal('input_text_changed', "typing_notification_cb", "") w.hook_command( # Command name and description @@ -2879,14 +3566,14 @@ def setup_hooks(): '|'.join(cmds.keys()), # Function name 'slack_command_cb', '') - #w.hook_command('me', '', 'stuff', 'stuff2', '', 'me_command_cb', '') + # w.hook_command('me', '', 'stuff', 'stuff2', '', 'me_command_cb', '') w.hook_command_run('/me', 'me_command_cb', '') w.hook_command_run('/query', 'command_talk', '') w.hook_command_run('/join', 'command_talk', '') w.hook_command_run('/part', 'part_command_cb', '') w.hook_command_run('/leave', 'part_command_cb', '') - w.hook_command_run('/topic', 'command_topic', '') + w.hook_command_run('/topic', 'topic_command_cb', '') w.hook_command_run('/thread', 'thread_command_callback', '') w.hook_command_run('/reply', 'thread_command_callback', '') w.hook_command_run('/rehistory', 'rehistory_command_callback', '') @@ -2894,17 +3581,18 @@ def setup_hooks(): w.hook_command_run('/msg', 'msg_command_cb', '') w.hook_command_run('/label', 'label_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', '') w.hook_completion("nicks", "complete @-nicks for slack", "nick_completion_cb", "") w.hook_completion("emoji", "complete :emoji: for slack", "emoji_completion_cb", "") # Hooks to fix/implement - #w.hook_timer(1000 * 60 * 5, 0, 0, "cache_write_cb", "") - #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", "") - + # 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 @@ -2913,59 +3601,147 @@ 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 - #return + # TODO: do this smarter + # return if level >= config.debug_level: global debug_string message = "DEBUG: {}".format(message) - # message = message.encode('utf-8', 'replace') if fout: file('/tmp/debug.log', 'a+').writelines(message + '\n') if main_buffer: - #w.prnt("", "---------") + # w.prnt("", "---------") w.prnt("", "slack: " + message) else: if slack_debug and (not debug_string or debug_string in message): - #w.prnt(slack_debug, "---------") + # w.prnt(slack_debug, "---------") w.prnt(slack_debug, message) - ###### Config code +Setting = collections.namedtuple('Setting', ['default', 'desc']) + class PluginConfig(object): # Default settings. - # These are in the (string) format that weechat expects; at __init__ time - # this value will be used to set the default for any settings not already - # defined, and then the real (python) values of the settings will be - # extracted. - # TODO: setting descriptions. - settings = { - 'colorize_messages': 'false', - 'colorize_private_chats': 'false', - 'debug_mode': 'false', - 'debug_level': '3', - 'distracting_channels': '', - 'show_reaction_nicks': 'false', - 'slack_api_token': 'INSERT VALID KEY HERE!', - 'slack_timeout': '20000', - 'switch_buffer_on_join': 'true', - 'trigger_value': 'false', - 'unfurl_ignore_alt_text': 'false', - 'cache_messages': 'true', - 'record_events': 'false', - 'thread_suffix_color': 'lightcyan', - 'unhide_buffers_with_activity': 'false', - 'short_buffer_names': 'false', - 'channel_name_typing_indicator': 'true', - 'background_load_all_history': 'false', - 'never_away': 'false', - 'server_aliases': '', + # 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 = { + '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.'), + '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.'), + '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.'), + '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_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`.'), + 'short_buffer_names': Setting( + default='false', + desc='Use `foo.#channel` rather than `foo.slack.com.#channel` as the' + ' internal name for Slack buffers. Overrides server_aliases.'), + '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_suffix_color': Setting( + default='lightcyan', + desc='Color to use for the [thread: XXX] suffix on messages that' + ' have threads attached to them.'), + '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).'), } # 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. + # Use items() rather than iteritems() so we don't need to worry about + # invalidating the iterator. + 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.iteritems(): if not w.config_get_plugin(key): w.config_set_plugin(key, default) @@ -2997,6 +3773,25 @@ class PluginConfig(object): 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_debug_level = get_int + get_group_name_prefix = get_string + get_map_underline_to = get_string + get_render_bold_as = get_string + get_render_italic_as = get_string + get_slack_timeout = get_int + get_thread_suffix_color = get_string + 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(',')] @@ -3012,15 +3807,6 @@ class PluginConfig(object): else: return token - def get_thread_suffix_color(self, key): - return w.config_get_plugin("thread_suffix_color") - - def get_debug_level(self, key): - return int(w.config_get_plugin(key)) - - def get_slack_timeout(self, key): - return int(w.config_get_plugin(key)) - def migrate(self): """ This is to migrate the extension name from slack_extension to slack @@ -3042,6 +3828,7 @@ def setup_trace(): now = time.time() f = open('{}/{}-trace.json'.format(RECORD_DIR, now), 'w') + def trace_calls(frame, event, arg): global f if event != 'call': @@ -3062,25 +3849,30 @@ def trace_calls(frame, event, arg): f.flush() return +def initiate_connection(token, retries=3): + return SlackRequest(token, + 'rtm.start', + {"batch_presence_aware": 1 }, + retries=retries) # Main if __name__ == "__main__": + w = WeechatWrapper(weechat) + if w.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, "script_unloaded", ""): - version = w.info_get("version_number", "") or 0 - if int(version) < 0x1030000: + 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() - #setup_trace() + # setup_trace() - #WEECHAT_HOME = w.info_get("weechat_dir", "") - #CACHE_NAME = "slack.cache" - #STOP_TALKING_TO_SLACK = False + # WEECHAT_HOME = w.info_get("weechat_dir", "") # Global var section slack_debug = None @@ -3088,29 +3880,26 @@ if __name__ == "__main__": config_changed_cb = config.config_changed typing_timer = time.time() - #domain = None - #previous_buffer = None - #slack_buffer = None + # domain = None + # previous_buffer = None + # slack_buffer = None - #never_away = False + # never_away = False hide_distractions = False - #hotlist = w.infolist_get("hotlist", "", "") - #main_weechat_buffer = w.info_get("irc_buffer", "{}.{}".format(domain, "DOESNOTEXIST!@#$")) - - #message_cache = collections.defaultdict(list) - #if config.cache_messages: - # cache_load() + # hotlist = w.infolist_get("hotlist", "", "") + # main_weechat_buffer = w.info_get("irc_buffer", "{}.{}".format(domain, "DOESNOTEXIST!@#$")) w.hook_config("plugins.var.python." + SCRIPT_NAME + ".*", "config_changed_cb", "") + w.hook_modifier("input_text_for_buffer", "input_text_for_buffer_cb", "") - load_emoji() + EMOJI.extend(load_emoji()) setup_hooks() # attach to the weechat hooks we need tokens = config.slack_api_token.split(',') for t in tokens: - s = SlackRequest(t, 'rtm.start', {}) + s = initiate_connection(t) EVENTROUTER.receive(s) if config.record_events: EVENTROUTER.record() diff --git a/weemoji.json b/weemoji.json index eb19849..3b3e045 100644 --- a/weemoji.json +++ b/weemoji.json @@ -1 +1,1348 @@ -{"emoji": ["wine_glass", "flag-tl", "flag-tn", "clock830", "flag-th", "rabbit", "flag-tj", "european_post_office", "flag-nr", "tram", "wink", "flag-tg", "department_store", "flag-ta", "slightly_smiling_face", "alien", "crocodile", "flag-au", "flag-tz", "bulb", "heavy_heart_exclamation_mark_ornament", "bomb", "flag-tv", "golfer", "hole", "flag-tr", "maple_leaf", "building_construction", "face_with_rolling_eyes", "man_with_gua_pi_mao", "person_with_ball", "e-mail", "tv", "open_hands", "sweat_drops", "pager", "file_cabinet", "laughing", "part_alternation_mark", "flag-td", "tm", "mountain_cableway", "melon", "smile", "snow_cloud", "large_blue_circle", "persevere", "sound", "fax", "woman", "eight_pointed_black_star", "thought_balloon", "end", "oncoming_automobile", "wave", "u7a7a", "woman-woman-boy-boy", "flag-cd", "hammer_and_wrench", "ticket", "flag-tc", "ramen", "twisted_rightwards_arrows", "cool", "four", "school", "small_airplane", "high_brightness", "nerd_face", "upside_down_face", "deciduous_tree", "notes", "white_flower", "biohazard_sign", "gun", "video_game", "saxophone", "car", "flag-ic", "notebook_with_decorative_cover", "triumph", "flag-io", "flag-in", "flag-im", "slightly_frowning_face", "black_right_pointing_double_triangle_with_vertical_bar", "tea", "flag-ls", "flag-bn", "dove_of_peace", "flag-ie", "arrow_left", "old_key", "flag-tt", "zero", "small_orange_diamond", "a", "white_square_button", "flag-is", "hankey", "flag-iq", "cactus", "spaghetti", "white_small_square", "ribbon", "flag-it", "toilet", "mega", "abc", "hocho", "flag-sr", "knife_fork_plate", "flag-fo", "purple_heart", "love_letter", "flag-fk", "file_folder", "flag-fi", "clipboard", "baby_bottle", "new", "bird", "flag-ua", "1234", "peace_symbol", "spock-hand", "couch_and_lamp", "no_smoking", "no_bicycles", "herb", "pouting_cat", "vertical_traffic_light", "leo", "house_with_garden", "flag-pm", "baseball", "busstop", "new_moon", "kissing", "man-woman-boy-boy", "100", "flag-na", "boy", "flag-sl", "capital_abcd", "no_entry", "wheel_of_dharma", "metro", "leaves", "heavy_plus_sign", "roller_coaster", "game_die", "man-man-girl-girl", "classical_building", "hamster", "flag-gy", "pick", "popcorn", "cold_sweat", "massage", "fleur_de_lis", "flag-pr", "chains", "flag-pt", "apple", "family", "scales", "sleeping_accommodation", "rice_cracker", "wind_blowing_face", "inbox_tray", "flag-ma", "flag-pa", "green_heart", "mahjong", "flag-pf", "flag-pg", "flag-ph", "flag-ec", "sleuth_or_spy", "clock330", "flag-ca", "dango", "honey_pot", "eye", "keycap_star", "baby", "sake", "confounded", "hospital", "poodle", "frog", "musical_note", "camera", "sleeping", "crescent_moon", "world_map", "aries", "flag-nl", "ear_of_rice", "flag-si", "video_camera", "mouse2", "chestnut", "flag-mg", "guardsman", "clock230", "baby_symbol", "atom_symbol", "steam_locomotive", "man_in_business_suit_levitating", "motor_boat", "tangerine", "blue_heart", "mantelpiece_clock", "recycle", "train", "beers", "water_buffalo", "flag-cz", "first_quarter_moon_with_face", "mailbox_closed", "curly_loop", "lower_left_fountain_pen", "pouch", "flag-ba", "jack_o_lantern", "izakaya_lantern", "palm_tree", "derelict_house_building", "tired_face", "cat", "dizzy", "nine", "chocolate_bar", "v", "running_shirt_with_sash", "ferry", "arrow_lower_left", "put_litter_in_its_place", "coffin", "abcd", "heart", "chart_with_upwards_trend", "arrow_backward", "hamburger", "pushpin", "lock", "flag-eu", "dolphin", "flag-es", "confused", "accept", "night_with_stars", "studio_microphone", "pig2", "white_medium_small_square", "flag-eh", "flag-eg", "sunglasses", "airplane", "trumpet", "flag-ee", "bow", "flag-bj", "clock12", "earth_americas", "see_no_evil", "scorpius", "flag-bo", "flag-bl", "flag-bm", "flag-bb", "mouse", "speedboat", "six", "snowman_without_snow", "ledger", "flag-bd", "flag-be", "flag-bz", "small_blue_diamond", "leftwards_arrow_with_hook", "amphora", "rewind", "flag-br", "no_bell", "flag-mc", "earth_asia", "flag-bv", "goat", "flag-bt", "pizza", "heavy_check_mark", "trident", "briefcase", "cocktail", "kissing_closed_eyes", "sunny", "star_of_david", "flag-bh", "customs", "motorway", "fork_and_knife", "birthday", "fast_forward", "heartpulse", "mag", "taco", "sparkler", "sparkles", "flag-va", "shirt", "tomato", "womens", "octopus", "wheelchair", "volleyball", "dragon", "mostly_sunny", "tulip", "flag-cu", "truck", "wrench", "flag-je", "ambulance", "sa", "point_up_2", "egg", "small_red_triangle", "umbrella_with_rain_drops", "flag-gp", "shield", "office", "mute", "clapper", "flag-bf", "funeral_urn", "haircut", "soon", "flag-bg", "symbols", "black_square_button", "flag-jp", "keyboard", "japan", "post_office", "last_quarter_moon_with_face", "flag-sb", "rosette", "pray", "linked_paperclips", "flushed", "flag-sa", "dark_sunglasses", "dizzy_face", "rugby_football", "currency_exchange", "flag-by", "paperclip", "moneybag", "mailbox_with_no_mail", "man-woman-girl-girl", "sob", "soccer", "dolls", "flag-gr", "coffee", "tiger2", "flag-la", "flag-lb", "neutral_face", "black_right_pointing_triangle_with_double_vertical_bar", "monorail", "elephant", "flag-li", "open_mouth", "bar_chart", "flag-lt", "european_castle", "flag-lv", "page_with_curl", "woman-heart-woman", "snake", "kiss", "blue_car", "confetti_ball", "flag-ly", "bank", "bread", "minidisc", "flag-mt", "flag-bq", "rice_ball", "oncoming_police_car", "capricorn", "point_left", "flag-gw", "tokyo_tower", "barely_sunny", "weary", "flag-bw", "clock930", "fishing_pole_and_fish", "repeat_one", "bowling", "volcano", "older_woman", "railway_car", "smiley_cat", "flag-er", "information_source", "cry", "telescope", "beginner", "earth_africa", "postal_horn", "house", "fish", "construction_worker", "money_mouth_face", "spider", "u7121", "bride_with_veil", "camera_with_flash", "books", "keycap_ten", "fist", "beetle", "lock_with_ink_pen", "8ball", "worried", "weight_lifter", "sunrise", "exclamation", "no_good", "flag-zm", "lipstick", "lower_left_crayon", "flag-ps", "smirk", "racing_car", "card_file_box", "factory", "baggage_claim", "cherry_blossom", "om_symbol", "sparkle", "fountain", "point_right", "cyclone", "-1", "blue_book", "reminder_ribbon", "dancers", "sheep", "flower_playing_cards", "umbrella", "flag-np", "hatching_chick", "black_circle_for_record", "flag-vi", "free", "traffic_light", "five", "grimacing", "cookie", "poultry_leg", "grapes", "raised_hand_with_fingers_splayed", "smirk_cat", "flag-ws", "diamond_shape_with_a_dot_inside", "lollipop", "flag-id", "man-heart-man", "high_heel", "dagger_knife", "black_medium_small_square", "green_book", "flag-kw", "headphones", "no_mobile_phones", "sun_with_face", "mailbox", "mosque", "passport_control", "bookmark", "+1", "notebook", "yum", "closed_lock_with_key", "heartbeat", "man-woman-girl", "blush", "radioactive_sign", "bullettrain_front", "flag-mh", "ophiuchus", "flag-mp", "bouquet", "sports_medal", "flag-uy", "fire_engine", "one", "feet", "date", "flag-vu", "cow2", "scissors", "ring", "disappointed_relieved", "whale", "zap", "children_crossing", "national_park", "clock430", "horse", "basketball", "monkey", "thinking_face", "blossom", "gift_heart", "top", "flag-il", "spider_web", "clock630", "crossed_swords", "station", "clock730", "man", "banana", "flag-mv", "shaved_ice", "eyes", "shell", "waving_white_flag", "gear", "flag-hn", "radio_button", "memo", "hotel", "small_red_triangle_down", "broken_heart", "suspension_railway", "railway_track", "nut_and_bolt", "aerial_tramway", "flag-hr", "seat", "latin_cross", "flag-hu", "panda_face", "middle_finger", "minibus", "b", "unamused", "flag-af", "flag-ae", "flag-ad", "evergreen_tree", "flag-ao", "mailbox_with_mail", "bee", "scream_cat", "smile_cat", "flag-aq", "flag-ve", "flag-aw", "hourglass_flowing_sand", "clock11", "round_pushpin", "tophat", "six_pointed_star", "dog2", "grinning", "tractor", "flag-vc", "u6709", "u6708", "flag-za", "crying_cat_face", "angel", "nail_care", "runner", "table_tennis_paddle_and_ball", "ram", "writing_hand", "bathtub", "ant", "rat", "flag-hk", "information_desk_person", "flag-ir", "rice_scene", "bookmark_tabs", "milky_way", "pencil2", "mountain", "microphone", "koala", "necktie", "atm", "bullettrain_side", "kissing_cat", "relieved", "thermometer", "flag-xk", "u55b6", "globe_with_meridians", "snowflake", "woman-kiss-woman", "loudspeaker", "princess", "printer", "flag-sy", "flag-sx", "flag-sz", "tornado", "flag-st", "flag-sv", "chart", "flag-ss", "credit_card", "flag-sm", "checkered_flag", "flag-so", "flag-sn", "eight", "flag-sh", "flag-sk", "flag-sj", "handbag", "pensive", "flag-sg", "flag-py", "medal", "arrows_clockwise", "flag-sc", "ballot_box_with_check", "eject", "fried_shrimp", "mans_shoe", "card_index_dividers", "m", "dog", "dollar", "police_car", "new_moon_with_face", "shinto_shrine", "ideograph_advantage", "pineapple", "airplane_arriving", "link", "scream", "bell", "speak_no_evil", "walking", "flag-fm", "golf", "satellite_antenna", "flag-fj", "dromedary_camel", "flag-om", "horse_racing", "three_button_mouse", "lower_left_ballpoint_pen", "radio", "flag-cv", "partly_sunny_rain", "point_down", "chicken", "unicorn_face", "umbrella_on_ground", "flag-tm", "copyright", "arrow_lower_right", "city_sunset", "yen", "waning_crescent_moon", "cupid", "mens", "virgo", "libra", "busts_in_silhouette", "straight_ruler", "flag-fr", "two", "rice", "lips", "flag-gs", "flag-ge", "flag-ac", "alarm_clock", "couplekiss", "sagittarius", "flag-dz", "electric_plug", "circus_tent", "flag-gu", "watch", "arrow_up", "bear", "face_with_head_bandage", "frowning", "flag-dm", "incoming_envelope", "flag-do", "watermelon", "rotating_light", "flag-dj", "wedding", "flag-ag", "flag-dg", "flag-gd", "yellow_heart", "gem", "flag-to", "negative_squared_cross_mark", "girl", "rage", "calling", "flag-at", "microscope", "cheese_wedge", "whale2", "x", "interrobang", "japanese_ogre", "fuelpump", "oncoming_taxi", "man_with_turban", "flag-lk", "arrow_up_small", "art", "smiling_imp", "hear_no_evil", "star_and_crescent", "convenience_store", "up", "flag-ye", "flag-cw", "computer", "arrow_down", "vhs", "flag-ky", "parking", "flag-vn", "pisces", "calendar", "flag-al", "hammer", "hourglass", "hibiscus", "shower", "black_joker", "ferris_wheel", "flag-ar", "camping", "bicyclist", "no_mouth", "postbox", "large_blue_diamond", "non-potable_water", "label", "icecream", "admission_tickets", "lower_left_paintbrush", "flag-hm", "diamonds", "champagne", "email", "older_man", "tent", "flag-ax", "raising_hand", "wc", "bed", "zipper_mouth_face", "joy", "hot_pepper", "aquarius", "waving_black_flag", "couple_with_heart", "guitar", "four_leaf_clover", "key", "flag-az", "flag-tk", "dress", "surfer", "statue_of_liberty", "crystal_ball", "cop", "clock1230", "tropical_drink", "cow", "flag-cp", "no_pedestrians", "oncoming_bus", "moyai", "restroom", "white_large_square", "kaaba", "eggplant", "comet", "low_brightness", "flag-tf", "ok_woman", "space_invader", "pig_nose", "flag-kp", "cancer", "ice_skate", "battery", "man-kiss-man", "wastebasket", "jeans", "cd", "flag-ke", "carousel_horse", "hotsprings", "page_facing_up", "flag-mn", "church", "boar", "black_square_for_stop", "flag-dk", "flag-kn", "flag-ki", "flag-kh", "boat", "turkey", "flag-am", "person_with_blond_hair", "swimmer", "wavy_dash", "three", "oden", "secret", "woman-woman-girl-boy", "stadium", "chipmunk", "stuck_out_tongue_closed_eyes", "helicopter", "heavy_division_sign", "flag-mm", "passenger_ship", "u7981", "mushroom", "fire", "two_hearts", "revolving_hearts", "arrow_down_small", "tiger", "desktop_computer", "flag-de", "foggy", "skin-tone-2", "skin-tone-3", "skin-tone-4", "skin-tone-5", "skin-tone-6", "heart_eyes", "open_file_folder", "dash", "blowfish", "speech_balloon", "wind_chime", "arrow_right_hook", "seedling", "fearful", "envelope_with_arrow", "flag-yt", "closed_umbrella", "film_projector", "bikini", "warning", "taxi", "u5408", "newspaper", "card_index", "raised_hands", "anchor", "loop", "flag-zw", "potable_water", "seven", "pound", "two_women_holding_hands", "timer_clock", "flag-rs", "registered", "sushi", "purse", "monkey_face", "u5272", "rooster", "shamrock", "anger", "rain_cloud", "vs", "flag-ro", "flag-pl", "frame_with_picture", "arrow_forward", "violin", "name_badge", "orthodox_cross", "id", "helmet_with_white_cross", "flag-re", "shopping_bags", "synagogue", "house_buildings", "white_circle", "balloon", "flag-lc", "heart_decoration", "flag-mz", "joy_cat", "kimono", "speaker", "flag-my", "train2", "first_quarter_moon", "dragon_face", "left_luggage", "flag-mx", "meat_on_bone", "light_rail", "bellhop_bell", "satellite", "arrow_heading_up", "snail", "black_small_square", "u6307", "leopard", "hand", "flag-bi", "flag-pn", "badminton_racquet_and_shuttlecock", "barber", "christmas_tree", "cityscape", "slot_machine", "ice_cream", "flag-qa", "euro", "anguished", "crossed_flags", "burrito", "rolled_up_newspaper", "musical_score", "white_frowning_face", "triangular_ruler", "ballot_box_with_ballot", "ocean", "flag-kr", "signal_strength", "flags", "the_horns", "hearts", "joystick", "muscle", "love_hotel", "hotdog", "snowman", "eyeglasses", "flag-lr", "rocket", "camel", "flag-gq", "boot", "u7533", "racehorse", "sleepy", "flag-gt", "heart_eyes_cat", "green_apple", "flag-gi", "flag-gh", "racing_motorcycle", "flag-gm", "flag-gl", "flag-gn", "flag-ga", "bridge_at_night", "flag-pe", "flag-gb", "face_with_thermometer", "clock130", "flag-gg", "flag-gf", "flashlight", "womans_hat", "flag-mf", "sandal", "white_medium_square", "snowboarder", "sunflower", "grey_exclamation", "person_frowning", "rose", "cl", "flag-cf", "cherries", "innocent", "arrow_up_down", "stopwatch", "left_speech_bubble", "ski", "pill", "musical_keyboard", "skier", "full_moon", "hugging_face", "flag-bs", "orange_book", "flag-wf", "flag-ug", "mount_fuji", "couple", "yin_yang", "japanese_goblin", "flag-as", "dart", "clock1", "clock2", "clock3", "clock4", "clock5", "clock6", "clock7", "clock8", "clock9", "doughnut", "flag-kz", "disappointed", "grin", "place_of_worship", "womans_clothes", "flag-vg", "gift", "prayer_beads", "bangbang", "stuck_out_tongue_winking_eye", "flag-kg", "candy", "lightning", "arrows_counterclockwise", "two_men_holding_hands", "dancer", "skull_and_crossbones", "trolleybus", "woman-woman-girl-girl", "bamboo", "flag-um", "trackball", "clap", "outbox_tray", "left_right_arrow", "japanese_castle", "door", "waning_gibbous_moon", "crown", "back", "flag-et", "flag-pw", "flag-us", "sparkling_heart", "clubs", "repeat", "candle", "crab", "man-woman-girl-boy", "smoking", "flag-pk", "man-man-boy-boy", "bento", "robot_face", "moon", "thunder_cloud_and_rain", "tanabata_tree", "fog", "cloud", "large_orange_diamond", "heavy_minus_sign", "o2", "nose", "flag-no", "point_up", "smiley", "facepunch", "zzz", "flag-ni", "flag-nf", "flag-ng", "corn", "flag-ne", "flag-nc", "loud_sound", "kissing_smiling_eyes", "fish_cake", "flag-ms", "flag-nz", "stew", "santa", "kissing_heart", "flag-nu", "tropical_fish", "eight_spoked_asterisk", "trophy", "on", "ok", "city_sunrise", "package", "arrow_right", "school_satchel", "o", "film_frames", "chart_with_downwards_trend", "clock10", "hammer_and_pick", "wolf", "sweat", "ox", "flag-rw", "mountain_railway", "tongue", "speaking_head_in_silhouette", "curry", "angry", "alembic", "baby_chick", "double_vertical_bar", "underage", "do_not_litter", "man-man-boy", "field_hockey_stick_and_ball", "waxing_crescent_moon", "full_moon_with_face", "bath", "flag-se", "sos", "red_circle", "flag-sd", "syringe", "last_quarter_moon", "tada", "ok_hand", "custard", "rowboat", "compression", "clock530", "heavy_multiplication_x", "white_check_mark", "tennis", "question", "beer", "flag-jo", "flag-tw", "lion_face", "flag-ru", "stars", "flag-jm", "stuck_out_tongue", "woman-woman-boy", "iphone", "flag-cm", "sweat_smile", "flag-cl", "flag-uz", "bus", "relaxed", "fireworks", "flag-md", "right_anger_bubble", "level_slider", "construction", "black_circle", "fallen_leaf", "airplane_departure", "astonished", "flag-ci", "turtle", "ear", "black_left_pointing_double_triangle_with_vertical_bar", "bug", "penguin", "arrow_heading_down", "congratulations", "snow_capped_mountain", "flag-ck", "skull", "mobile_phone_off", "flag-ht", "control_knobs", "expressionless", "fries", "grey_question", "arrow_upper_left", "strawberry", "cat2", "athletic_shoe", "unlock", "star2", "cake", "gemini", "man-man-girl-boy", "arrow_double_up", "cricket_bat_and_ball", "flag-me", "ab", "hash", "sweet_potato", "mortar_board", "cinema", "flag-mo", "hatched_chick", "triangular_flag_on_post", "flag-ml", "flag-mk", "flag-ai", "black_nib", "pig", "flag-mw", "floppy_disk", "flag-mu", "black_large_square", "koko", "flag-mr", "flag-mq", "person_with_pouting_face", "flag-ea", "bow_and_arrow", "ship", "ice_hockey_stick_and_puck", "telephone_receiver", "performing_arts", "rainbow", "movie_camera", "lemon", "arrow_double_down", "peach", "arrow_upper_right", "ng", "mountain_bicyclist", "book", "clock1130", "boom", "spiral_calendar_pad", "clock1030", "flag-km", "beach_with_umbrella", "imp", "bust_in_silhouette", "star", "rabbit2", "man-man-girl", "footprints", "football", "pear", "taurus", "articulated_lorry", "no_entry_sign", "u6e80", "money_with_wings", "flag-lu", "bike", "black_medium_square", "closed_book", "desert", "woman-woman-girl", "oil_drum", "ghost", "droplet", "flag-co", "flag-cn", "spades", "flag-ch", "vibration_mode", "phone", "dvd", "flag-cg", "menorah_with_nine_branches", "mask", "flag-cc", "mag_right", "scorpion", "flag-cy", "flag-cx", "hushed", "desert_island", "sunrise_over_mountains", "partly_sunny", "spiral_note_pad", "heavy_dollar_sign", "scroll", "flag-cr"]} +{ + "emoji": [ + "+1", + "-1", + "100", + "1234", + "8ball", + "a", + "ab", + "abc", + "abcd", + "accept", + "admission_tickets", + "aerial_tramway", + "airplane", + "airplane_arriving", + "airplane_departure", + "alarm_clock", + "alembic", + "alien", + "ambulance", + "amphora", + "anchor", + "angel", + "anger", + "angry", + "anguished", + "ant", + "apple", + "aquarius", + "aries", + "arrow_backward", + "arrow_double_down", + "arrow_double_up", + "arrow_down", + "arrow_down_small", + "arrow_forward", + "arrow_heading_down", + "arrow_heading_up", + "arrow_left", + "arrow_lower_left", + "arrow_lower_right", + "arrow_right", + "arrow_right_hook", + "arrow_up", + "arrow_up_down", + "arrow_up_small", + "arrow_upper_left", + "arrow_upper_right", + "arrows_clockwise", + "arrows_counterclockwise", + "art", + "articulated_lorry", + "astonished", + "athletic_shoe", + "atm", + "atom_symbol", + "b", + "baby", + "baby_bottle", + "baby_chick", + "baby_symbol", + "back", + "badminton_racquet_and_shuttlecock", + "baggage_claim", + "balloon", + "ballot_box_with_ballot", + "ballot_box_with_check", + "bamboo", + "banana", + "bangbang", + "bank", + "bar_chart", + "barber", + "barely_sunny", + "baseball", + "basketball", + "bath", + "bathtub", + "battery", + "beach_with_umbrella", + "bear", + "bed", + "bee", + "beer", + "beers", + "beetle", + "beginner", + "bell", + "bellhop_bell", + "bento", + "bicyclist", + "bike", + "bikini", + "biohazard_sign", + "bird", + "birthday", + "black_circle", + "black_circle_for_record", + "black_joker", + "black_large_square", + "black_left_pointing_double_triangle_with_vertical_bar", + "black_medium_small_square", + "black_medium_square", + "black_nib", + "black_right_pointing_double_triangle_with_vertical_bar", + "black_right_pointing_triangle_with_double_vertical_bar", + "black_small_square", + "black_square_button", + "black_square_for_stop", + "blossom", + "blowfish", + "blue_book", + "blue_car", + "blue_heart", + "blush", + "boar", + "boat", + "bomb", + "book", + "bookmark", + "bookmark_tabs", + "books", + "boom", + "boot", + "bouquet", + "bow", + "bow_and_arrow", + "bowling", + "boy", + "bread", + "bride_with_veil", + "bridge_at_night", + "briefcase", + "broken_heart", + "bug", + "building_construction", + "bulb", + "bullettrain_front", + "bullettrain_side", + "burrito", + "bus", + "busstop", + "bust_in_silhouette", + "busts_in_silhouette", + "cactus", + "cake", + "calendar", + "calling", + "camel", + "camera", + "camera_with_flash", + "camping", + "cancer", + "candle", + "candy", + "capital_abcd", + "capricorn", + "car", + "card_file_box", + "card_index", + "card_index_dividers", + "carousel_horse", + "cat", + "cat2", + "cd", + "chains", + "champagne", + "chart", + "chart_with_downwards_trend", + "chart_with_upwards_trend", + "checkered_flag", + "cheese_wedge", + "cherries", + "cherry_blossom", + "chestnut", + "chicken", + "children_crossing", + "chipmunk", + "chocolate_bar", + "christmas_tree", + "church", + "cinema", + "circus_tent", + "city_sunrise", + "city_sunset", + "cityscape", + "cl", + "clap", + "clapper", + "classical_building", + "clipboard", + "clock1", + "clock10", + "clock1030", + "clock11", + "clock1130", + "clock12", + "clock1230", + "clock130", + "clock2", + "clock230", + "clock3", + "clock330", + "clock4", + "clock430", + "clock5", + "clock530", + "clock6", + "clock630", + "clock7", + "clock730", + "clock8", + "clock830", + "clock9", + "clock930", + "closed_book", + "closed_lock_with_key", + "closed_umbrella", + "cloud", + "clubs", + "cn", + "cocktail", + "coffee", + "coffin", + "cold_sweat", + "collision", + "comet", + "compression", + "computer", + "confetti_ball", + "confounded", + "confused", + "congratulations", + "construction", + "construction_worker", + "control_knobs", + "convenience_store", + "cookie", + "cool", + "cop", + "copyright", + "corn", + "couch_and_lamp", + "couple", + "couple_with_heart", + "couplekiss", + "cow", + "cow2", + "crab", + "credit_card", + "crescent_moon", + "cricket_bat_and_ball", + "crocodile", + "crossed_flags", + "crossed_swords", + "crown", + "cry", + "crying_cat_face", + "crystal_ball", + "cupid", + "curly_loop", + "currency_exchange", + "curry", + "custard", + "customs", + "cyclone", + "dagger_knife", + "dancer", + "dancers", + "dango", + "dark_sunglasses", + "dart", + "dash", + "date", + "de", + "deciduous_tree", + "department_store", + "derelict_house_building", + "desert", + "desert_island", + "desktop_computer", + "diamond_shape_with_a_dot_inside", + "diamonds", + "disappointed", + "disappointed_relieved", + "dizzy", + "dizzy_face", + "do_not_litter", + "dog", + "dog2", + "dollar", + "dolls", + "dolphin", + "door", + "double_vertical_bar", + "doughnut", + "dove_of_peace", + "dragon", + "dragon_face", + "dress", + "dromedary_camel", + "droplet", + "dvd", + "e-mail", + "ear", + "ear_of_rice", + "earth_africa", + "earth_americas", + "earth_asia", + "egg", + "eggplant", + "eight", + "eight_pointed_black_star", + "eight_spoked_asterisk", + "eject", + "electric_plug", + "elephant", + "email", + "end", + "envelope", + "envelope_with_arrow", + "es", + "euro", + "european_castle", + "european_post_office", + "evergreen_tree", + "exclamation", + "expressionless", + "eye", + "eyeglasses", + "eyes", + "face_with_head_bandage", + "face_with_rolling_eyes", + "face_with_thermometer", + "facepunch", + "factory", + "fallen_leaf", + "family", + "fast_forward", + "fax", + "fearful", + "feet", + "ferris_wheel", + "ferry", + "field_hockey_stick_and_ball", + "file_cabinet", + "file_folder", + "film_frames", + "film_projector", + "fire", + "fire_engine", + "fireworks", + "first_quarter_moon", + "first_quarter_moon_with_face", + "fish", + "fish_cake", + "fishing_pole_and_fish", + "fist", + "five", + "flag-ac", + "flag-ad", + "flag-ae", + "flag-af", + "flag-ag", + "flag-ai", + "flag-al", + "flag-am", + "flag-ao", + "flag-aq", + "flag-ar", + "flag-as", + "flag-at", + "flag-au", + "flag-aw", + "flag-ax", + "flag-az", + "flag-ba", + "flag-bb", + "flag-bd", + "flag-be", + "flag-bf", + "flag-bg", + "flag-bh", + "flag-bi", + "flag-bj", + "flag-bl", + "flag-bm", + "flag-bn", + "flag-bo", + "flag-bq", + "flag-br", + "flag-bs", + "flag-bt", + "flag-bv", + "flag-bw", + "flag-by", + "flag-bz", + "flag-ca", + "flag-cc", + "flag-cd", + "flag-cf", + "flag-cg", + "flag-ch", + "flag-ci", + "flag-ck", + "flag-cl", + "flag-cm", + "flag-cn", + "flag-co", + "flag-cp", + "flag-cr", + "flag-cu", + "flag-cv", + "flag-cw", + "flag-cx", + "flag-cy", + "flag-cz", + "flag-de", + "flag-dg", + "flag-dj", + "flag-dk", + "flag-dm", + "flag-do", + "flag-dz", + "flag-ea", + "flag-ec", + "flag-ee", + "flag-eg", + "flag-eh", + "flag-er", + "flag-es", + "flag-et", + "flag-eu", + "flag-fi", + "flag-fj", + "flag-fk", + "flag-fm", + "flag-fo", + "flag-fr", + "flag-ga", + "flag-gb", + "flag-gd", + "flag-ge", + "flag-gf", + "flag-gg", + "flag-gh", + "flag-gi", + "flag-gl", + "flag-gm", + "flag-gn", + "flag-gp", + "flag-gq", + "flag-gr", + "flag-gs", + "flag-gt", + "flag-gu", + "flag-gw", + "flag-gy", + "flag-hk", + "flag-hm", + "flag-hn", + "flag-hr", + "flag-ht", + "flag-hu", + "flag-ic", + "flag-id", + "flag-ie", + "flag-il", + "flag-im", + "flag-in", + "flag-io", + "flag-iq", + "flag-ir", + "flag-is", + "flag-it", + "flag-je", + "flag-jm", + "flag-jo", + "flag-jp", + "flag-ke", + "flag-kg", + "flag-kh", + "flag-ki", + "flag-km", + "flag-kn", + "flag-kp", + "flag-kr", + "flag-kw", + "flag-ky", + "flag-kz", + "flag-la", + "flag-lb", + "flag-lc", + "flag-li", + "flag-lk", + "flag-lr", + "flag-ls", + "flag-lt", + "flag-lu", + "flag-lv", + "flag-ly", + "flag-ma", + "flag-mc", + "flag-md", + "flag-me", + "flag-mf", + "flag-mg", + "flag-mh", + "flag-mk", + "flag-ml", + "flag-mm", + "flag-mn", + "flag-mo", + "flag-mp", + "flag-mq", + "flag-mr", + "flag-ms", + "flag-mt", + "flag-mu", + "flag-mv", + "flag-mw", + "flag-mx", + "flag-my", + "flag-mz", + "flag-na", + "flag-nc", + "flag-ne", + "flag-nf", + "flag-ng", + "flag-ni", + "flag-nl", + "flag-no", + "flag-np", + "flag-nr", + "flag-nu", + "flag-nz", + "flag-om", + "flag-pa", + "flag-pe", + "flag-pf", + "flag-pg", + "flag-ph", + "flag-pk", + "flag-pl", + "flag-pm", + "flag-pn", + "flag-pr", + "flag-ps", + "flag-pt", + "flag-pw", + "flag-py", + "flag-qa", + "flag-re", + "flag-ro", + "flag-rs", + "flag-ru", + "flag-rw", + "flag-sa", + "flag-sb", + "flag-sc", + "flag-sd", + "flag-se", + "flag-sg", + "flag-sh", + "flag-si", + "flag-sj", + "flag-sk", + "flag-sl", + "flag-sm", + "flag-sn", + "flag-so", + "flag-sr", + "flag-ss", + "flag-st", + "flag-sv", + "flag-sx", + "flag-sy", + "flag-sz", + "flag-ta", + "flag-tc", + "flag-td", + "flag-tf", + "flag-tg", + "flag-th", + "flag-tj", + "flag-tk", + "flag-tl", + "flag-tm", + "flag-tn", + "flag-to", + "flag-tr", + "flag-tt", + "flag-tv", + "flag-tw", + "flag-tz", + "flag-ua", + "flag-ug", + "flag-um", + "flag-us", + "flag-uy", + "flag-uz", + "flag-va", + "flag-vc", + "flag-ve", + "flag-vg", + "flag-vi", + "flag-vn", + "flag-vu", + "flag-wf", + "flag-ws", + "flag-xk", + "flag-ye", + "flag-yt", + "flag-za", + "flag-zm", + "flag-zw", + "flags", + "flashlight", + "fleur_de_lis", + "flipper", + "floppy_disk", + "flower_playing_cards", + "flushed", + "fog", + "foggy", + "football", + "footprints", + "fork_and_knife", + "fountain", + "four", + "four_leaf_clover", + "fr", + "frame_with_picture", + "free", + "fried_shrimp", + "fries", + "frog", + "frowning", + "fuelpump", + "full_moon", + "full_moon_with_face", + "funeral_urn", + "game_die", + "gb", + "gear", + "gem", + "gemini", + "ghost", + "gift", + "gift_heart", + "girl", + "globe_with_meridians", + "goat", + "golf", + "golfer", + "grapes", + "green_apple", + "green_book", + "green_heart", + "grey_exclamation", + "grey_question", + "grimacing", + "grin", + "grinning", + "guardsman", + "guitar", + "gun", + "haircut", + "hamburger", + "hammer", + "hammer_and_pick", + "hammer_and_wrench", + "hamster", + "hand", + "handbag", + "hankey", + "hash", + "hatched_chick", + "hatching_chick", + "headphones", + "hear_no_evil", + "heart", + "heart_decoration", + "heart_eyes", + "heart_eyes_cat", + "heartbeat", + "heartpulse", + "hearts", + "heavy_check_mark", + "heavy_division_sign", + "heavy_dollar_sign", + "heavy_exclamation_mark", + "heavy_heart_exclamation_mark_ornament", + "heavy_minus_sign", + "heavy_multiplication_x", + "heavy_plus_sign", + "helicopter", + "helmet_with_white_cross", + "herb", + "hibiscus", + "high_brightness", + "high_heel", + "hocho", + "hole", + "honey_pot", + "honeybee", + "horse", + "horse_racing", + "hospital", + "hot_pepper", + "hotdog", + "hotel", + "hotsprings", + "hourglass", + "hourglass_flowing_sand", + "house", + "house_buildings", + "house_with_garden", + "hugging_face", + "hushed", + "ice_cream", + "ice_hockey_stick_and_puck", + "ice_skate", + "icecream", + "id", + "ideograph_advantage", + "imp", + "inbox_tray", + "incoming_envelope", + "information_desk_person", + "information_source", + "innocent", + "interrobang", + "iphone", + "it", + "izakaya_lantern", + "jack_o_lantern", + "japan", + "japanese_castle", + "japanese_goblin", + "japanese_ogre", + "jeans", + "joy", + "joy_cat", + "joystick", + "jp", + "kaaba", + "key", + "keyboard", + "keycap_star", + "keycap_ten", + "kimono", + "kiss", + "kissing", + "kissing_cat", + "kissing_closed_eyes", + "kissing_heart", + "kissing_smiling_eyes", + "knife", + "knife_fork_plate", + "koala", + "koko", + "kr", + "label", + "lantern", + "large_blue_circle", + "large_blue_diamond", + "large_orange_diamond", + "last_quarter_moon", + "last_quarter_moon_with_face", + "latin_cross", + "laughing", + "leaves", + "ledger", + "left_luggage", + "left_right_arrow", + "left_speech_bubble", + "leftwards_arrow_with_hook", + "lemon", + "leo", + "leopard", + "level_slider", + "libra", + "light_rail", + "lightning", + "lightning_cloud", + "link", + "linked_paperclips", + "lion_face", + "lips", + "lipstick", + "lock", + "lock_with_ink_pen", + "lollipop", + "loop", + "loud_sound", + "loudspeaker", + "love_hotel", + "love_letter", + "low_brightness", + "lower_left_ballpoint_pen", + "lower_left_crayon", + "lower_left_fountain_pen", + "lower_left_paintbrush", + "m", + "mag", + "mag_right", + "mahjong", + "mailbox", + "mailbox_closed", + "mailbox_with_mail", + "mailbox_with_no_mail", + "man", + "man-heart-man", + "man-kiss-man", + "man-man-boy", + "man-man-boy-boy", + "man-man-girl", + "man-man-girl-boy", + "man-man-girl-girl", + "man-woman-boy", + "man-woman-boy-boy", + "man-woman-girl", + "man-woman-girl-boy", + "man-woman-girl-girl", + "man_and_woman_holding_hands", + "man_in_business_suit_levitating", + "man_with_gua_pi_mao", + "man_with_turban", + "mans_shoe", + "mantelpiece_clock", + "maple_leaf", + "mask", + "massage", + "meat_on_bone", + "medal", + "mega", + "melon", + "memo", + "menorah_with_nine_branches", + "mens", + "metro", + "microphone", + "microscope", + "middle_finger", + "milky_way", + "minibus", + "minidisc", + "mobile_phone_off", + "money_mouth_face", + "money_with_wings", + "moneybag", + "monkey", + "monkey_face", + "monorail", + "moon", + "mortar_board", + "mosque", + "mostly_sunny", + "motor_boat", + "motorway", + "mount_fuji", + "mountain", + "mountain_bicyclist", + "mountain_cableway", + "mountain_railway", + "mouse", + "mouse2", + "movie_camera", + "moyai", + "muscle", + "mushroom", + "musical_keyboard", + "musical_note", + "musical_score", + "mute", + "nail_care", + "name_badge", + "national_park", + "necktie", + "negative_squared_cross_mark", + "nerd_face", + "neutral_face", + "new", + "new_moon", + "new_moon_with_face", + "newspaper", + "ng", + "night_with_stars", + "nine", + "no_bell", + "no_bicycles", + "no_entry", + "no_entry_sign", + "no_good", + "no_mobile_phones", + "no_mouth", + "no_pedestrians", + "no_smoking", + "non-potable_water", + "nose", + "notebook", + "notebook_with_decorative_cover", + "notes", + "nut_and_bolt", + "o", + "o2", + "ocean", + "octopus", + "oden", + "office", + "oil_drum", + "ok", + "ok_hand", + "ok_woman", + "old_key", + "older_man", + "older_woman", + "om_symbol", + "on", + "oncoming_automobile", + "oncoming_bus", + "oncoming_police_car", + "oncoming_taxi", + "one", + "open_book", + "open_file_folder", + "open_hands", + "open_mouth", + "ophiuchus", + "orange_book", + "orthodox_cross", + "outbox_tray", + "ox", + "package", + "page_facing_up", + "page_with_curl", + "pager", + "palm_tree", + "panda_face", + "paperclip", + "parking", + "part_alternation_mark", + "partly_sunny", + "partly_sunny_rain", + "passenger_ship", + "passport_control", + "paw_prints", + "peace_symbol", + "peach", + "pear", + "pencil", + "pencil2", + "penguin", + "pensive", + "performing_arts", + "persevere", + "person_frowning", + "person_with_ball", + "person_with_blond_hair", + "person_with_pouting_face", + "phone", + "pick", + "pig", + "pig2", + "pig_nose", + "pill", + "pineapple", + "pisces", + "pizza", + "place_of_worship", + "point_down", + "point_left", + "point_right", + "point_up", + "point_up_2", + "police_car", + "poodle", + "poop", + "popcorn", + "post_office", + "postal_horn", + "postbox", + "potable_water", + "pouch", + "poultry_leg", + "pound", + "pouting_cat", + "pray", + "prayer_beads", + "princess", + "printer", + "punch", + "purple_heart", + "purse", + "pushpin", + "put_litter_in_its_place", + "question", + "rabbit", + "rabbit2", + "racehorse", + "racing_car", + "racing_motorcycle", + "radio", + "radio_button", + "radioactive_sign", + "rage", + "railway_car", + "railway_track", + "rain_cloud", + "rainbow", + "raised_hand", + "raised_hand_with_fingers_splayed", + "raised_hands", + "raising_hand", + "ram", + "ramen", + "rat", + "recycle", + "red_car", + "red_circle", + "registered", + "relaxed", + "relieved", + "reminder_ribbon", + "repeat", + "repeat_one", + "restroom", + "reversed_hand_with_middle_finger_extended", + "revolving_hearts", + "rewind", + "ribbon", + "rice", + "rice_ball", + "rice_cracker", + "rice_scene", + "right_anger_bubble", + "ring", + "robot_face", + "rocket", + "rolled_up_newspaper", + "roller_coaster", + "rooster", + "rose", + "rosette", + "rotating_light", + "round_pushpin", + "rowboat", + "ru", + "rugby_football", + "runner", + "running", + "running_shirt_with_sash", + "sa", + "sagittarius", + "sailboat", + "sake", + "sandal", + "santa", + "satellite", + "satellite_antenna", + "satisfied", + "saxophone", + "scales", + "school", + "school_satchel", + "scissors", + "scorpion", + "scorpius", + "scream", + "scream_cat", + "scroll", + "seat", + "secret", + "see_no_evil", + "seedling", + "seven", + "shamrock", + "shaved_ice", + "sheep", + "shell", + "shield", + "shinto_shrine", + "ship", + "shirt", + "shit", + "shoe", + "shopping_bags", + "shower", + "sign_of_the_horns", + "signal_strength", + "six", + "six_pointed_star", + "ski", + "skier", + "skin-tone-2", + "skin-tone-3", + "skin-tone-4", + "skin-tone-5", + "skin-tone-6", + "skull", + "skull_and_crossbones", + "sleeping", + "sleeping_accommodation", + "sleepy", + "sleuth_or_spy", + "slightly_frowning_face", + "slightly_smiling_face", + "slot_machine", + "small_airplane", + "small_blue_diamond", + "small_orange_diamond", + "small_red_triangle", + "small_red_triangle_down", + "smile", + "smile_cat", + "smiley", + "smiley_cat", + "smiling_imp", + "smirk", + "smirk_cat", + "smoking", + "snail", + "snake", + "snow_capped_mountain", + "snow_cloud", + "snowboarder", + "snowflake", + "snowman", + "snowman_without_snow", + "sob", + "soccer", + "soon", + "sos", + "sound", + "space_invader", + "spades", + "spaghetti", + "sparkle", + "sparkler", + "sparkles", + "sparkling_heart", + "speak_no_evil", + "speaker", + "speaking_head_in_silhouette", + "speech_balloon", + "speedboat", + "spider", + "spider_web", + "spiral_calendar_pad", + "spiral_note_pad", + "spock-hand", + "sports_medal", + "stadium", + "star", + "star2", + "star_and_crescent", + "star_of_david", + "stars", + "station", + "statue_of_liberty", + "steam_locomotive", + "stew", + "stopwatch", + "straight_ruler", + "strawberry", + "stuck_out_tongue", + "stuck_out_tongue_closed_eyes", + "stuck_out_tongue_winking_eye", + "studio_microphone", + "sun_behind_cloud", + "sun_behind_rain_cloud", + "sun_small_cloud", + "sun_with_face", + "sunflower", + "sunglasses", + "sunny", + "sunrise", + "sunrise_over_mountains", + "surfer", + "sushi", + "suspension_railway", + "sweat", + "sweat_drops", + "sweat_smile", + "sweet_potato", + "swimmer", + "symbols", + "synagogue", + "syringe", + "table_tennis_paddle_and_ball", + "taco", + "tada", + "tanabata_tree", + "tangerine", + "taurus", + "taxi", + "tea", + "telephone", + "telephone_receiver", + "telescope", + "tennis", + "tent", + "the_horns", + "thermometer", + "thinking_face", + "thought_balloon", + "three", + "three_button_mouse", + "thumbsdown", + "thumbsup", + "thunder_cloud_and_rain", + "ticket", + "tiger", + "tiger2", + "timer_clock", + "tired_face", + "tm", + "toilet", + "tokyo_tower", + "tomato", + "tongue", + "top", + "tophat", + "tornado", + "tornado_cloud", + "trackball", + "tractor", + "traffic_light", + "train", + "train2", + "tram", + "triangular_flag_on_post", + "triangular_ruler", + "trident", + "triumph", + "trolleybus", + "trophy", + "tropical_drink", + "tropical_fish", + "truck", + "trumpet", + "tshirt", + "tulip", + "turkey", + "turtle", + "tv", + "twisted_rightwards_arrows", + "two", + "two_hearts", + "two_men_holding_hands", + "two_women_holding_hands", + "u5272", + "u5408", + "u55b6", + "u6307", + "u6708", + "u6709", + "u6e80", + "u7121", + "u7533", + "u7981", + "u7a7a", + "uk", + "umbrella", + "umbrella_on_ground", + "umbrella_with_rain_drops", + "unamused", + "underage", + "unicorn_face", + "unlock", + "up", + "upside_down_face", + "us", + "v", + "vertical_traffic_light", + "vhs", + "vibration_mode", + "video_camera", + "video_game", + "violin", + "virgo", + "volcano", + "volleyball", + "vs", + "walking", + "waning_crescent_moon", + "waning_gibbous_moon", + "warning", + "wastebasket", + "watch", + "water_buffalo", + "watermelon", + "wave", + "waving_black_flag", + "waving_white_flag", + "wavy_dash", + "waxing_crescent_moon", + "waxing_gibbous_moon", + "wc", + "weary", + "wedding", + "weight_lifter", + "whale", + "whale2", + "wheel_of_dharma", + "wheelchair", + "white_check_mark", + "white_circle", + "white_flower", + "white_frowning_face", + "white_large_square", + "white_medium_small_square", + "white_medium_square", + "white_small_square", + "white_square_button", + "wind_blowing_face", + "wind_chime", + "wine_glass", + "wink", + "wolf", + "woman", + "woman-heart-woman", + "woman-kiss-woman", + "woman-woman-boy", + "woman-woman-boy-boy", + "woman-woman-girl", + "woman-woman-girl-boy", + "woman-woman-girl-girl", + "womans_clothes", + "womans_hat", + "womens", + "world_map", + "worried", + "wrench", + "writing_hand", + "x", + "yellow_heart", + "yen", + "yin_yang", + "yum", + "zap", + "zero", + "zipper_mouth_face", + "zzz" + ] +} |