forked from platypush/platypush
Added Slack integration
This commit is contained in:
parent
286bab7489
commit
90ec108580
12 changed files with 409 additions and 3 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
5
docs/source/platypush/events/chat.slack.rst
Normal file
5
docs/source/platypush/events/chat.slack.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``platypush.message.event.chat.slack``
|
||||
======================================
|
||||
|
||||
.. automodule:: platypush.message.event.chat.slack
|
||||
:members:
|
5
docs/source/platypush/plugins/chat.rst
Normal file
5
docs/source/platypush/plugins/chat.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``chat``
|
||||
========
|
||||
|
||||
.. automodule:: platypush.plugins.chat
|
||||
:members:
|
5
docs/source/platypush/plugins/slack.rst
Normal file
5
docs/source/platypush/plugins/slack.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``slack``
|
||||
=========
|
||||
|
||||
.. automodule:: platypush.plugins.slack
|
||||
:members:
|
|
@ -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
|
||||
|
|
70
platypush/message/event/chat/slack.py
Normal file
70
platypush/message/event/chat/slack.py
Normal 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:
|
|
@ -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()
|
|
@ -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
258
platypush/plugins/slack.py
Normal 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
|
|
@ -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:
|
||||
|
|
34
platypush/schemas/slack.py
Normal file
34
platypush/schemas/slack.py
Normal 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.'
|
||||
)
|
||||
)
|
Loading…
Reference in a new issue