From 90ec1085805c49002f284e92386a89778d985ed0 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Wed, 28 Jul 2021 01:09:09 +0200 Subject: [PATCH] Added Slack integration --- CHANGELOG.md | 4 +- docs/source/events.rst | 1 + docs/source/platypush/events/chat.slack.rst | 5 + docs/source/platypush/plugins/chat.rst | 5 + docs/source/platypush/plugins/slack.rst | 5 + docs/source/plugins.rst | 2 + platypush/message/event/chat/slack.py | 70 ++++++ platypush/plugins/chat/__init__.py | 10 + platypush/plugins/chat/telegram.py | 5 +- platypush/plugins/slack.py | 258 ++++++++++++++++++++ platypush/schemas/__init__.py | 13 + platypush/schemas/slack.py | 34 +++ 12 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 docs/source/platypush/events/chat.slack.rst create mode 100644 docs/source/platypush/plugins/chat.rst create mode 100644 docs/source/platypush/plugins/slack.rst create mode 100644 platypush/message/event/chat/slack.py create mode 100644 platypush/plugins/slack.py create mode 100644 platypush/schemas/slack.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f3837d..dd430c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,14 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. -## [Unreleased] +## [0.21.3] - 2021-07-28 ### Added - Added `sun` plugin for sunrise/sunset events. +- Added `slack` integration. + ## [0.21.2] - 2021-07-20 ### Added diff --git a/docs/source/events.rst b/docs/source/events.rst index b0bace68..3d016653 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -13,6 +13,7 @@ Events platypush/events/bluetooth.rst platypush/events/button.flic.rst platypush/events/camera.rst + platypush/events/chat.slack.rst platypush/events/chat.telegram.rst platypush/events/clipboard.rst platypush/events/covid19.rst diff --git a/docs/source/platypush/events/chat.slack.rst b/docs/source/platypush/events/chat.slack.rst new file mode 100644 index 00000000..eb8e8dba --- /dev/null +++ b/docs/source/platypush/events/chat.slack.rst @@ -0,0 +1,5 @@ +``platypush.message.event.chat.slack`` +====================================== + +.. automodule:: platypush.message.event.chat.slack + :members: diff --git a/docs/source/platypush/plugins/chat.rst b/docs/source/platypush/plugins/chat.rst new file mode 100644 index 00000000..621ee988 --- /dev/null +++ b/docs/source/platypush/plugins/chat.rst @@ -0,0 +1,5 @@ +``chat`` +======== + +.. automodule:: platypush.plugins.chat + :members: diff --git a/docs/source/platypush/plugins/slack.rst b/docs/source/platypush/plugins/slack.rst new file mode 100644 index 00000000..dd59735f --- /dev/null +++ b/docs/source/platypush/plugins/slack.rst @@ -0,0 +1,5 @@ +``slack`` +========= + +.. automodule:: platypush.plugins.slack + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 72570625..63d62fbb 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -25,6 +25,7 @@ Plugins platypush/plugins/camera.gstreamer.rst platypush/plugins/camera.ir.mlx90640.rst platypush/plugins/camera.pi.rst + platypush/plugins/chat.rst platypush/plugins/chat.telegram.rst platypush/plugins/clipboard.rst platypush/plugins/config.rst @@ -112,6 +113,7 @@ Plugins platypush/plugins/sensor.rst platypush/plugins/serial.rst platypush/plugins/shell.rst + platypush/plugins/slack.rst platypush/plugins/smartthings.rst platypush/plugins/sound.rst platypush/plugins/ssh.rst diff --git a/platypush/message/event/chat/slack.py b/platypush/message/event/chat/slack.py new file mode 100644 index 00000000..52536e0c --- /dev/null +++ b/platypush/message/event/chat/slack.py @@ -0,0 +1,70 @@ +from abc import ABC +from datetime import datetime +from typing import Union, Optional, Iterable + +from dateutil.tz import gettz + +from platypush.message.event import Event + + +class SlackEvent(Event, ABC): + """ + Base class for Slack events. + """ + def __init__(self, *args, timestamp: Optional[Union[int, float, datetime]] = None, **kwargs): + """ + :param timestamp: Event timestamp. + """ + kwargs['event_time'] = self._convert_timestamp(timestamp) + super().__init__(*args, **kwargs) + + @staticmethod + def _convert_timestamp(timestamp: Optional[Union[int, float, datetime]] = None) -> Optional[datetime]: + if not (isinstance(timestamp, int) or isinstance(timestamp, float)): + return timestamp + + return datetime.fromtimestamp(timestamp, tz=gettz()) + + +class SlackMessageEvent(SlackEvent, ABC): + """ + Base class for message-related events. + """ + def __init__(self, *args, text: str, user: str, channel: Optional[str] = None, team: Optional[str] = None, + icons: dict = None, blocks: Iterable[dict] = None, **kwargs): + """ + :param text: Message text. + :param user: ID of the sender. + :param channel: ID of the channel. + :param team: ID of the team. + :param icons: Mapping of the icons for this message. + :param blocks: Extra blocks in the message. + """ + super().__init__(*args, text=text, user=user, channel=channel, team=team, icons=icons, blocks=blocks, **kwargs) + + +class SlackMessageReceivedEvent(SlackMessageEvent): + """ + Event triggered when a message is received on a monitored resource. + """ + + +class SlackMessageEditedEvent(SlackMessageEvent): + """ + Event triggered when a message is edited on a monitored resource. + """ + + +class SlackMessageDeletedEvent(SlackMessageEvent): + """ + Event triggered when a message is deleted from a monitored resource. + """ + + +class SlackAppMentionReceivedEvent(SlackMessageEvent): + """ + Event triggered when a message that mentions the app is received on a monitored resource. + """ + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/chat/__init__.py b/platypush/plugins/chat/__init__.py index e69de29b..3e657a4e 100644 --- a/platypush/plugins/chat/__init__.py +++ b/platypush/plugins/chat/__init__.py @@ -0,0 +1,10 @@ +from platypush.plugins import Plugin, action + + +class ChatPlugin(Plugin): + """ + Base class for chat plugins. + """ + @action + def send_message(self, *args, **kwargs): + raise NotImplementedError() diff --git a/platypush/plugins/chat/telegram.py b/platypush/plugins/chat/telegram.py index 3f238b9e..0d0d8826 100644 --- a/platypush/plugins/chat/telegram.py +++ b/platypush/plugins/chat/telegram.py @@ -13,7 +13,8 @@ from telegram.user import User as TelegramUser from platypush.message.response.chat.telegram import TelegramMessageResponse, TelegramFileResponse, \ TelegramChatResponse, TelegramUserResponse, TelegramUsersResponse -from platypush.plugins import Plugin, action +from platypush.plugins import action +from platypush.plugins.chat import ChatPlugin class Resource: @@ -36,7 +37,7 @@ class Resource: self._file.close() -class ChatTelegramPlugin(Plugin): +class ChatTelegramPlugin(ChatPlugin): """ Plugin to programmatically send Telegram messages through a Telegram bot. In order to send messages to contacts, groups or channels you'll first need to register a bot. To do so: diff --git a/platypush/plugins/slack.py b/platypush/plugins/slack.py new file mode 100644 index 00000000..5e4cfdbe --- /dev/null +++ b/platypush/plugins/slack.py @@ -0,0 +1,258 @@ +import json +from typing import Optional, Iterable + +import multiprocessing +import requests +import websocket +from websocket import WebSocketApp + +from platypush.context import get_bus +from platypush.message.event.chat.slack import SlackMessageReceivedEvent, SlackMessageDeletedEvent, \ + SlackMessageEditedEvent, SlackAppMentionReceivedEvent +from platypush.plugins import RunnablePlugin, action +from platypush.plugins.chat import ChatPlugin + + +class SlackPlugin(ChatPlugin, RunnablePlugin): + """ + Plugin used to interact with Slack instances. + + You'll have to generate your own Slack app and tokens in order to use this plugin. Steps: + + - Create a new Slack app `here `_ and associate a Slack workspace to it. + - In the configuration panel of your app scroll to _Socket Mode_ and select _Enable Socket Mode_. + - Scroll to _Event Subscriptions_ and select _Enable Events_. + - Choose the type of events that you want to subscribe to. You can select bot events (i.e. when somebody in + the channel mentions the name of your app) or any of the workspace events (e.g. creation of messages, user + events etc.). + - Scroll to _App-Level Tokens_ and generate a new token with ``connections:write`` scope. This token will be + used to receive Slack events over websocket. + - Scroll to _OAuth & Permissions_ and select the scopes that you want to enable. You may usually want to enable + _Bot Token Scopes_ -> ``app_mentions:read``, so the script can react when somebody mentions its name. You may + also want to select the user scopes relevant to your application - e.g. read/write messages, manage users etc. + - If you changed scopes and settings, you may have to reinstall the app in the workspace through the + _Install App_ menu. + - Navigate to the _Install App_ menu. If the app has been correctly installed in your workspace then you should + see a _Bot User OAuth Token_, used to authenticate API calls performed as the app/bot. If you also granted + user permissions to the app then you should also see a _User OAuth Token_ on the page. + + Triggers: + + - :class:`platypush.message.event.chat.slack.SlackMessageReceivedEvent` when a message is received on a + monitored channel. + - :class:`platypush.message.event.chat.slack.SlackMessageEditedEvent` when a message is edited on a + monitored channel. + - :class:`platypush.message.event.chat.slack.SlackMessageDeletedEvent` when a message is deleted from a + monitored channel. + - :class:`platypush.message.event.chat.slack.SlackAppMentionReceivedEvent` when a message that mentions + the app is received on a monitored channel. + + """ + + _api_base_url = 'https://slack.com/api' + + def __init__(self, app_token: str, bot_token: str, user_token: Optional[str] = None, **kwargs): + """ + :param app_token: Your Slack app token. + :param bot_token: Bot OAuth token reported on the _Install App_ menu. + :param user_token: User OAuth token reported on the _Install App_ menu. + """ + super().__init__(**kwargs) + self._app_token = app_token + self._bot_token = bot_token + self._user_token = user_token + self._ws_url: Optional[str] = None + self._ws_app: Optional[websocket.WebSocketApp] = None + self._ws_listener: Optional[multiprocessing.Process] = None + self._connected_event = multiprocessing.Event() + self._disconnected_event = multiprocessing.Event() + self._state_lock = multiprocessing.RLock() + + @classmethod + def _url_for(cls, method: str) -> str: + return f'{cls._api_base_url}/{method}' + + @action + def send_message(self, channel: str, as_user: bool = False, text: Optional[str] = None, + blocks: Optional[Iterable[str]] = None, **kwargs): + """ + Send a message to a channel. + It requires a token with ``chat:write`` bot/user scope. + + :param channel: Channel ID or name. + :param as_user: If true then the message will be sent as the authorized user, otherwise as the application bot + (default: false). + :param text: Text to be sent. + :param blocks: Extra blocks to be added to the message (e.g. images, links, markdown). See + `Slack documentation for blocks `_. + """ + rs = requests.post( + self._url_for('chat.postMessage'), + headers={ + 'Authorization': f'Bearer {self._user_token if as_user else self._bot_token}' + }, + json={ + 'channel': channel, + 'text': text, + 'blocks': blocks or [], + } + ) + + try: + rs.raise_for_status() + rs = rs.json() + assert rs.get('ok'), rs.get('error', rs.get('warning')) + except Exception as e: + raise AssertionError(e) + + def main(self): + self._connect() + stop_events = [] + + while not any(stop_events): + stop_events = self._should_stop.wait(timeout=1), self._disconnected_event.wait(timeout=1) + + def stop(self): + if self._ws_app: + self._ws_app.close() + self._ws_app = None + + if self._ws_listener and self._ws_listener.is_alive(): + self.logger.info('Terminating websocket process') + self._ws_listener.terminate() + self._ws_listener.join(5) + + if self._ws_listener and self._ws_listener.is_alive(): + self.logger.warning('Terminating the websocket process failed, killing the process') + self._ws_listener.kill() + + if self._ws_listener: + self._ws_listener.join() + self._ws_listener = None + + super().stop() + + def _connect(self): + with self._state_lock: + if self.should_stop() or self._connected_event.is_set(): + return + + self._ws_url = None + rs = requests.post('https://slack.com/api/apps.connections.open', headers={ + 'Authorization': f'Bearer {self._app_token}', + }) + + try: + rs.raise_for_status() + except: + if rs.status_code == 401 or rs.status_code == 403: + self.logger.error('Unauthorized/Forbidden Slack API request, stopping the service') + self.stop() + return + + raise + + rs = rs.json() + assert rs.get('ok') + self._ws_url = rs.get('url') + self._ws_app = websocket.WebSocketApp(self._ws_url, + on_open=self._on_open(), + on_message=self._on_msg(), + on_error=self._on_error(), + on_close=self._on_close()) + + def server(): + self._ws_app.run_forever() + + self._ws_listener = multiprocessing.Process(target=server) + self._ws_listener.start() + + def _on_open(self): + def hndl(*_): + with self._state_lock: + self._disconnected_event.clear() + self._connected_event.set() + self.logger.info('Connected to the Slack websocket') + + return hndl + + @staticmethod + def _send_ack(ws: WebSocketApp, msg): + envelope_id = msg.get('envelope_id') + if envelope_id: + # Send ACK + ws.send(json.dumps({ + 'envelope_id': envelope_id, + })) + + def _on_msg(self): + def hndl(*args): + ws = args[0] if len(args) > 1 else self._ws_app + data = json.loads(args[1] if len(args) > 1 else args[0]) + output_event = None + self._send_ack(ws, data) + + if data['type'] == 'events_api': + event = data.get('payload', {}).get('event', {}) + event_args = {} + + if event['type'] == 'app_mention': + output_event = SlackAppMentionReceivedEvent( + text=event['text'], + user=event['user'], + channel=event['channel'], + team=event['team'], + timestamp=event['event_ts'], + icons=event.get('icons'), + blocks=event.get('blocks') + ) + elif event['type'] == 'message': + msg = event.copy() + prev_msg = event.get('previous_message') + event_type = SlackMessageReceivedEvent + + if event.get('subtype') == 'message_deleted': + msg = prev_msg + event_type = SlackMessageDeletedEvent + event_args['timestamp'] = event['deleted_ts'] + else: + event_args['timestamp'] = msg.get('ts') + if event.get('subtype') == 'message_changed': + msg = msg.get('message', msg) + event_args['previous_message'] = prev_msg + event_type = SlackMessageEditedEvent + + event_args.update({ + 'text': msg.get('text'), + 'user': msg.get('user'), + 'channel': msg.get('channel', event.get('channel')), + 'team': msg.get('team'), + 'icons': msg.get('icons'), + 'blocks': msg.get('blocks'), + }) + + output_event = event_type(**event_args) + + if output_event: + get_bus().post(output_event) + + return hndl + + def _on_error(self): + def hndl(*args): + error = args[1] if len(args) > 1 else args[0] + ws = args[0] if len(args) > 1 else None + self.logger.warning('Slack websocket error: {}'.format(error)) + if ws: + ws.close() + + return hndl + + def _on_close(self): + def hndl(*_): + with self._state_lock: + self._disconnected_event.set() + self._connected_event.clear() + self.logger.warning('Slack websocket connection closed') + + return hndl diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py index c4c103f9..a1ab6e55 100644 --- a/platypush/schemas/__init__.py +++ b/platypush/schemas/__init__.py @@ -1,6 +1,19 @@ from datetime import datetime from typing import Optional +from marshmallow import fields + + +class StrippedString(fields.Function): + def __init__(self, *args, **kwargs): + kwargs['serialize'] = self._strip + kwargs['deserialize'] = self._strip + super().__init__(*args, **kwargs) + + @staticmethod + def _strip(value: str): + return value.strip() + def normalize_datetime(dt: str) -> Optional[datetime]: if not dt: diff --git a/platypush/schemas/slack.py b/platypush/schemas/slack.py new file mode 100644 index 00000000..cc79b9da --- /dev/null +++ b/platypush/schemas/slack.py @@ -0,0 +1,34 @@ +from marshmallow import fields, INCLUDE +from marshmallow.schema import Schema + +from platypush.schemas import StrippedString + + +class SlackMessageBlockSchema(Schema): + class Meta: + unknown = INCLUDE + + type = fields.String(required=True, metadata=dict(description='Message block type')) + block_id = fields.String(required=True, metadata=dict(description='Block ID')) + + +class SlackMessageIconSchema(Schema): + image_36 = fields.URL() + image_48 = fields.URL() + image_72 = fields.URL() + + +class SlackMessageSchema(Schema): + text = StrippedString(required=True, metadata=dict(description='Message text')) + user = fields.String(required=True, metadata=dict(description='User ID of the sender')) + channel = fields.String(metadata=dict(description='Channel ID associated with the message')) + team = fields.String(metadata=dict(description='Team ID associated with the message')) + timestamp = fields.DateTime(metadata=dict(description='Date and time of the event')) + icons = fields.Nested(SlackMessageIconSchema) + blocks = fields.Nested(SlackMessageBlockSchema, many=True) + previous_message = fields.Nested( + 'SlackMessageSchema', metadata=dict( + description='For received replies, it includes the parent message in the reply chain. ' + 'For edited messages, it contains the previous version.' + ) + )