From 8aadd5569ed4cc20c2a4849482e75e9c1c20bb71 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 3 Jan 2020 16:28:49 +0100 Subject: [PATCH] Added Trello backend: closes #90 --- platypush/backend/todoist.py | 3 +- platypush/backend/trello.py | 195 +++++++++++++++++++++++++++ platypush/message/event/trello.py | 75 +++++++++++ platypush/message/response/trello.py | 1 + 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 platypush/backend/trello.py create mode 100644 platypush/message/event/trello.py diff --git a/platypush/backend/todoist.py b/platypush/backend/todoist.py index aca190208..b493ab269 100644 --- a/platypush/backend/todoist.py +++ b/platypush/backend/todoist.py @@ -65,7 +65,8 @@ class TodoistBackend(Backend): return hndl def _on_close(self): - def hndl(): + # noinspection PyUnusedLocal + def hndl(ws): self._ws = None self.logger.warning('Todoist websocket connection closed') self._connected = False diff --git a/platypush/backend/trello.py b/platypush/backend/trello.py new file mode 100644 index 000000000..293065fd3 --- /dev/null +++ b/platypush/backend/trello.py @@ -0,0 +1,195 @@ +import json + +from threading import Event +from typing import List +from websocket import WebSocketApp + +from platypush.backend import Backend +from platypush.context import get_plugin +from platypush.message.event.trello import MoveCardEvent, NewCardEvent, ArchivedCardEvent, \ + UnarchivedCardEvent + +from platypush.plugins.trello import TrelloPlugin + + +class TrelloBackend(Backend): + """ + This backend listens for events on a remote Trello account through websocket interface.. + Note that the Trello websocket interface + `is not officially supported `_ + and it requires a different token from the one you use for the Trello API (and for the Trello plugin). + To get the websocket token: + + 1. Open https://trello.com in your browser. + 2. Open the developer tools (F12), go to the Network tab, select 'Websocket' or 'WS' in the filter bar + and refresh the page. + 3. You should see an entry in the format ``wss://trello.com/1/Session/socket?token=``. + 4. Copy the ```` in the configuration of this backend. + + Requires: + + * **websocket-client** (``pip install websocket-client``) + * The :class:`platypush.plugins.trello.TrelloPlugin` configured. + + Triggers: + + * :class:`platypush.message.event.trello.NewCardEvent` when a card is created. + * :class:`platypush.message.event.MoveCardEvent` when a card is moved. + * :class:`platypush.message.event.ArchivedCardEvent` when a card is archived/closed. + * :class:`platypush.message.event.UnarchivedCardEvent` when a card is un-archived/opened. + + """ + + _websocket_url_base = 'wss://trello.com/1/Session/socket?token={token}' + + def __init__(self, boards: List[str], token: str, **kwargs): + """ + :param boards: List of boards to subscribe, by ID or name. + :param token: Trello web client API token. + """ + + super().__init__(**kwargs) + self._plugin: TrelloPlugin = get_plugin('trello') + self.token = token + self._req_id = 0 + self._boards_by_id = {} + self._boards_by_name = {} + + for b in boards: + b = self._plugin.get_board(b).board + self._boards_by_id[b.id] = b + self._boards_by_name[b.name] = b + + self.url = self._websocket_url_base.format(token=self.token) + self._connected = Event() + + self._items = {} + self._event_handled = False + + def _initialize_connection(self, ws: WebSocketApp): + for board_id in self._boards_by_id.keys(): + self._send(ws, { + 'type': 'subscribe', + 'modelType': 'Board', + 'idModel': board_id, + 'tags': ['clientActions', 'updates'], + 'invitationTokens': [], + }) + + self.logger.info('Trello boards subscribed') + + def _on_msg(self): + # noinspection PyUnusedLocal + def hndl(ws: WebSocketApp, msg): + if not msg: + # Reply back with an empty message when the server sends an empty message + ws.send('') + return + + # noinspection PyBroadException + try: + msg = json.loads(msg) + except: + self.logger.warning('Received invalid JSON message from Trello: {}'.format(msg)) + return + + if 'error' in msg: + self.logger.warning('Trello error: {}'.format(msg['error'])) + return + + if msg.get('reqid') == 0: + self.logger.debug('Ping response received, subscribing boards') + self._initialize_connection(ws) + return + + notify = msg.get('notify') + if not notify: + return + + if notify['event'] != 'updateModels' or notify['typeName'] != 'Action': + return + + for delta in notify['deltas']: + args = { + 'card_id': delta['data']['card']['id'], + 'card_name': delta['data']['card']['name'], + 'list_id': (delta['data'].get('list') or delta['data'].get('listAfter', {})).get('id'), + 'list_name': (delta['data'].get('list') or delta['data'].get('listAfter', {})).get('name'), + 'board_id': delta['data']['board']['id'], + 'board_name': delta['data']['board']['name'], + 'closed': delta.get('closed'), + 'member_id': delta['memberCreator']['id'], + 'member_username': delta['memberCreator']['username'], + 'member_fullname': delta['memberCreator']['fullName'], + 'date': delta['date'], + } + + if delta.get('type') == 'createCard': + self.bus.post(NewCardEvent(**args)) + elif delta.get('type') == 'updateCard': + if 'listBefore' in delta['data']: + args.update({ + 'old_list_id': delta['data']['listBefore']['id'], + 'old_list_name': delta['data']['listBefore']['name'], + }) + + self.bus.post(MoveCardEvent(**args)) + elif 'closed' in delta['data'].get('old', {}): + cls = UnarchivedCardEvent if delta['data']['old']['closed'] else ArchivedCardEvent + self.bus.post(cls(**args)) + + return hndl + + def _on_error(self): + # noinspection PyUnusedLocal + def hndl(ws: WebSocketApp, error): + self.logger.warning('Trello websocket error: {}'.format(error)) + + return hndl + + def _on_close(self): + # noinspection PyUnusedLocal + def hndl(ws: WebSocketApp): + self.logger.warning('Trello websocket connection closed') + self._connected.clear() + self._req_id = 0 + + while True: + try: + self._connect() + self._connected.wait(timeout=20) + break + except TimeoutError: + continue + + return hndl + + def _on_open(self): + # noinspection PyUnusedLocal + def hndl(ws: WebSocketApp): + self._connected.set() + self._send(ws, {'type': 'ping'}) + self.logger.info('Trello websocket connected') + + return hndl + + def _send(self, ws: WebSocketApp, msg: dict): + msg['reqid'] = self._req_id + ws.send(json.dumps(msg)) + self._req_id += 1 + + def _connect(self) -> WebSocketApp: + return WebSocketApp(self.url, + on_open=self._on_open(), + on_message=self._on_msg(), + on_error=self._on_error(), + on_close=self._on_close()) + + def run(self): + super().run() + self.logger.info('Started Todoist backend') + ws = self._connect() + ws.run_forever() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/trello.py b/platypush/message/event/trello.py new file mode 100644 index 000000000..194f06581 --- /dev/null +++ b/platypush/message/event/trello.py @@ -0,0 +1,75 @@ +import datetime + +from platypush.message.event import Event + + +class TrelloEvent(Event): + pass + + +class CardEvent(TrelloEvent): + def __init__(self, + card_id: str, + card_name: str, + list_id: str, + list_name: str, + board_id: str, + board_name: str, + closed: bool, + member_id: str, + member_username: str, + member_fullname: str, + date: datetime.datetime, + *args, **kwargs): + super().__init__(*args, + card_id=card_id, + card_name=card_name, + list_id=list_id, + list_name=list_name, + board_id=board_id, + board_name=board_name, + closed=closed, + member_id=member_id, + member_username=member_username, + member_fullname=member_fullname, + date=date, + **kwargs) + + +class NewCardEvent(CardEvent): + """ + Event triggered when a card is created. + """ + + +class MoveCardEvent(CardEvent): + """ + Event triggered when a card is moved to another list. + """ + + def __init__(self, old_list_id: str, old_list_name: str, *args, **kwargs): + super().__init__(*args, old_list_id=old_list_id, old_list_name=old_list_name, **kwargs) + + +class ArchivedCardEvent(CardEvent): + """ + Event triggered when a card is archived. + """ + + def __init__(self, *args, **kwargs): + kwargs['old_closed'] = False + super().__init__(*args, **kwargs) + + + +class UnarchivedCardEvent(CardEvent): + """ + Event triggered when a card is un-archived. + """ + + def __init__(self, *args, **kwargs): + kwargs['old_closed'] = True + super().__init__(*args, **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/response/trello.py b/platypush/message/response/trello.py index 9f397d330..2e28cbd30 100644 --- a/platypush/message/response/trello.py +++ b/platypush/message/response/trello.py @@ -37,6 +37,7 @@ class TrelloBoard(Mapping): class TrelloBoardResponse(TrelloResponse): def __init__(self, board: TrelloBoard, **kwargs): super().__init__(output=board, **kwargs) + self.board = board class TrelloBoardsResponse(TrelloResponse):