Added Slack integration

This commit is contained in:
Fabio Manganiello 2021-07-28 01:09:09 +02:00
parent 286bab7489
commit 90ec108580
12 changed files with 409 additions and 3 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
``platypush.message.event.chat.slack``
======================================
.. automodule:: platypush.message.event.chat.slack
:members:

View file

@ -0,0 +1,5 @@
``chat``
========
.. automodule:: platypush.plugins.chat
:members:

View file

@ -0,0 +1,5 @@
``slack``
=========
.. automodule:: platypush.plugins.slack
:members:

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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:

258
platypush/plugins/slack.py Normal file
View file

@ -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 <https://api.slack.com/apps>`_ 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 <https://api.slack.com/reference/block-kit/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

View file

@ -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:

View file

@ -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.'
)
)