diff --git a/README.md b/README.md index 2e851cb..2016292 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,7 @@ Your `~/.sclack` file will look like: ### Multiple workspaces -If you want to, you can use Sclack in multiple workspaces. You can have -at most 9 workspaces defined inside `workspaces`: +If you want to, you can use Sclack in multiple workspaces. You can have at most 9 workspaces defined inside `workspaces`: ```json { @@ -154,11 +153,25 @@ The mouse support also has been programmed. You can scroll the chatbox and the s "emoji": true, "markdown": true, "pictures": true, - "browser": "" + "browser": "", + "notification": "" } } ``` -* `browser`: Config your preferable browser to open the link, when ever you focus on chat box text which contains external link (http/https), press enter key, the link will be opened. Valid [value](https://docs.python.org/2/library/webbrowser.html#webbrowser.get). Example you can config `"browser": "chrome"` +* `browser`: Config your preferable browser to open the link, when ever you focus on chat box text which contains external link (http/https), press enter key, the link will be opened. Valid [value](https://docs.python.org/2/library/webbrowser.html#webbrowser.get). Example you can config `"browser": "chrome"` +* `notification`: How do you want to receive notification. `all` receive all; `none` disable notification, `mentioned` Only mentioned and direct message + +#### Notification + +Supported: +* Linux +* Macos >= 10.10 use [terminal-notifier](https://github.com/julienXX/terminal-notifier), you can install your custom terminal-notifier or using default binary in pync package + +To test your notification availability, trigger below command, if you can see notification you can use this feature + +```bash +python sclack/notification.py +``` ## Tested Terminals diff --git a/TODO.md b/TODO.md index 2681b70..c7efc80 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,11 @@ - [x] Show indicator when a message is edited - [x] Live events - [x] Post message +- [x] 'Do not disturb' status - [ ] Header for direct message - [ ] Load more on up - [ ] Unread messages indicator -- [ ] Navigate throught users and conversations I don't belong to. +- [ ] Navigate thought users and conversations I don't belong to. - [ ] Publish on PIP - [ ] Group messages - [ ] Update documentation and screenshots @@ -15,7 +16,6 @@ # Good to have - React to a message -- 'Do not disturb' status - Integration with reminders - Handle slash commands -- RTM events (see https://api.slack.com/rtm) \ No newline at end of file +- RTM events (see https://api.slack.com/rtm) diff --git a/app.py b/app.py index 430a2ad..7161a12 100755 --- a/app.py +++ b/app.py @@ -3,14 +3,17 @@ import concurrent.futures import functools import json +import re import os import requests import sys +import platform import time import traceback import tempfile import urwid from datetime import datetime + from sclack.components import Attachment, Channel, ChannelHeader, ChatBox, Dm from sclack.components import Indicators, MarkdownText, MessageBox from sclack.component.message import Message @@ -22,9 +25,11 @@ from sclack.quick_switcher import QuickSwitcher from sclack.store import Store from sclack.themes import themes +from sclack.notification import TerminalNotifier from sclack.widgets.set_snooze import SetSnoozeWidget from sclack.utils.channel import is_dm, is_group, is_channel +from sclack.utils.message import get_mentioned_patterns loop = asyncio.get_event_loop() @@ -90,6 +95,38 @@ def __init__(self, config): ) self.configure_screen(self.urwid_loop.screen) self.last_keypress = (0, None) + self.mentioned_patterns = None + + def get_mentioned_patterns(self): + return get_mentioned_patterns(self.store.state.auth['user_id']) + + def should_notify_me(self, message_obj): + """ + Checking whether notify to user + :param message_obj: + :return: + """ + # Snoozzzzzed or disabled + if self.store.state.is_snoozed or self.config['features']['notification'] in ['', 'none']: + return False + + # You send message, don't need notification + if message_obj.get('user') == self.store.state.auth['user_id']: + return False + + if self.config['features']['notification'] == 'all': + return True + + # Private message + if message_obj.get('channel') is not None and message_obj.get('channel')[0] == 'D': + return True + + regex = self.mentioned_patterns + if regex is None: + regex = self.get_mentioned_patterns() + self.mentioned_patterns = regex + + return len(re.findall(regex, message_obj['text'])) > 0 def start(self): self._loading = True @@ -151,6 +188,8 @@ def mount_sidebar(self, executor): loop.run_in_executor(executor, self.store.load_users), loop.run_in_executor(executor, self.store.load_user_dnd), ) + self.mentioned_patterns = self.get_mentioned_patterns() + profile = Profile(name=self.store.state.auth['user'], is_snoozed=self.store.state.is_snoozed) channels = [] @@ -345,7 +384,7 @@ def go_to_profile(self, user_id): return self.store.state.profile_user_id = user_id profile = ProfileSideBar( - user.get('display_name') or user.get('real_name') or user['name'], + self.store.get_user_display_name(user), user['profile'].get('status_text', None), user['profile'].get('tz_label', None), user['profile'].get('phone', None), @@ -361,7 +400,7 @@ def render_chatbox_header(self): if self.store.state.channel['id'][0] == 'D': user = self.store.find_user_by_id(self.store.state.channel['user']) header = ChannelHeader( - name=user.get('display_name') or user.get('real_name') or user['name'], + name=self.store.get_user_display_name(user), topic=user['profile']['status_text'], is_starred=self.store.state.channel.get('is_starred', False), is_dm_workaround_please_remove_me=True @@ -383,6 +422,18 @@ def on_change_topic(self, text): self.store.set_topic(self.store.state.channel['id'], text) self.go_to_sidebar() + def notification_messages(self, messages): + """ + Check and send notifications + :param messages: + :return: + """ + for message in messages: + if self.should_notify_me(message): + loop.create_task( + self.send_notification(message, MarkdownText(message['text'])) + ) + def render_message(self, message, channel_id=None): is_app = False subtype = message.get('subtype') @@ -458,6 +509,7 @@ def render_message(self, message, channel_id=None): ] attachments = [] + for attachment in message.get('attachments', []): attachment_widget = Attachment( service_name=attachment.get('service_name'), @@ -538,8 +590,9 @@ def render_messages(self, messages, channel_id=None): previous_date = self.store.state.last_date last_read_datetime = datetime.fromtimestamp(float(self.store.state.channel.get('last_read', '0'))) today = datetime.today().date() - for message in messages: - message_datetime = datetime.fromtimestamp(float(message['ts'])) + + for raw_message in messages: + message_datetime = datetime.fromtimestamp(float(raw_message['ts'])) message_date = message_datetime.date() date_text = None unread_text = None @@ -561,13 +614,50 @@ def render_messages(self, messages, channel_id=None): elif date_text is not None: _messages.append(TextDivider(('history_date', date_text), 'center')) - message = self.render_message(message, channel_id) + message = self.render_message(raw_message, channel_id) if message is not None: _messages.append(message) return _messages + @asyncio.coroutine + def send_notification(self, raw_message, markdown_text): + """ + Only MacOS and Linux + @TODO Windows + :param raw_message: + :param markdown_text: + :return: + """ + user = self.store.find_user_by_id(raw_message.get('user')) + sender_name = self.store.get_user_display_name(user) + + if raw_message.get('channel')[0] == 'D': + notification_title = 'New message in {}'.format( + self.store.state.auth['team'] + ) + else: + notification_title = 'New message in {} #{}'.format( + self.store.state.auth['team'], + self.store.get_channel_name(raw_message.get('channel')), + ) + + + icon_path = os.path.realpath( + os.path.join( + os.path.dirname(__file__), + 'resources/slack_icon.png' + ) + ) + TerminalNotifier().notify( + str(markdown_text), + title=notification_title, + subtitle=sender_name, + appIcon=icon_path, + sound='default' + ) + def handle_mark_read(self, data): """ Mark as read to bottom @@ -760,13 +850,18 @@ def stop_typing(*args): self.chatbox.body.scroll_to_bottom() else: pass + + if event.get('subtype') != 'message_deleted' and event.get('subtype') != 'message_changed': + # Notification + self.notification_messages([event]) elif event['type'] == 'user_typing': if not self.is_chatbox_rendered: return if event.get('channel') == self.store.state.channel['id']: user = self.store.find_user_by_id(event['user']) - name = user.get('display_name') or user.get('real_name') or user['name'] + name = self.store.get_user_display_name(user) + if alarm is not None: self.urwid_loop.remove_alarm(alarm) self.chatbox.message_box.typing = name @@ -775,8 +870,8 @@ def stop_typing(*args): pass # print(json.dumps(event, indent=2)) elif event.get('type') == 'dnd_updated' and 'dnd_status' in event: - self.store.is_snoozed = event['dnd_status']['snooze_enabled'] - self.sidebar.profile.set_snooze(self.store.is_snoozed) + self.store.state.is_snoozed = event['dnd_status']['snooze_enabled'] + self.sidebar.profile.set_snooze(self.store.state.is_snoozed) elif event.get('ok', False): if not self.is_chatbox_rendered: return @@ -925,6 +1020,7 @@ def ask_for_token(json_config): config_file.write(json.dumps(token_config, indent=False)) json_config.update(token_config) + if __name__ == '__main__': json_config = {} with open('./config.json', 'r') as config_file: diff --git a/config.json b/config.json index 8f7c87a..0abe28d 100644 --- a/config.json +++ b/config.json @@ -27,7 +27,8 @@ "emoji": true, "markdown": true, "pictures": true, - "browser": "" + "browser": "", + "notification": "" }, "icons": { "block": "\u258C", diff --git a/requirements.txt b/requirements.txt index cfa1999..6466598 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pyperclip==1.6.2 requests slackclient==1.2.1 urwid_readline +git+git://github.com/duynguyenhoang/pync@994fbf77360a273fac1225558de01c8d0040dc6c#egg=pync diff --git a/resources/slack_icon.png b/resources/slack_icon.png new file mode 100644 index 0000000..edf6343 Binary files /dev/null and b/resources/slack_icon.png differ diff --git a/sclack/markdown.py b/sclack/markdown.py index d8609d7..c2c8277 100644 --- a/sclack/markdown.py +++ b/sclack/markdown.py @@ -41,6 +41,7 @@ def parse_message(self, text): self._state = 'message' self._previous_state = 'message' self._result = [] + def render_emoji(result): return emoji_codemap.get(result.group(1), result.group(0)) @@ -72,3 +73,6 @@ def render_emoji(result): self._result.append(('message', self.decode_buffer())) return self._result + + def __str__(self): + return urwid.Text(self.markup).text diff --git a/sclack/notification.py b/sclack/notification.py new file mode 100644 index 0000000..ed20c1b --- /dev/null +++ b/sclack/notification.py @@ -0,0 +1,106 @@ +# Notification wrapper + +import os +import platform +import subprocess +import sys + + +class TerminalNotifier(object): + def __init__(self, wait=False): + self.wait = wait + + def notify(self, message, **kwargs): + if platform.system() == 'Darwin': + import pync + pync.notify(message, **kwargs) + elif platform.system() == 'Linux': + new_kwargs = {} + mappings = { + 'group': 'category', + 'appIcon': 'icon', + 'title': 'title', + 'subtitle': 'subtitle', + } + + for origin_attr, new_attr in mappings.items(): + if kwargs.get(origin_attr): + new_kwargs[new_attr] = kwargs.get(origin_attr) + + if kwargs.get('subtitle'): + if new_kwargs.get('title'): + title = '{} by '.format(new_kwargs['title']) + else: + title = '' + + new_kwargs['title'] = '{}{}'.format(title, kwargs.get('subtitle')) + + pync = LinuxTerminalNotifier(wait=self.wait) + pync.notify(message, **new_kwargs) + else: + # M$ Windows + pass + + +class LinuxTerminalNotifier(object): + def __init__(self, wait=False): + """ + Raises an exception if not supported on the current platform or + if terminal-notifier was not found. + """ + self._wait = wait + proc = subprocess.Popen(["which", "notify-send"], stdout=subprocess.PIPE) + env_bin_path = proc.communicate()[0].strip() + + if env_bin_path and os.path.exists(env_bin_path): + self.bin_path = os.path.realpath(env_bin_path) + + if not os.path.exists(self.bin_path): + raise Exception("Notifier is not defined") + + def notify(self, message, **kwargs): + if sys.version_info < (3,): + message = message.encode('utf-8') + + self._wait = kwargs.pop('wait', False) + args = [] + + if kwargs.get('icon'): + args += ['--icon', kwargs['icon']] + + if kwargs.get('title'): + args += [kwargs['title'], message] + else: + args += [message] + + return self.execute(args) + + def execute(self, args): + args = [str(arg) for arg in args] + + output = subprocess.Popen([self.bin_path, ] + args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + if self._wait: + output.wait() + + if output.returncode: + raise Exception("Some error during subprocess call.") + + return output + + +if __name__ == '__main__': + """ + Test your notification availability + """ + TerminalNotifier().notify( + 'Your notification message is here', + title='Sclack notification', + appIcon=os.path.realpath( + os.path.join( + os.path.dirname(__file__), + '..', + 'resources/slack_icon.png' + ) + ) + ) diff --git a/sclack/store.py b/sclack/store.py index eec697f..2273811 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -162,6 +162,19 @@ def load_channels(self): self.state.channels.sort(key=lambda channel: channel['name']) self.state.dms.sort(key=lambda dm: dm['created']) + def get_channel_name(self, channel_id): + matched_channel = None + + for channel in self.state.channels: + if channel['id'] == channel_id: + matched_channel = channel + break + + if matched_channel: + return matched_channel['name'] + + return channel_id + def load_groups(self): self.state.groups = self.slack.api_call('mpim.list')['groups'] diff --git a/sclack/utils/message.py b/sclack/utils/message.py index e784826..64b8fc9 100644 --- a/sclack/utils/message.py +++ b/sclack/utils/message.py @@ -1,3 +1,5 @@ +import re + from datetime import datetime @@ -17,3 +19,27 @@ def format_date_time(ts): date_text = message_datetime.strftime('%b %d, %Y at %I:%M%p') return date_text + + +def get_mentioned_patterns(user_id): + """ + All possible pattern in message which mention me + :param user_id: + :type user_id: str + :return: + """ + slack_mentions = [ + '', + '', + '', + '<@{}>'.format(user_id), + ] + + patterns = [] + + for mention in slack_mentions: + patterns.append('^{}[ ]+'.format(mention)) + patterns.append('^{}$'.format(mention)) + patterns.append('[ ]+{}'.format(mention)) + + return re.compile('|'.join(patterns)) diff --git a/tests/test_quick_switcher.py b/tests/test_quick_switcher.py index 0002617..2cd4d06 100644 --- a/tests/test_quick_switcher.py +++ b/tests/test_quick_switcher.py @@ -1,4 +1,5 @@ from sclack.quick_switcher import remove_diacritic + def test_remove_diacritic(): assert remove_diacritic("sábado") == "sabado"