diff --git a/CHANGELOG.md b/CHANGELOG.md index 747d01ed..e6ec694c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ 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.23.4] - 2022-08-28 + +### Added + +- Added `matrix` integration + ([issue](https://git.platypush.tech/platypush/platypush/issues/2), + [PR](https://git.platypush.tech/platypush/platypush/pulls/217)). + +### Changed - Removed `clipboard` backend. Enabling the `clipboard` plugin will also enable clipboard monitoring, with no need for an additional backend. diff --git a/docs/source/conf.py b/docs/source/conf.py index a3f641d2..d27aa7cb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -71,7 +71,7 @@ master_doc = 'index' # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -138,15 +138,12 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -156,8 +153,7 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'platypush.tex', 'platypush Documentation', - 'BlackLight', 'manual'), + (master_doc, 'platypush.tex', 'platypush Documentation', 'BlackLight', 'manual'), ] @@ -165,10 +161,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'platypush', 'platypush Documentation', - [author], 1) -] +man_pages = [(master_doc, 'platypush', 'platypush Documentation', [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -177,9 +170,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'platypush', 'platypush Documentation', - author, 'platypush', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + 'platypush', + 'platypush Documentation', + author, + 'platypush', + 'One line description of project.', + 'Miscellaneous', + ), ] @@ -196,102 +195,108 @@ intersphinx_mapping = {'https://docs.python.org/': None} todo_include_todos = True autodoc_default_options = { - 'inherited-members': True, + 'members': True, + 'show-inheritance': True, } -autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', - 'google.assistant.embedded', - 'google.assistant.library', - 'google.assistant.library.event', - 'google.assistant.library.file_helpers', - 'google.oauth2.credentials', - 'oauth2client', - 'apiclient', - 'tenacity', - 'smartcard', - 'Leap', - 'oauth2client', - 'rtmidi', - 'bluetooth', - 'gevent.wsgi', - 'Adafruit_IO', - 'pyperclip', - 'pydbus', - 'inputs', - 'inotify', - 'omxplayer', - 'plexapi', - 'cwiid', - 'sounddevice', - 'soundfile', - 'numpy', - 'cv2', - 'nfc', - 'ndef', - 'bcrypt', - 'google', - 'feedparser', - 'kafka', - 'googlesamples', - 'icalendar', - 'httplib2', - 'mpd', - 'serial', - 'pyHS100', - 'grpc', - 'envirophat', - 'gps', - 'picamera', - 'pmw3901', - 'PIL', - 'croniter', - 'pyaudio', - 'avs', - 'PyOBEX', - 'todoist', - 'trello', - 'telegram', - 'telegram.ext', - 'pyfirmata2', - 'cups', - 'graphyte', - 'cpuinfo', - 'psutil', - 'openzwave', - 'deepspeech', - 'wave', - 'pvporcupine ', - 'pvcheetah', - 'pyotp', - 'linode_api4', - 'pyzbar', - 'tensorflow', - 'keras', - 'pandas', - 'samsungtvws', - 'paramiko', - 'luma', - 'zeroconf', - 'dbus', - 'gi', - 'gi.repository', - 'twilio', - 'Adafruit_Python_DHT', - 'RPi.GPIO', - 'RPLCD', - 'imapclient', - 'pysmartthings', - 'aiohttp', - 'watchdog', - 'pyngrok', - 'irc', - 'irc.bot', - 'irc.strings', - 'irc.client', - 'irc.connection', - 'irc.events', - 'defusedxml', - ] +autodoc_mock_imports = [ + 'googlesamples.assistant.grpc.audio_helpers', + 'google.assistant.embedded', + 'google.assistant.library', + 'google.assistant.library.event', + 'google.assistant.library.file_helpers', + 'google.oauth2.credentials', + 'oauth2client', + 'apiclient', + 'tenacity', + 'smartcard', + 'Leap', + 'oauth2client', + 'rtmidi', + 'bluetooth', + 'gevent.wsgi', + 'Adafruit_IO', + 'pyperclip', + 'pydbus', + 'inputs', + 'inotify', + 'omxplayer', + 'plexapi', + 'cwiid', + 'sounddevice', + 'soundfile', + 'numpy', + 'cv2', + 'nfc', + 'ndef', + 'bcrypt', + 'google', + 'feedparser', + 'kafka', + 'googlesamples', + 'icalendar', + 'httplib2', + 'mpd', + 'serial', + 'pyHS100', + 'grpc', + 'envirophat', + 'gps', + 'picamera', + 'pmw3901', + 'PIL', + 'croniter', + 'pyaudio', + 'avs', + 'PyOBEX', + 'todoist', + 'trello', + 'telegram', + 'telegram.ext', + 'pyfirmata2', + 'cups', + 'graphyte', + 'cpuinfo', + 'psutil', + 'openzwave', + 'deepspeech', + 'wave', + 'pvporcupine ', + 'pvcheetah', + 'pyotp', + 'linode_api4', + 'pyzbar', + 'tensorflow', + 'keras', + 'pandas', + 'samsungtvws', + 'paramiko', + 'luma', + 'zeroconf', + 'dbus', + 'gi', + 'gi.repository', + 'twilio', + 'Adafruit_Python_DHT', + 'RPi.GPIO', + 'RPLCD', + 'imapclient', + 'pysmartthings', + 'aiohttp', + 'watchdog', + 'pyngrok', + 'irc', + 'irc.bot', + 'irc.strings', + 'irc.client', + 'irc.connection', + 'irc.events', + 'defusedxml', + 'nio', + 'aiofiles', + 'aiofiles.os', + 'async_lru', +] sys.path.insert(0, os.path.abspath('../..')) diff --git a/docs/source/events.rst b/docs/source/events.rst index 8fdb115e..913705d1 100644 --- a/docs/source/events.rst +++ b/docs/source/events.rst @@ -42,6 +42,7 @@ Events platypush/events/linode.rst platypush/events/log.http.rst platypush/events/mail.rst + platypush/events/matrix.rst platypush/events/media.rst platypush/events/midi.rst platypush/events/mqtt.rst @@ -73,6 +74,7 @@ Events platypush/events/weather.rst platypush/events/web.rst platypush/events/web.widget.rst + platypush/events/websocket.rst platypush/events/wiimote.rst platypush/events/zeroborg.rst platypush/events/zeroconf.rst diff --git a/docs/source/platypush/events/matrix.rst b/docs/source/platypush/events/matrix.rst new file mode 100644 index 00000000..eaad6da6 --- /dev/null +++ b/docs/source/platypush/events/matrix.rst @@ -0,0 +1,5 @@ +``matrix`` +========== + +.. automodule:: platypush.message.event.matrix + :members: diff --git a/docs/source/platypush/events/websocket.rst b/docs/source/platypush/events/websocket.rst new file mode 100644 index 00000000..7ec41a28 --- /dev/null +++ b/docs/source/platypush/events/websocket.rst @@ -0,0 +1,5 @@ +``websocket`` +============= + +.. automodule:: platypush.message.event.websocket + :members: diff --git a/docs/source/platypush/plugins/dbus.rst b/docs/source/platypush/plugins/dbus.rst index 8eb7aecf..86be5704 100644 --- a/docs/source/platypush/plugins/dbus.rst +++ b/docs/source/platypush/plugins/dbus.rst @@ -2,4 +2,4 @@ ========================== .. automodule:: platypush.plugins.dbus - :members: + :exclude-members: DBusService, BusType diff --git a/docs/source/platypush/plugins/matrix.rst b/docs/source/platypush/plugins/matrix.rst new file mode 100644 index 00000000..fc01524c --- /dev/null +++ b/docs/source/platypush/plugins/matrix.rst @@ -0,0 +1,5 @@ +``matrix`` +========== + +.. automodule:: platypush.plugins.matrix + :members: MatrixPlugin diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 8c2f0a26..4eaff3e8 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -76,6 +76,7 @@ Plugins platypush/plugins/mail.smtp.rst platypush/plugins/mailgun.rst platypush/plugins/mastodon.rst + platypush/plugins/matrix.rst platypush/plugins/media.chromecast.rst platypush/plugins/media.gstreamer.rst platypush/plugins/media.jellyfin.rst diff --git a/platypush/__init__.py b/platypush/__init__.py index c50e7e94..f72e7e39 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -25,7 +25,7 @@ from .message.response import Response from .utils import set_thread_name, get_enabled_plugins __author__ = 'Fabio Manganiello ' -__version__ = '0.23.3' +__version__ = '0.23.4' logger = logging.getLogger('platypush') diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index d4c538cf..bc2bebd7 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -10,13 +10,13 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", - "core-js": "^3.21.1", + "core-js": "^3.23.4", "lato-font": "^3.0.0", "mitt": "^2.1.0", - "sass": "^1.49.9", - "sass-loader": "^10.2.1", + "sass": "^1.53.0", + "sass-loader": "^10.3.1", "vue": "^3.2.13", - "vue-router": "^4.0.14", + "vue-router": "^4.1.2", "vue-skycons": "^4.2.0", "w3css": "^2.7.0" }, @@ -1759,6 +1759,58 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz", + "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@node-ipc/js-queue": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", @@ -2768,9 +2820,9 @@ "dev": true }, "node_modules/@vue/devtools-api": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", - "integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz", + "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==" }, "node_modules/@vue/reactivity": { "version": "3.2.31", @@ -4205,9 +4257,9 @@ } }, "node_modules/core-js": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", - "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==", + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz", + "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -9402,9 +9454,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -9418,9 +9470,9 @@ } }, "node_modules/sass-loader": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", - "integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz", + "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==", "dependencies": { "klona": "^2.0.4", "loader-utils": "^2.0.0", @@ -9437,7 +9489,7 @@ }, "peerDependencies": { "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "sass": "^1.3.0", "webpack": "^4.36.0 || ^5.0.0" }, @@ -9710,9 +9762,9 @@ } }, "node_modules/shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", "dev": true }, "node_modules/signal-exit": { @@ -10223,13 +10275,13 @@ } }, "node_modules/terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "dependencies": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "bin": { @@ -10308,14 +10360,6 @@ "node": ">=0.4.0" } }, - "node_modules/terser/node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "engines": { - "node": ">= 8" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10825,11 +10869,11 @@ } }, "node_modules/vue-router": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", - "integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz", + "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==", "dependencies": { - "@vue/devtools-api": "^6.0.0" + "@vue/devtools-api": "^6.1.4" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -12938,6 +12982,49 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" + }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz", + "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==", + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "@node-ipc/js-queue": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@node-ipc/js-queue/-/js-queue-2.0.3.tgz", @@ -13770,9 +13857,9 @@ } }, "@vue/devtools-api": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.1.3.tgz", - "integrity": "sha512-79InfO2xHv+WHIrH1bHXQUiQD/wMls9qBk6WVwGCbdwP7/3zINtvqPNMtmSHXsIKjvUAHc8L0ouOj6ZQQRmcXg==" + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz", + "integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==" }, "@vue/reactivity": { "version": "3.2.31", @@ -14868,9 +14955,9 @@ } }, "core-js": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz", - "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==" + "version": "3.23.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.23.4.tgz", + "integrity": "sha512-vjsKqRc1RyAJC3Ye2kYqgfdThb3zYnx9CrqoCcjMOENMtQPC7ZViBvlDxwYU/2z2NI/IPuiXw5mT4hWhddqjzQ==" }, "core-js-compat": { "version": "3.21.1", @@ -18676,9 +18763,9 @@ "dev": true }, "sass": { - "version": "1.49.9", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.9.tgz", - "integrity": "sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.53.0.tgz", + "integrity": "sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ==", "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -18686,9 +18773,9 @@ } }, "sass-loader": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.2.1.tgz", - "integrity": "sha512-RRvWl+3K2LSMezIsd008ErK4rk6CulIMSwrcc2aZvjymUgKo/vjXGp1rSWmfTUX7bblEOz8tst4wBwWtCGBqKA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.3.1.tgz", + "integrity": "sha512-y2aBdtYkbqorVavkC3fcJIUDGIegzDWPn3/LAFhsf3G+MzPKTJx37sROf5pXtUeggSVbNbmfj8TgRaSLMelXRA==", "requires": { "klona": "^2.0.4", "loader-utils": "^2.0.0", @@ -18912,9 +18999,9 @@ "dev": true }, "shell-quote": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", - "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", "dev": true }, "signal-exit": { @@ -19314,13 +19401,13 @@ "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==" }, "terser": { - "version": "5.12.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.12.1.tgz", - "integrity": "sha512-NXbs+7nisos5E+yXwAD+y7zrcTkMqb0dEJxIGtSKPdCBzopf7ni4odPul2aechpV7EXNvOudYOX2bb5tln1jbQ==", + "version": "5.14.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz", + "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==", "requires": { + "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", - "source-map": "~0.7.2", "source-map-support": "~0.5.20" }, "dependencies": { @@ -19328,11 +19415,6 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz", "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==" - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" } } }, @@ -19746,11 +19828,11 @@ } }, "vue-router": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.0.14.tgz", - "integrity": "sha512-wAO6zF9zxA3u+7AkMPqw9LjoUCjSxfFvINQj3E/DceTt6uEz1XZLraDhdg2EYmvVwTBSGlLYsUw8bDmx0754Mw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.2.tgz", + "integrity": "sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==", "requires": { - "@vue/devtools-api": "^6.0.0" + "@vue/devtools-api": "^6.1.4" } }, "vue-skycons": { diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index d62b0659..a198cf8f 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -10,13 +10,13 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.1.1", "axios": "^0.21.4", - "core-js": "^3.21.1", + "core-js": "^3.23.4", "lato-font": "^3.0.0", "mitt": "^2.1.0", - "sass": "^1.49.9", - "sass-loader": "^10.2.1", + "sass": "^1.53.0", + "sass-loader": "^10.3.1", "vue": "^3.2.13", - "vue-router": "^4.0.14", + "vue-router": "^4.1.2", "vue-skycons": "^4.2.0", "w3css": "^2.7.0" }, diff --git a/platypush/config/__init__.py b/platypush/config/__init__.py index e9acfec4..481bd275 100644 --- a/platypush/config/__init__.py +++ b/platypush/config/__init__.py @@ -213,11 +213,10 @@ class Config: config['scripts_dir'] = os.path.abspath( os.path.expanduser(file_config[section]) ) - elif ( - 'disabled' not in file_config[section] - or file_config[section]['disabled'] is False - ): - config[section] = file_config[section] + else: + section_config = file_config.get(section, {}) or {} + if not section_config.get('disabled'): + config[section] = section_config return config diff --git a/platypush/message/event/__init__.py b/platypush/message/event/__init__.py index dc69214a..038f53e2 100644 --- a/platypush/message/event/__init__.py +++ b/platypush/message/event/__init__.py @@ -2,6 +2,7 @@ import copy import hashlib import json import re +import sys import time import uuid @@ -13,15 +14,23 @@ from platypush.utils import get_event_class_by_type class Event(Message): - """ Event message class """ + """Event message class""" # If this class property is set to false then the logging of these events # will be disabled. Logging is usually disabled for events with a very # high frequency that would otherwise pollute the logs e.g. camera capture # events # pylint: disable=redefined-builtin - def __init__(self, target=None, origin=None, id=None, timestamp=None, - disable_logging=False, disable_web_clients_notification=False, **kwargs): + def __init__( + self, + target=None, + origin=None, + id=None, + timestamp=None, + disable_logging=False, + disable_web_clients_notification=False, + **kwargs + ): """ Params: target -- Target node [String] @@ -34,22 +43,27 @@ class Event(Message): self.id = id if id else self._generate_id() self.target = target if target else Config.get('device_id') self.origin = origin if origin else Config.get('device_id') - self.type = '{}.{}'.format(self.__class__.__module__, - self.__class__.__name__) + self.type = '{}.{}'.format(self.__class__.__module__, self.__class__.__name__) self.args = kwargs self.disable_logging = disable_logging self.disable_web_clients_notification = disable_web_clients_notification for arg, value in self.args.items(): if arg not in [ - 'id', 'args', 'origin', 'target', 'type', 'timestamp', 'disable_logging' + 'id', + 'args', + 'origin', + 'target', + 'type', + 'timestamp', + 'disable_logging', ] and not arg.startswith('_'): self.__setattr__(arg, value) @classmethod def build(cls, msg): - """ Builds an event message from a JSON UTF-8 string/bytearray, a - dictionary, or another Event """ + """Builds an event message from a JSON UTF-8 string/bytearray, a + dictionary, or another Event""" msg = super().parse(msg) event_type = msg['args'].pop('type') @@ -64,8 +78,10 @@ class Event(Message): @staticmethod def _generate_id(): - """ Generate a unique event ID """ - return hashlib.md5(str(uuid.uuid1()).encode()).hexdigest() # lgtm [py/weak-sensitive-data-hashing] + """Generate a unique event ID""" + return hashlib.md5( + str(uuid.uuid1()).encode() + ).hexdigest() # lgtm [py/weak-sensitive-data-hashing] def matches_condition(self, condition): """ @@ -120,7 +136,13 @@ class Event(Message): """ result = EventMatchResult(is_match=False) - event_tokens = re.split(r'\s+', self.args[argname].strip().lower()) + if self.args.get(argname) == condition_value: + # In case of an exact match, return immediately + result.is_match = True + result.score = sys.maxsize + return result + + event_tokens = re.split(r'\s+', self.args.get(argname, '').strip().lower()) condition_tokens = re.split(r'\s+', condition_value.strip().lower()) while event_tokens and condition_tokens: @@ -148,9 +170,11 @@ class Event(Message): else: result.parsed_args[argname] += ' ' + event_token - if (len(condition_tokens) == 1 and len(event_tokens) == 1) \ - or (len(event_tokens) > 1 and len(condition_tokens) > 1 - and event_tokens[1] == condition_tokens[1]): + if (len(condition_tokens) == 1 and len(event_tokens) == 1) or ( + len(event_tokens) > 1 + and len(condition_tokens) > 1 + and event_tokens[1] == condition_tokens[1] + ): # Stop appending tokens to this argument, as the next # condition will be satisfied as well condition_tokens.pop(0) @@ -173,30 +197,30 @@ class Event(Message): args = copy.deepcopy(self.args) flatten(args) - return json.dumps({ - 'type': 'event', - 'target': self.target, - 'origin': self.origin if hasattr(self, 'origin') else None, - 'id': self.id if hasattr(self, 'id') else None, - '_timestamp': self.timestamp, - 'args': { - 'type': self.type, - **args + return json.dumps( + { + 'type': 'event', + 'target': self.target, + 'origin': self.origin if hasattr(self, 'origin') else None, + 'id': self.id if hasattr(self, 'id') else None, + '_timestamp': self.timestamp, + 'args': {'type': self.type, **args}, }, - }, cls=self.Encoder) + cls=self.Encoder, + ) -class EventMatchResult(object): - """ When comparing an event against an event condition, you want to - return this object. It contains the match status (True or False), - any parsed arguments, and a match_score that identifies how "strong" - the match is - in case of multiple event matches, the ones with the - highest score will win """ +class EventMatchResult: + """When comparing an event against an event condition, you want to + return this object. It contains the match status (True or False), + any parsed arguments, and a match_score that identifies how "strong" + the match is - in case of multiple event matches, the ones with the + highest score will win""" def __init__(self, is_match, score=0, parsed_args=None): self.is_match = is_match self.score = score - self.parsed_args = {} if not parsed_args else parsed_args + self.parsed_args = parsed_args or {} def flatten(args): @@ -213,4 +237,5 @@ def flatten(args): elif isinstance(arg, (dict, list)): flatten(args[i]) + # vim:sw=4:ts=4:et: diff --git a/platypush/message/event/matrix.py b/platypush/message/event/matrix.py new file mode 100644 index 00000000..04ed4976 --- /dev/null +++ b/platypush/message/event/matrix.py @@ -0,0 +1,250 @@ +from datetime import datetime +from typing import Dict, Any + +from platypush.message.event import Event + + +class MatrixEvent(Event): + """ + Base matrix event. + """ + + def __init__( + self, + *args, + server_url: str, + sender_id: str | None = None, + sender_display_name: str | None = None, + sender_avatar_url: str | None = None, + room_id: str | None = None, + room_name: str | None = None, + room_topic: str | None = None, + server_timestamp: datetime | None = None, + **kwargs + ): + """ + :param server_url: Base server URL. + :param sender_id: The event's sender ID. + :param sender_display_name: The event's sender display name. + :param sender_avatar_url: The event's sender avatar URL. + :param room_id: Event room ID. + :param room_name: The name of the room associated to the event. + :param room_topic: The topic of the room associated to the event. + :param server_timestamp: The server timestamp of the event. + """ + evt_args: Dict[str, Any] = { + 'server_url': server_url, + } + + if sender_id: + evt_args['sender_id'] = sender_id + if sender_display_name: + evt_args['sender_display_name'] = sender_display_name + if sender_avatar_url: + evt_args['sender_avatar_url'] = sender_avatar_url + if room_id: + evt_args['room_id'] = room_id + if room_name: + evt_args['room_name'] = room_name + if room_topic: + evt_args['room_topic'] = room_topic + if server_timestamp: + evt_args['server_timestamp'] = server_timestamp + + super().__init__(*args, **evt_args, **kwargs) + + +class MatrixSyncEvent(MatrixEvent): + """ + Event triggered when the startup synchronization has been completed and the + plugin is ready to use. + """ + + +class MatrixMessageEvent(MatrixEvent): + """ + Event triggered when a message is received on a subscribed room. + """ + + def __init__( + self, + *args, + body: str = '', + url: str | None = None, + thumbnail_url: str | None = None, + mimetype: str | None = None, + formatted_body: str | None = None, + format: str | None = None, + **kwargs + ): + """ + :param body: The body of the message. + :param url: The URL of the media file, if the message includes media. + :param thumbnail_url: The URL of the thumbnail, if the message includes media. + :param mimetype: The MIME type of the media file, if the message includes media. + :param formatted_body: The formatted body, if ``format`` is specified. + :param format: The format of the message (e.g. ``html`` or ``markdown``). + """ + super().__init__( + *args, + body=body, + url=url, + thumbnail_url=thumbnail_url, + mimetype=mimetype, + formatted_body=formatted_body, + format=format, + **kwargs + ) + + +class MatrixMessageImageEvent(MatrixEvent): + """ + Event triggered when a message containing an image is received. + """ + + +class MatrixMessageFileEvent(MatrixEvent): + """ + Event triggered when a message containing a generic file is received. + """ + + +class MatrixMessageAudioEvent(MatrixEvent): + """ + Event triggered when a message containing an audio file is received. + """ + + +class MatrixMessageVideoEvent(MatrixEvent): + """ + Event triggered when a message containing a video file is received. + """ + + +class MatrixReactionEvent(MatrixEvent): + """ + Event triggered when a user submits a reaction to an event. + """ + + def __init__(self, *args, in_response_to_event_id: str, **kwargs): + """ + :param in_response_to_event_id: The ID of the URL related to the reaction. + """ + super().__init__( + *args, in_response_to_event_id=in_response_to_event_id, **kwargs + ) + + +class MatrixEncryptedMessageEvent(MatrixMessageEvent): + """ + Event triggered when a message is received but the client doesn't + have the E2E keys to decrypt it, or encryption has not been enabled. + """ + + +class MatrixCallEvent(MatrixEvent): + """ + Base class for Matrix call events. + """ + + def __init__( + self, *args, call_id: str, version: int, sdp: str | None = None, **kwargs + ): + """ + :param call_id: The unique ID of the call. + :param version: An increasing integer representing the version of the call. + :param sdp: SDP text of the session description. + """ + super().__init__(*args, call_id=call_id, version=version, sdp=sdp, **kwargs) + + +class MatrixCallInviteEvent(MatrixCallEvent): + """ + Event triggered when the user is invited to a call. + """ + + def __init__(self, *args, invite_validity: float | None = None, **kwargs): + """ + :param invite_validity: For how long the invite will be valid, in seconds. + :param sdp: SDP text of the session description. + """ + super().__init__(*args, invite_validity=invite_validity, **kwargs) + + +class MatrixCallAnswerEvent(MatrixCallEvent): + """ + Event triggered by the callee when they wish to answer the call. + """ + + +class MatrixCallHangupEvent(MatrixCallEvent): + """ + Event triggered when a participant in the call exists. + """ + + +class MatrixRoomCreatedEvent(MatrixEvent): + """ + Event triggered when a room is created. + """ + + +class MatrixRoomJoinEvent(MatrixEvent): + """ + Event triggered when a user joins a room. + """ + + +class MatrixRoomLeaveEvent(MatrixEvent): + """ + Event triggered when a user leaves a room. + """ + + +class MatrixRoomInviteEvent(MatrixEvent): + """ + Event triggered when the user is invited to a room. + """ + + +class MatrixRoomTopicChangedEvent(MatrixEvent): + """ + Event triggered when the topic/title of a room changes. + """ + + def __init__(self, *args, topic: str, **kwargs): + """ + :param topic: New room topic. + """ + super().__init__(*args, topic=topic, **kwargs) + + +class MatrixRoomTypingStartEvent(MatrixEvent): + """ + Event triggered when a user in a room starts typing. + """ + + +class MatrixRoomTypingStopEvent(MatrixEvent): + """ + Event triggered when a user in a room stops typing. + """ + + +class MatrixRoomSeenReceiptEvent(MatrixEvent): + """ + Event triggered when the last message seen by a user in a room is updated. + """ + + +class MatrixUserPresenceEvent(MatrixEvent): + """ + Event triggered when a user comes online or goes offline. + """ + + def __init__(self, *args, is_active: bool, last_active: datetime | None, **kwargs): + """ + :param is_active: True if the user is currently online. + :param topic: When the user was last active. + """ + super().__init__(*args, is_active=is_active, last_active=last_active, **kwargs) diff --git a/platypush/message/event/websocket.py b/platypush/message/event/websocket.py new file mode 100644 index 00000000..4656c190 --- /dev/null +++ b/platypush/message/event/websocket.py @@ -0,0 +1,16 @@ +from typing import Any + +from platypush.message.event import Event + + +class WebsocketMessageEvent(Event): + """ + Event triggered when a message is receive on a subscribed websocket URL. + """ + + def __init__(self, *args, url: str, message: Any, **kwargs): + """ + :param url: Websocket URL. + :param message: The received message. + """ + super().__init__(*args, url=url, message=message, **kwargs) diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index 40d189d4..7b895fdf 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -12,17 +12,30 @@ from platypush.config import Config from platypush.context import get_plugin from platypush.message import Message from platypush.message.response import Response -from platypush.utils import get_hash, get_module_and_method_from_action, get_redis_queue_name_by_message, \ - is_functional_procedure +from platypush.utils import ( + get_hash, + get_module_and_method_from_action, + get_redis_queue_name_by_message, + is_functional_procedure, +) logger = logging.getLogger('platypush') class Request(Message): - """ Request message class """ + """Request message class""" - def __init__(self, target, action, origin=None, id=None, backend=None, - args=None, token=None, timestamp=None): + def __init__( + self, + target, + action, + origin=None, + id=None, + backend=None, + args=None, + token=None, + timestamp=None, + ): """ Params: target -- Target node [Str] @@ -48,9 +61,13 @@ class Request(Message): @classmethod def build(cls, msg): msg = super().parse(msg) - args = {'target': msg.get('target', Config.get('device_id')), 'action': msg['action'], - 'args': msg.get('args', {}), 'id': msg['id'] if 'id' in msg else cls._generate_id(), - 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time()} + args = { + 'target': msg.get('target', Config.get('device_id')), + 'action': msg['action'], + 'args': msg.get('args', {}), + 'id': msg['id'] if 'id' in msg else cls._generate_id(), + 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time(), + } if 'origin' in msg: args['origin'] = msg['origin'] @@ -61,7 +78,7 @@ class Request(Message): @staticmethod def _generate_id(): _id = '' - for i in range(0, 16): + for _ in range(0, 16): _id += '%.2x' % random.randint(0, 255) return _id @@ -84,9 +101,14 @@ class Request(Message): return proc_config(*args, **kwargs) - proc = Procedure.build(name=proc_name, requests=proc_config['actions'], - _async=proc_config['_async'], args=self.args, - backend=self.backend, id=self.id) + proc = Procedure.build( + name=proc_name, + requests=proc_config['actions'], + _async=proc_config['_async'], + args=self.args, + backend=self.backend, + id=self.id, + ) return proc.execute(*args, **kwargs) @@ -112,7 +134,7 @@ class Request(Message): if isinstance(value, str): value = self.expand_value_from_context(value, **context) - elif isinstance(value, dict) or isinstance(value, list): + elif isinstance(value, (dict, list)): self._expand_context(event_args=value, **context) event_args[key] = value @@ -132,7 +154,11 @@ class Request(Message): try: exec('{}="{}"'.format(k, re.sub(r'(^|[^\\])"', '\1\\"', v))) except Exception as e: - logger.debug('Could not set context variable {}={}: {}'.format(k, v, str(e))) + logger.debug( + 'Could not set context variable {}={}: {}'.format( + k, v, str(e) + ) + ) logger.debug('Context: {}'.format(context)) parsed_value = '' @@ -152,7 +178,7 @@ class Request(Message): if callable(context_value): context_value = context_value() - if isinstance(context_value, range) or isinstance(context_value, tuple): + if isinstance(context_value, (range, tuple)): context_value = [*context_value] if isinstance(context_value, datetime.date): context_value = context_value.isoformat() @@ -162,7 +188,7 @@ class Request(Message): parsed_value += prefix + ( json.dumps(context_value) - if isinstance(context_value, list) or isinstance(context_value, dict) + if isinstance(context_value, (list, dict)) else str(context_value) ) else: @@ -205,6 +231,9 @@ class Request(Message): """ def _thread_func(_n_tries, errors=None): + from platypush.context import get_bus + from platypush.plugins import RunnablePlugin + response = None try: @@ -221,11 +250,15 @@ class Request(Message): return response else: action = self.expand_value_from_context(self.action, **context) - (module_name, method_name) = get_module_and_method_from_action(action) + (module_name, method_name) = get_module_and_method_from_action( + action + ) plugin = get_plugin(module_name) except Exception as e: logger.exception(e) - msg = 'Uncaught pre-processing exception from action [{}]: {}'.format(self.action, str(e)) + msg = 'Uncaught pre-processing exception from action [{}]: {}'.format( + self.action, str(e) + ) logger.warning(msg) response = Response(output=None, errors=[msg]) self._send_response(response) @@ -243,24 +276,36 @@ class Request(Message): response = plugin.run(method_name, args) if not response: - logger.warning('Received null response from action {}'.format(action)) + logger.warning( + 'Received null response from action {}'.format(action) + ) else: if response.is_error(): - logger.warning(('Response processed with errors from ' + - 'action {}: {}').format( - action, str(response))) + logger.warning( + ( + 'Response processed with errors from ' + 'action {}: {}' + ).format(action, str(response)) + ) elif not response.disable_logging: - logger.info('Processed response from action {}: {}'. - format(action, str(response))) + logger.info( + 'Processed response from action {}: {}'.format( + action, str(response) + ) + ) except (AssertionError, TimeoutError) as e: - plugin.logger.exception(e) - logger.warning('{} from action [{}]: {}'.format(type(e), action, str(e))) + logger.warning( + '%s from action [%s]: %s', e.__class__.__name__, action, str(e) + ) response = Response(output=None, errors=[str(e)]) except Exception as e: # Retry mechanism plugin.logger.exception(e) - logger.warning(('Uncaught exception while processing response ' + - 'from action [{}]: {}').format(action, str(e))) + logger.warning( + ( + 'Uncaught exception while processing response ' + + 'from action [{}]: {}' + ).format(action, str(e)) + ) errors = errors or [] if str(e) not in errors: @@ -269,17 +314,21 @@ class Request(Message): response = Response(output=None, errors=errors) if _n_tries - 1 > 0: logger.info('Reloading plugin {} and retrying'.format(module_name)) - get_plugin(module_name, reload=True) - response = _thread_func(_n_tries=_n_tries-1, errors=errors) + plugin = get_plugin(module_name, reload=True) + if isinstance(plugin, RunnablePlugin): + plugin.bus = get_bus() + plugin.start() + + response = _thread_func(_n_tries=_n_tries - 1, errors=errors) finally: self._send_response(response) - return response - token_hash = Config.get('token_hash') + return response - if token_hash: - if self.token is None or get_hash(self.token) != token_hash: - raise PermissionError() + stored_token_hash = Config.get('token_hash') + token = getattr(self, 'token', '') + if stored_token_hash and get_hash(token) != stored_token_hash: + raise PermissionError() if _async: Thread(target=_thread_func, args=(n_tries,)).start() @@ -292,15 +341,18 @@ class Request(Message): the message into a UTF-8 JSON string """ - return json.dumps({ - 'type': 'request', - 'target': self.target, - 'action': self.action, - 'args': self.args, - 'origin': self.origin if hasattr(self, 'origin') else None, - 'id': self.id if hasattr(self, 'id') else None, - 'token': self.token if hasattr(self, 'token') else None, - '_timestamp': self.timestamp, - }) + return json.dumps( + { + 'type': 'request', + 'target': self.target, + 'action': self.action, + 'args': self.args, + 'origin': self.origin if hasattr(self, 'origin') else None, + 'id': self.id if hasattr(self, 'id') else None, + 'token': self.token if hasattr(self, 'token') else None, + '_timestamp': self.timestamp, + } + ) + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py index ff69c263..f35f06a4 100644 --- a/platypush/plugins/__init__.py +++ b/platypush/plugins/__init__.py @@ -1,7 +1,9 @@ +import asyncio import logging import threading import time +from abc import ABC, abstractmethod from functools import wraps from typing import Optional @@ -83,7 +85,7 @@ class RunnablePlugin(Plugin): return self._should_stop.is_set() def wait_stop(self, timeout=None): - return self._should_stop.wait(timeout) + return self._should_stop.wait(timeout=timeout) def start(self): set_thread_name(self.__class__.__name__) @@ -117,4 +119,74 @@ class RunnablePlugin(Plugin): self._thread = None +class AsyncRunnablePlugin(RunnablePlugin, ABC): + """ + Class for runnable plugins with an asynchronous event loop attached. + """ + + def __init__(self, *args, _stop_timeout: Optional[float] = 30.0, **kwargs): + super().__init__(*args, **kwargs) + + self._stop_timeout = _stop_timeout + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_runner: Optional[threading.Thread] = None + self._task: Optional[asyncio.Task] = None + + @property + def _should_start_runner(self): + return True + + @abstractmethod + async def listen(self): + pass + + async def _listen(self): + try: + await self.listen() + except KeyboardInterrupt: + pass + except RuntimeError as e: + if not ( + str(e).startswith('Event loop stopped before ') + or str(e).startswith('no running event loop') + ): + raise e + + def _start_listener(self): + set_thread_name(self.__class__.__name__ + ':listener') + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._task = self._loop.create_task(self._listen()) + self._task.set_name(self.__class__.__name__ + '.listen') + self._loop.run_forever() + + def main(self): + if self.should_stop() or (self._loop_runner and self._loop_runner.is_alive()): + self.logger.info('The main loop is already being run/stopped') + return + + if self._should_start_runner: + self._loop_runner = threading.Thread(target=self._start_listener) + self._loop_runner.start() + + self.wait_stop() + + def stop(self): + if self._task and self._loop and not self._task.done(): + self._loop.call_soon_threadsafe(self._task.cancel) + + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + self._loop = None + + if self._loop_runner and self._loop_runner.is_alive(): + try: + self._loop_runner.join(timeout=self._stop_timeout) + finally: + self._loop_runner = None + + super().stop() + + # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/__init__.py b/platypush/plugins/matrix/__init__.py new file mode 100644 index 00000000..205da044 --- /dev/null +++ b/platypush/plugins/matrix/__init__.py @@ -0,0 +1,863 @@ +import asyncio +import logging +import os +import pathlib +import re + +from dataclasses import dataclass +from typing import Collection, Coroutine, Sequence +from urllib.parse import urlparse + +from nio import ( + Api, + ErrorResponse, + MatrixRoom, + RoomMessage, +) + +from nio.api import MessageDirection, RoomVisibility + +from nio.crypto.device import OlmDevice +from nio.exceptions import OlmUnverifiedDeviceError + +from platypush.config import Config + +from platypush.plugins import AsyncRunnablePlugin, action +from platypush.schemas.matrix import ( + MatrixDeviceSchema, + MatrixDownloadedFileSchema, + MatrixEventIdSchema, + MatrixMemberSchema, + MatrixMessagesResponseSchema, + MatrixMyDeviceSchema, + MatrixProfileSchema, + MatrixRoomIdSchema, + MatrixRoomSchema, +) + +from platypush.utils import get_mime_type + +from .client import MatrixClient + +logger = logging.getLogger(__name__) + + +@dataclass +class Credentials: + server_url: str + user_id: str + access_token: str + device_id: str | None + + def to_dict(self) -> dict: + return { + 'server_url': self.server_url, + 'user_id': self.user_id, + 'access_token': self.access_token, + 'device_id': self.device_id, + } + + +class MatrixPlugin(AsyncRunnablePlugin): + """ + Matrix chat integration. + + Requires: + + * **matrix-nio** (``pip install 'matrix-nio[e2e]'``) + * **libolm** (on Debian ```apt-get install libolm-devel``, on Arch + ``pacman -S libolm``) + * **async_lru** (``pip install async_lru``) + + Note that ``libolm`` and the ``[e2e]`` module are only required if you want + E2E encryption support. + + Unless you configure the extension to use the token of an existing trusted + device, it is recommended that you mark the virtual device used by this + integration as trusted through a device that is already trusted. You may + encounter errors when sending or receiving messages on encrypted rooms if + your user has some untrusted devices. The easiest way to mark the device as + trusted is the following: + + - Configure the integration with your credentials and start Platypush. + - Use the same credentials to log in through a Matrix app or web client + (Element, Hydrogen, etc.) that has already been trusted. + - You should see a notification that prompts you to review the + untrusted devices logged in to your account. Dismiss it for now - + that verification path is currently broken on the underlying library + used by this integration. + - Instead, select a room that you have already joined, select the list + of users in the room and select yourself. + - In the _Security_ section, you should see that at least one device is + marked as unverified, and you can start the verification process by + clicking on it. + - Select "*Verify through emoji*". A list of emojis should be prompted. + Optionally, verify the logs of the application to check that you see + the same list. Then confirm that you see the same emojis, and your + device will be automatically marked as trusted. + + All the URLs returned by actions and events on this plugin are in the + ``mxc:///`` format. You can either convert them to HTTP + through the :meth:`.mxc_to_http` method, or download them through the + :meth:`.download` method. + + Triggers: + + * :class:`platypush.message.event.matrix.MatrixMessageEvent`: when a + message is received. + * :class:`platypush.message.event.matrix.MatrixMessageImageEvent`: when a + message containing an image is received. + * :class:`platypush.message.event.matrix.MatrixMessageAudioEvent`: when a + message containing an audio file is received. + * :class:`platypush.message.event.matrix.MatrixMessageVideoEvent`: when a + message containing a video file is received. + * :class:`platypush.message.event.matrix.MatrixMessageFileEvent`: when a + message containing a generic file is received. + * :class:`platypush.message.event.matrix.MatrixSyncEvent`: when the + startup synchronization has been completed and the plugin is ready to + use. + * :class:`platypush.message.event.matrix.MatrixRoomCreatedEvent`: when + a room is created. + * :class:`platypush.message.event.matrix.MatrixRoomJoinEvent`: when a + user joins a room. + * :class:`platypush.message.event.matrix.MatrixRoomLeaveEvent`: when a + user leaves a room. + * :class:`platypush.message.event.matrix.MatrixRoomInviteEvent`: when + the user is invited to a room. + * :class:`platypush.message.event.matrix.MatrixRoomTopicChangedEvent`: + when the topic/title of a room changes. + * :class:`platypush.message.event.matrix.MatrixCallInviteEvent`: when + the user is invited to a call. + * :class:`platypush.message.event.matrix.MatrixCallAnswerEvent`: when a + called user wishes to pick the call. + * :class:`platypush.message.event.matrix.MatrixCallHangupEvent`: when a + called user exits the call. + * :class:`platypush.message.event.matrix.MatrixEncryptedMessageEvent`: + when a message is received but the client doesn't have the E2E keys + to decrypt it, or encryption has not been enabled. + * :class:`platypush.message.event.matrix.MatrixRoomTypingStartEvent`: + when a user in a room starts typing. + * :class:`platypush.message.event.matrix.MatrixRoomTypingStopEvent`: + when a user in a room stops typing. + * :class:`platypush.message.event.matrix.MatrixRoomSeenReceiptEvent`: + when the last message seen by a user in a room is updated. + * :class:`platypush.message.event.matrix.MatrixUserPresenceEvent`: + when a user comes online or goes offline. + + """ + + def __init__( + self, + server_url: str = 'https://matrix-client.matrix.org', + user_id: str | None = None, + password: str | None = None, + access_token: str | None = None, + device_name: str | None = 'platypush', + device_id: str | None = None, + download_path: str | None = None, + autojoin_on_invite: bool = True, + autotrust_devices: bool = False, + autotrust_devices_whitelist: Collection[str] | None = None, + autotrust_users_whitelist: Collection[str] | None = None, + autotrust_rooms_whitelist: Collection[str] | None = None, + **kwargs, + ): + """ + Authentication requires user_id/password on the first login. + Afterwards, session credentials are stored under + ``<$PLATYPUSH_WORKDIR>/matrix/credentials.json`` (default: + ``~/.local/share/platypush/matrix/credentials.json``), and you can + remove the cleartext credentials from your configuration file. + + Otherwise, if you already have an ``access_token``, you can set the + associated field instead of using ``password``. This may be required if + the user has 2FA enabled. + + :param server_url: Default Matrix instance base URL (default: + ``https://matrix-client.matrix.org``). + :param user_id: user_id, in the format ``@user:example.org``, or just + the username if the account is hosted on the same server configured in + the ``server_url``. + :param password: User password. + :param access_token: User access token. + :param device_name: The name of this device/connection (default: ``platypush``). + :param device_id: Use an existing ``device_id`` for the sessions. + :param download_path: The folder where downloaded media will be saved + (default: ``~/Downloads``). + :param autojoin_on_invite: Whether the account should automatically + join rooms upon invite. If false, then you may want to implement your + own logic in an event hook when a + :class:`platypush.message.event.matrix.MatrixRoomInviteEvent` event is + received, and call the :meth:`.join` method if required. + :param autotrust_devices: If set to True, the plugin will automatically + trust the devices on encrypted rooms. Set this property to True + only if you only plan to use a bot on trusted rooms. Note that if + no automatic trust mechanism is set you may need to explicitly + create your logic for trusting users - either with a hook when + :class:`platypush.message.event.matrix.MatrixSyncEvent` is + received, or when a room is joined, or before sending a message. + :param autotrust_devices_whitelist: Automatically trust devices with IDs + IDs provided in this list. + :param autotrust_users_whitelist: Automatically trust devices from the + user IDs provided in this list. + :param autotrust_rooms_whitelist: Automatically trust devices on the + room IDs provided in this list. + """ + super().__init__(**kwargs) + if not (server_url.startswith('http://') or server_url.startswith('https://')): + server_url = f'https://{server_url}' + self._server_url = server_url + server_name = self._server_url.split('/')[2].split(':')[0] + + if user_id and not re.match(user_id, '^@[a-zA-Z0-9.-_]+:.+'): + user_id = f'@{user_id}:{server_name}' + + self._user_id = user_id + self._password = password + self._access_token = access_token + self._device_name = device_name + self._device_id = device_id + self._download_path = download_path or os.path.join( + os.path.expanduser('~'), 'Downloads' + ) + + self._autojoin_on_invite = autojoin_on_invite + self._autotrust_devices = autotrust_devices + self._autotrust_devices_whitelist = set(autotrust_devices_whitelist or []) + self._autotrust_users_whitelist = set(autotrust_users_whitelist or []) + self._autotrust_rooms_whitelist = set(autotrust_rooms_whitelist or []) + self._workdir = os.path.join(Config.get('workdir'), 'matrix') # type: ignore + self._credentials_file = os.path.join(self._workdir, 'credentials.json') + self._processed_responses = {} + self._client = self._get_client() + pathlib.Path(self._workdir).mkdir(parents=True, exist_ok=True) + + def _get_client(self) -> MatrixClient: + return MatrixClient( + homeserver=self._server_url, + user=self._user_id, + credentials_file=self._credentials_file, + autojoin_on_invite=self._autojoin_on_invite, + autotrust_devices=self._autotrust_devices, + autotrust_devices_whitelist=self._autotrust_devices_whitelist, + autotrust_rooms_whitelist=self._autotrust_rooms_whitelist, + autotrust_users_whitelist=self._autotrust_users_whitelist, + device_id=self._device_id, + ) + + @property + def client(self) -> MatrixClient: + if not self._client: + self._client = self._get_client() + return self._client + + async def _login(self) -> MatrixClient: + await self.client.login( + password=self._password, + device_name=self._device_name, + token=self._access_token, + ) + + return self.client + + async def listen(self): + while not self.should_stop(): + await self._login() + + try: + await self.client.sync_forever(timeout=30000, full_state=True) + except KeyboardInterrupt: + pass + except Exception as e: + self.logger.exception(e) + self.logger.info('Waiting 10 seconds before reconnecting') + await asyncio.sleep(10) + finally: + try: + await self.client.close() + finally: + self._client = None + + def _loop_execute(self, coro: Coroutine): + assert self._loop, 'The loop is not running' + + try: + ret = asyncio.run_coroutine_threadsafe(coro, self._loop).result() + except OlmUnverifiedDeviceError as e: + raise AssertionError(str(e)) + + assert not isinstance(ret, ErrorResponse), ret.message + if hasattr(ret, 'transport_response'): + response = ret.transport_response + assert response.ok, f'{coro} failed with status {response.status}' + + return ret + + def _process_local_attachment(self, attachment: str, room_id: str) -> dict: + attachment = os.path.expanduser(attachment) + assert os.path.isfile(attachment), f'{attachment} is not a valid file' + + filename = os.path.basename(attachment) + mime_type = get_mime_type(attachment) or 'application/octet-stream' + message_type = mime_type.split('/')[0] + if message_type not in {'audio', 'video', 'image'}: + message_type = 'text' + + encrypted = self.get_room(room_id).output.get('encrypted', False) # type: ignore + url = self.upload( + attachment, name=filename, content_type=mime_type, encrypt=encrypted + ).output # type: ignore + + return { + 'url': url, + 'msgtype': 'm.' + message_type, + 'body': filename, + 'info': { + 'size': os.stat(attachment).st_size, + 'mimetype': mime_type, + }, + } + + def _process_remote_attachment(self, attachment: str) -> dict: + parsed_url = urlparse(attachment) + server = parsed_url.netloc.strip('/') + media_id = parsed_url.path.strip('/') + + response = self._loop_execute(self.client.download(server, media_id)) + + content_type = response.content_type + message_type = content_type.split('/')[0] + if message_type not in {'audio', 'video', 'image'}: + message_type = 'text' + + return { + 'url': attachment, + 'msgtype': 'm.' + message_type, + 'body': response.filename, + 'info': { + 'size': len(response.body), + 'mimetype': content_type, + }, + } + + def _process_attachment(self, attachment: str, room_id: str): + if attachment.startswith('mxc://'): + return self._process_remote_attachment(attachment) + return self._process_local_attachment(attachment, room_id=room_id) + + @action + def send_message( + self, + room_id: str, + message_type: str = 'text', + body: str | None = None, + attachment: str | None = None, + tx_id: str | None = None, + ignore_unverified_devices: bool = False, + ): + """ + Send a message to a room. + + :param room_id: Room ID. + :param body: Message body. + :param attachment: Path to a local file to send as an attachment, or + URL of an existing Matrix media ID in the format + ``mxc:///``. If the attachment is a local file, + the file will be automatically uploaded, ``message_type`` will be + automatically inferred from the file and the ``body`` will be + replaced by the filename. + :param message_type: Message type. Supported: `text`, `audio`, `video`, + `image`. Default: `text`. + :param tx_id: Unique transaction ID to associate to this message. + :param ignore_unverified_devices: If true, unverified devices will be + ignored. Otherwise, if the room is encrypted and it contains + devices that haven't been marked as trusted, the message + delivery may fail (default: False). + :return: .. schema:: matrix.MatrixEventIdSchema + """ + content = { + 'msgtype': 'm.' + message_type, + 'body': body, + } + + if attachment: + content.update(self._process_attachment(attachment, room_id=room_id)) + + ret = self._loop_execute( + self.client.room_send( + message_type='m.room.message', + room_id=room_id, + tx_id=tx_id, + ignore_unverified_devices=ignore_unverified_devices, + content=content, + ) + ) + + ret = self._loop_execute(ret.transport_response.json()) + return MatrixEventIdSchema().dump(ret) + + @action + def get_profile(self, user_id: str): + """ + Retrieve the details about a user. + + :param user_id: User ID. + :return: .. schema:: matrix.MatrixProfileSchema + """ + profile = self._loop_execute(self.client.get_profile(user_id)) # type: ignore + profile.user_id = user_id + return MatrixProfileSchema().dump(profile) + + @action + def get_room(self, room_id: str): + """ + Retrieve the details about a room. + + :param room_id: room ID. + :return: .. schema:: matrix.MatrixRoomSchema + """ + response = self._loop_execute(self.client.room_get_state(room_id)) # type: ignore + room_args = {'room_id': room_id, 'own_user_id': None, 'encrypted': False} + room_params = {} + + for evt in response.events: + if evt.get('type') == 'm.room.create': + room_args['own_user_id'] = evt.get('content', {}).get('creator') + elif evt.get('type') == 'm.room.encryption': + room_args['encrypted'] = True + elif evt.get('type') == 'm.room.name': + room_params['name'] = evt.get('content', {}).get('name') + elif evt.get('type') == 'm.room.topic': + room_params['topic'] = evt.get('content', {}).get('topic') + + room = MatrixRoom(**room_args) + for k, v in room_params.items(): + setattr(room, k, v) + return MatrixRoomSchema().dump(room) + + @action + def get_messages( + self, + room_id: str, + start: str | None = None, + end: str | None = None, + backwards: bool = True, + limit: int = 10, + ): + """ + Retrieve a list of messages from a room. + + :param room_id: Room ID. + :param start: Start retrieving messages from this batch ID (default: + latest batch returned from a call to ``sync``). + :param end: Retrieving messages until this batch ID. + :param backwards: Set to True if you want to retrieve messages starting + from the most recent, in descending order (default). Otherwise, the + first returned message will be the oldest and messages will be + returned in ascending order. + :param limit: Maximum number of messages to be returned (default: 10). + :return: .. schema:: matrix.MatrixMessagesResponseSchema + """ + response = self._loop_execute( + self.client.room_messages( + room_id, + start=start, + end=end, + limit=limit, + direction=( + MessageDirection.back if backwards else MessageDirection.front + ), + ) + ) + + response.chunk = [m for m in response.chunk if isinstance(m, RoomMessage)] + return MatrixMessagesResponseSchema().dump(response) + + @action + def get_my_devices(self): + """ + Get the list of devices associated to the current user. + + :return: .. schema:: matrix.MatrixMyDeviceSchema(many=True) + """ + response = self._loop_execute(self.client.devices()) + return MatrixMyDeviceSchema().dump(response.devices, many=True) + + @action + def get_device(self, device_id: str): + """ + Get the info about a device given its ID. + + :return: .. schema:: matrix.MatrixDeviceSchema + """ + return MatrixDeviceSchema().dump(self._get_device(device_id)) + + @action + def update_device(self, device_id: str, display_name: str | None = None): + """ + Update information about a user's device. + + :param display_name: New display name. + :return: .. schema:: matrix.MatrixDeviceSchema + """ + content = {} + if display_name: + content['display_name'] = display_name + + self._loop_execute(self.client.update_device(device_id, content)) + return MatrixDeviceSchema().dump(self._get_device(device_id)) + + @action + def delete_devices( + self, + devices: Sequence[str], + username: str | None = None, + password: str | None = None, + ): + """ + Delete a list of devices from the user's authorized list and invalidate + their access tokens. + + :param devices: List of devices that should be deleted. + :param username: Username, if the server requires authentication upon + device deletion. + :param password: User password, if the server requires authentication + upon device deletion. + """ + auth = {} + if username and password: + auth = {'type': 'm.login.password', 'user': username, 'password': password} + + self._loop_execute(self.client.delete_devices([*devices], auth=auth)) + + @action + def get_joined_rooms(self): + """ + Retrieve the rooms that the user has joined. + + :return: .. schema:: matrix.MatrixRoomSchema(many=True) + """ + response = self._loop_execute(self.client.joined_rooms()) + return [self.get_room(room_id).output for room_id in response.rooms] # type: ignore + + @action + def get_room_members(self, room_id: str): + """ + Retrieve the list of users joined into a room. + + :param room_id: The room ID. + :return: .. schema:: matrix.MatrixMemberSchema(many=True) + """ + response = self._loop_execute(self.client.joined_members(room_id)) + return MatrixMemberSchema().dump(response.members, many=True) + + @action + def room_alias_to_id(self, alias: str) -> str: + """ + Convert a room alias (in the format ``#alias:matrix.example.org``) to a + room ID (in the format ``!aBcDeFgHiJkMnO:matrix.example.org``). + + :param alias: The room alias. + :return: The room ID, as a string. + """ + response = self._loop_execute(self.client.room_resolve_alias(alias)) + return response.room_id + + @action + def add_room_alias(self, room_id: str, alias: str): + """ + Add an alias to a room. + + :param room_id: An existing room ID. + :param alias: The room alias. + """ + self._loop_execute(self.client.room_put_alias(alias, room_id)) + + @action + def delete_room_alias(self, alias: str): + """ + Delete a room alias. + + :param alias: The room alias. + """ + self._loop_execute(self.client.room_delete_alias(alias)) + + @action + def upload_keys(self): + """ + Synchronize the E2EE keys with the homeserver. + """ + self._loop_execute(self.client.keys_upload()) + + def _get_device(self, device_id: str) -> OlmDevice: + device = self.client.get_device(device_id) + assert device, f'No such device_id: {device_id}' + return device + + @action + def trust_device(self, device_id: str): + """ + Mark a device as trusted. + + :param device_id: Device ID. + """ + device = self._get_device(device_id) + self.client.verify_device(device) + + @action + def untrust_device(self, device_id: str): + """ + Mark a device as untrusted. + + :param device_id: Device ID. + """ + device = self._get_device(device_id) + self.client.unverify_device(device) + + @action + def mxc_to_http(self, url: str, homeserver: str | None = None) -> str: + """ + Convert a Matrix URL (in the format ``mxc://server/media_id``) to an + HTTP URL. + + Note that invoking this function on a URL containing encrypted content + (i.e. a URL containing media sent to an encrypted room) will provide a + URL that points to encrypted content. The best way to deal with + encrypted media is by using :meth:`.download` to download the media + locally. + + :param url: The MXC URL to be converted. + :param homeserver: The hosting homeserver (default: the same as the URL). + :return: The converted HTTP(s) URL. + """ + http_url = Api.mxc_to_http(url, homeserver=homeserver) + assert http_url, f'Could not convert URL {url}' + return http_url + + @action + def download( + self, + url: str, + download_path: str | None = None, + filename: str | None = None, + allow_remote=True, + ): + """ + Download a file given its Matrix URL. + + Note that URLs that point to encrypted resources will be automatically + decrypted only if they were received on a room joined by this account. + + :param url: Matrix URL, in the format ``mxc:///``. + :param download_path: Override the default ``download_path`` (output + directory for the downloaded file). + :param filename: Name of the output file (default: inferred from the + remote resource). + :param allow_remote: Indicates to the server that it should not attempt + to fetch the media if it is deemed remote. This is to prevent + routing loops where the server contacts itself. + :return: .. schema:: matrix.MatrixDownloadedFileSchema + """ + parsed_url = urlparse(url) + server = parsed_url.netloc.strip('/') + media_id = parsed_url.path.strip('/') + + response = self._loop_execute( + self.client.download( + server, media_id, filename=filename, allow_remote=allow_remote + ) + ) + + if not download_path: + download_path = self._download_path + if not filename: + filename = response.filename or media_id + + outfile = os.path.join(str(download_path), str(filename)) + pathlib.Path(download_path).mkdir(parents=True, exist_ok=True) + + with open(outfile, 'wb') as f: + f.write(response.body) + + return MatrixDownloadedFileSchema().dump( + { + 'url': url, + 'path': outfile, + 'size': len(response.body), + 'content_type': response.content_type, + } + ) + + @action + def upload( + self, + file: str, + name: str | None = None, + content_type: str | None = None, + encrypt: bool = False, + ) -> str: + """ + Upload a file to the server. + + :param file: Path to the file to upload. + :param name: Filename to be used for the remote file (default: same as + the local file). + :param content_type: Specify a content type for the file (default: + inferred from the file's extension and content). + :param encrypt: Encrypt the file (default: False). + :return: The Matrix URL of the uploaded resource. + """ + rs = self._loop_execute( + self.client.upload_file(file, name, content_type, encrypt) + ) + + return rs[0].content_uri + + @action + def create_room( + self, + name: str | None = None, + alias: str | None = None, + topic: str | None = None, + is_public: bool = False, + is_direct: bool = False, + federate: bool = True, + encrypted: bool = False, + invite_users: Sequence[str] = (), + ): + """ + Create a new room on the server. + + :param name: Room name. + :param alias: Custom alias for the canonical name. For example, if set + to ``foo``, the alias for this room will be + ``#foo:matrix.example.org``. + :param topic: Room topic. + :param is_public: Set to True if you want the room to be public and + discoverable (default: False). + :param is_direct: Set to True if this should be considered a direct + room with only one user (default: False). + :param federate: Whether you want to allow users from other servers to + join the room (default: True). + :param encrypted: Whether the room should be encrypted (default: False). + :param invite_users: A list of user IDs to invite to the room. + :return: .. schema:: matrix.MatrixRoomIdSchema + """ + rs = self._loop_execute( + self.client.room_create( + name=name, + alias=alias, + topic=topic, + is_direct=is_direct, + federate=federate, + invite=invite_users, + visibility=( + RoomVisibility.public if is_public else RoomVisibility.private + ), + initial_state=[ + { + 'type': 'm.room.encryption', + 'content': { + 'algorithm': 'm.megolm.v1.aes-sha2', + }, + } + ] + if encrypted + else (), + ) + ) + + return MatrixRoomIdSchema().dump(rs) + + @action + def invite(self, room_id: str, user_id: str): + """ + Invite a user to a room. + + :param room_id: Room ID. + :param user_id: User ID. + """ + self._loop_execute(self.client.room_invite(room_id, user_id)) + + @action + def kick(self, room_id: str, user_id: str, reason: str | None = None): + """ + Kick a user out of a room. + + :param room_id: Room ID. + :param user_id: User ID. + :param reason: Optional reason. + """ + self._loop_execute(self.client.room_kick(room_id, user_id, reason)) + + @action + def ban(self, room_id: str, user_id: str, reason: str | None = None): + """ + Ban a user from a room. + + :param room_id: Room ID. + :param user_id: User ID. + :param reason: Optional reason. + """ + self._loop_execute(self.client.room_ban(room_id, user_id, reason)) + + @action + def unban(self, room_id: str, user_id: str): + """ + Remove a user ban from a room. + + :param room_id: Room ID. + :param user_id: User ID. + """ + self._loop_execute(self.client.room_unban(room_id, user_id)) + + @action + def join(self, room_id: str): + """ + Join a room. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.join(room_id)) + + @action + def leave(self, room_id: str): + """ + Leave a joined room. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.room_leave(room_id)) + + @action + def forget(self, room_id: str): + """ + Leave a joined room and forget its data as well as all the messages. + + If all the users leave a room, that room will be marked for deletion by + the homeserver. + + :param room_id: Room ID. + """ + self._loop_execute(self.client.room_forget(room_id)) + + @action + def set_display_name(self, display_name: str): + """ + Set/change the display name for the current user. + + :param display_name: New display name. + """ + self._loop_execute(self.client.set_displayname(display_name)) + + @action + def set_avatar(self, url: str): + """ + Set/change the avatar URL for the current user. + + :param url: New avatar URL. It must be a valid ``mxc://`` link. + """ + self._loop_execute(self.client.set_avatar(url)) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/client.py b/platypush/plugins/matrix/client.py new file mode 100644 index 00000000..1cf957e7 --- /dev/null +++ b/platypush/plugins/matrix/client.py @@ -0,0 +1,856 @@ +import asyncio +import datetime +import json +import logging +import os +import pathlib +import threading + +from dataclasses import dataclass +from typing import Collection, Dict, Optional, Union +from urllib.parse import urlparse + +from async_lru import alru_cache +from nio import ( + AsyncClient, + AsyncClientConfig, + CallAnswerEvent, + CallHangupEvent, + CallInviteEvent, + Event, + InviteEvent, + KeyVerificationStart, + KeyVerificationAccept, + KeyVerificationMac, + KeyVerificationKey, + KeyVerificationCancel, + LocalProtocolError, + LoginResponse, + MatrixRoom, + MegolmEvent, + ProfileGetResponse, + RoomCreateEvent, + RoomEncryptedAudio, + RoomEncryptedFile, + RoomEncryptedImage, + RoomEncryptedMedia, + RoomEncryptedVideo, + RoomGetEventError, + RoomGetStateResponse, + RoomMemberEvent, + RoomMessageAudio, + RoomMessageFile, + RoomMessageFormatted, + RoomMessageText, + RoomMessageImage, + RoomMessageMedia, + RoomMessageVideo, + RoomTopicEvent, + RoomUpgradeEvent, + StickerEvent, + SyncResponse, + ToDeviceError, + UnknownEncryptedEvent, + UnknownEvent, +) + +import aiofiles +import aiofiles.os + +from nio.client.async_client import client_session +from nio.client.base_client import logged_in +from nio.crypto import decrypt_attachment +from nio.crypto.device import OlmDevice +from nio.events.ephemeral import ReceiptEvent, TypingNoticeEvent +from nio.events.presence import PresenceEvent +from nio.responses import DownloadResponse, RoomMessagesResponse + +from platypush.config import Config +from platypush.context import get_bus +from platypush.message.event.matrix import ( + MatrixCallAnswerEvent, + MatrixCallHangupEvent, + MatrixCallInviteEvent, + MatrixEncryptedMessageEvent, + MatrixMessageAudioEvent, + MatrixMessageEvent, + MatrixMessageFileEvent, + MatrixMessageImageEvent, + MatrixMessageVideoEvent, + MatrixReactionEvent, + MatrixRoomCreatedEvent, + MatrixRoomInviteEvent, + MatrixRoomJoinEvent, + MatrixRoomLeaveEvent, + MatrixRoomSeenReceiptEvent, + MatrixRoomTopicChangedEvent, + MatrixRoomTypingStartEvent, + MatrixRoomTypingStopEvent, + MatrixSyncEvent, + MatrixUserPresenceEvent, +) + +from platypush.utils import get_mime_type + +logger = logging.getLogger(__name__) + + +@dataclass +class Credentials: + server_url: str + user_id: str + access_token: str + device_id: str | None + + def to_dict(self) -> dict: + return { + 'server_url': self.server_url, + 'user_id': self.user_id, + 'access_token': self.access_token, + 'device_id': self.device_id, + } + + +class MatrixClient(AsyncClient): + def __init__( + self, + *args, + credentials_file: str, + store_path: str | None = None, + config: Optional[AsyncClientConfig] = None, + autojoin_on_invite=True, + autotrust_devices=False, + autotrust_devices_whitelist: Collection[str] | None = None, + autotrust_rooms_whitelist: Collection[str] | None = None, + autotrust_users_whitelist: Collection[str] | None = None, + **kwargs, + ): + credentials_file = os.path.abspath(os.path.expanduser(credentials_file)) + + if not store_path: + store_path = os.path.join(Config.get('workdir'), 'matrix', 'store') # type: ignore + + assert store_path + store_path = os.path.abspath(os.path.expanduser(store_path)) + pathlib.Path(store_path).mkdir(exist_ok=True, parents=True) + + if not config: + config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + + super().__init__(*args, config=config, store_path=store_path, **kwargs) + self.logger = logging.getLogger(self.__class__.__name__) + self._credentials_file = credentials_file + self._autojoin_on_invite = autojoin_on_invite + self._autotrust_devices = autotrust_devices + self._autotrust_devices_whitelist = autotrust_devices_whitelist + self._autotrust_rooms_whitelist = autotrust_rooms_whitelist or set() + self._autotrust_users_whitelist = autotrust_users_whitelist or set() + self._first_sync_performed = asyncio.Event() + self._last_batches_by_room = {} + self._typing_users_by_room = {} + + self._encrypted_attachments_keystore_path = os.path.join( + store_path, 'attachment_keys.json' + ) + self._encrypted_attachments_keystore = {} + self._sync_store_timer: threading.Timer | None = None + keystore = {} + + try: + with open(self._encrypted_attachments_keystore_path, 'r') as f: + keystore = json.load(f) + except (ValueError, OSError): + with open(self._encrypted_attachments_keystore_path, 'w') as f: + f.write(json.dumps({})) + + pathlib.Path(self._encrypted_attachments_keystore_path).touch( + mode=0o600, exist_ok=True + ) + + self._encrypted_attachments_keystore = { + tuple(key.split('|')): data for key, data in keystore.items() + } + + async def _autojoin_room_callback(self, room: MatrixRoom, *_): + await self.join(room.room_id) # type: ignore + + def _load_from_file(self): + if not os.path.isfile(self._credentials_file): + return + + try: + with open(self._credentials_file, 'r') as f: + credentials = json.load(f) + except json.JSONDecodeError: + self.logger.warning( + 'Could not read credentials_file %s - overwriting it', + self._credentials_file, + ) + return + + assert credentials.get('user_id'), 'Missing user_id' + assert credentials.get('access_token'), 'Missing access_token' + + self.access_token = credentials['access_token'] + self.user_id = credentials['user_id'] + self.homeserver = credentials.get('server_url', self.homeserver) + if credentials.get('device_id'): + self.device_id = credentials['device_id'] + + self.load_store() + + async def login( + self, + password: str | None = None, + device_name: str | None = None, + token: str | None = None, + ) -> LoginResponse: + self._load_from_file() + login_res = None + + if self.access_token: + self.load_store() + self.logger.info( + 'Logged in to %s as %s using the stored access token', + self.homeserver, + self.user_id, + ) + + login_res = LoginResponse( + user_id=self.user_id, + device_id=self.device_id, + access_token=self.access_token, + ) + else: + assert self.user, 'No credentials file found and no user provided' + login_args = {'device_name': device_name} + if token: + login_args['token'] = token + else: + assert ( + password + ), 'No credentials file found and no password nor access token provided' + login_args['password'] = password + + login_res = await super().login(**login_args) + assert isinstance(login_res, LoginResponse), f'Failed to login: {login_res}' + self.logger.info(login_res) + + credentials = Credentials( + server_url=self.homeserver, + user_id=login_res.user_id, + access_token=login_res.access_token, + device_id=login_res.device_id, + ) + + with open(self._credentials_file, 'w') as f: + json.dump(credentials.to_dict(), f) + os.chmod(self._credentials_file, 0o600) + + if self.should_upload_keys: + self.logger.info('Uploading encryption keys') + await self.keys_upload() + + self.logger.info('Synchronizing state') + self._first_sync_performed.clear() + self._add_callbacks() + sync_token = self.loaded_sync_token + self.loaded_sync_token = '' + await self.sync(sync_filter={'room': {'timeline': {'limit': 1}}}) + self.loaded_sync_token = sync_token + + self._sync_devices_trust() + self._first_sync_performed.set() + + get_bus().post(MatrixSyncEvent(server_url=self.homeserver)) + self.logger.info('State synchronized') + return login_res + + @logged_in + async def sync(self, *args, **kwargs) -> SyncResponse: + response = await super().sync(*args, **kwargs) + assert isinstance(response, SyncResponse), str(response) + self._last_batches_by_room.update( + { + room_id: { + 'prev_batch': room.timeline.prev_batch, + 'next_batch': response.next_batch, + } + for room_id, room in response.rooms.join.items() + } + ) + + return response + + @logged_in + async def room_messages( + self, room_id: str, start: str | None = None, *args, **kwargs + ) -> RoomMessagesResponse: + if not start: + start = self._last_batches_by_room.get(room_id, {}).get('prev_batch') + assert start, ( + f'No sync batches were found for room {room_id} and no start' + 'batch has been provided' + ) + + response = await super().room_messages(room_id, start, *args, **kwargs) + assert isinstance(response, RoomMessagesResponse), str(response) + return response + + def _sync_devices_trust(self): + all_devices = self.get_devices() + devices_to_trust: Dict[str, OlmDevice] = {} + untrusted_devices = { + device_id: device + for device_id, device in all_devices.items() + if not device.verified + } + + if self._autotrust_devices: + devices_to_trust.update(untrusted_devices) + else: + if self._autotrust_devices_whitelist: + devices_to_trust.update( + { + device_id: device + for device_id, device in all_devices.items() + if device_id in self._autotrust_devices_whitelist + and device_id in untrusted_devices + } + ) + if self._autotrust_rooms_whitelist: + devices_to_trust.update( + { + device_id: device + for room_id, devices in self.get_devices_by_room().items() + for device_id, device in devices.items() # type: ignore + if room_id in self._autotrust_rooms_whitelist + and device_id in untrusted_devices + } + ) + if self._autotrust_users_whitelist: + devices_to_trust.update( + { + device_id: device + for user_id, devices in self.get_devices_by_user().items() + for device_id, device in devices.items() # type: ignore + if user_id in self._autotrust_users_whitelist + and device_id in untrusted_devices + } + ) + + for device in devices_to_trust.values(): + self.verify_device(device) + self.logger.info( + 'Device %s by user %s added to the whitelist', device.id, device.user_id + ) + + def get_devices_by_user( + self, user_id: str | None = None + ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: + devices = {user: devices for user, devices in self.device_store.items()} + + if user_id: + devices = devices.get(user_id, {}) + return devices + + def get_devices(self) -> Dict[str, OlmDevice]: + return { + device_id: device + for _, devices in self.device_store.items() + for device_id, device in devices.items() + } + + def get_device(self, device_id: str) -> Optional[OlmDevice]: + return self.get_devices().get(device_id) + + def get_devices_by_room( + self, room_id: str | None = None + ) -> Dict[str, Dict[str, OlmDevice]] | Dict[str, OlmDevice]: + devices = { + room_id: { + device_id: device + for _, devices in self.room_devices(room_id).items() + for device_id, device in devices.items() + } + for room_id in self.rooms.keys() + } + + if room_id: + devices = devices.get(room_id, {}) + return devices + + def _add_callbacks(self): + self.add_event_callback(self._event_catch_all, Event) + self.add_event_callback(self._on_invite, InviteEvent) # type: ignore + self.add_event_callback(self._on_message, RoomMessageText) # type: ignore + self.add_event_callback(self._on_message, RoomMessageMedia) # type: ignore + self.add_event_callback(self._on_message, RoomEncryptedMedia) # type: ignore + self.add_event_callback(self._on_message, StickerEvent) # type: ignore + self.add_event_callback(self._on_room_member, RoomMemberEvent) # type: ignore + self.add_event_callback(self._on_room_topic_changed, RoomTopicEvent) # type: ignore + self.add_event_callback(self._on_call_invite, CallInviteEvent) # type: ignore + self.add_event_callback(self._on_call_answer, CallAnswerEvent) # type: ignore + self.add_event_callback(self._on_call_hangup, CallHangupEvent) # type: ignore + self.add_event_callback(self._on_unknown_event, UnknownEvent) # type: ignore + self.add_event_callback(self._on_unknown_encrypted_event, UnknownEncryptedEvent) # type: ignore + self.add_event_callback(self._on_unknown_encrypted_event, MegolmEvent) # type: ignore + self.add_to_device_callback(self._on_key_verification_start, KeyVerificationStart) # type: ignore + self.add_to_device_callback(self._on_key_verification_cancel, KeyVerificationCancel) # type: ignore + self.add_to_device_callback(self._on_key_verification_key, KeyVerificationKey) # type: ignore + self.add_to_device_callback(self._on_key_verification_mac, KeyVerificationMac) # type: ignore + self.add_to_device_callback(self._on_key_verification_accept, KeyVerificationAccept) # type: ignore + self.add_ephemeral_callback(self._on_typing, TypingNoticeEvent) # type: ignore + self.add_ephemeral_callback(self._on_receipt, ReceiptEvent) # type: ignore + self.add_presence_callback(self._on_presence, PresenceEvent) # type: ignore + + if self._autojoin_on_invite: + self.add_event_callback(self._autojoin_room_callback, InviteEvent) # type: ignore + + def _sync_store(self): + self.logger.info('Synchronizing keystore') + serialized_keystore = json.dumps( + { + f'{server}|{media_id}': data + for ( + server, + media_id, + ), data in self._encrypted_attachments_keystore.items() + } + ) + + try: + with open(self._encrypted_attachments_keystore_path, 'w') as f: + f.write(serialized_keystore) + finally: + self._sync_store_timer = None + + @alru_cache(maxsize=500) + @client_session + async def get_profile(self, user_id: str | None = None) -> ProfileGetResponse: + """ + Cached version of get_profile. + """ + ret = await super().get_profile(user_id) + assert isinstance( + ret, ProfileGetResponse + ), f'Could not retrieve profile for user {user_id}: {ret.message}' + return ret + + @alru_cache(maxsize=500) + @client_session + async def room_get_state(self, room_id: str) -> RoomGetStateResponse: + """ + Cached version of room_get_state. + """ + ret = await super().room_get_state(room_id) + assert isinstance( + ret, RoomGetStateResponse + ), f'Could not retrieve profile for room {room_id}: {ret.message}' + return ret + + @client_session + async def download( + self, + server_name: str, + media_id: str, + filename: str | None = None, + allow_remote: bool = True, + ): + response = await super().download( + server_name, media_id, filename, allow_remote=allow_remote + ) + + assert isinstance( + response, DownloadResponse + ), f'Could not download media {media_id}: {response}' + + encryption_data = self._encrypted_attachments_keystore.get( + (server_name, media_id) + ) + if encryption_data: + self.logger.info('Decrypting media %s using the available keys', media_id) + response.filename = encryption_data.get('body', response.filename) + response.content_type = encryption_data.get( + 'mimetype', response.content_type + ) + response.body = decrypt_attachment( + response.body, + key=encryption_data.get('key'), + hash=encryption_data.get('hash'), + iv=encryption_data.get('iv'), + ) + + return response + + async def _event_base_args( + self, room: Optional[MatrixRoom], event: Optional[Event] = None + ) -> dict: + sender_id = getattr(event, 'sender', None) + sender = ( + await self.get_profile(sender_id) if sender_id else None # type: ignore + ) + + return { + 'server_url': self.homeserver, + 'sender_id': sender_id, + 'sender_display_name': sender.displayname if sender else None, + 'sender_avatar_url': sender.avatar_url if sender else None, + **( + { + 'room_id': room.room_id, + 'room_name': room.name, + 'room_topic': room.topic, + } + if room + else {} + ), + 'server_timestamp': ( + datetime.datetime.fromtimestamp(event.server_timestamp / 1000) + if event and getattr(event, 'server_timestamp', None) + else None + ), + } + + async def _event_catch_all(self, room: MatrixRoom, event: Event): + self.logger.debug('Received event on room %s: %r', room.room_id, event) + + async def _on_invite(self, room: MatrixRoom, event: RoomMessageText): + get_bus().post( + MatrixRoomInviteEvent( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_message( + self, + room: MatrixRoom, + event: Union[ + RoomMessageText, RoomMessageMedia, RoomEncryptedMedia, StickerEvent + ], + ): + if self._first_sync_performed.is_set(): + evt_type = MatrixMessageEvent + evt_args = { + 'body': event.body, + 'url': getattr(event, 'url', None), + **(await self._event_base_args(room, event)), + } + + if isinstance(event, (RoomMessageMedia, RoomEncryptedMedia, StickerEvent)): + evt_args['url'] = event.url + + if isinstance(event, RoomEncryptedMedia): + evt_args['thumbnail_url'] = event.thumbnail_url + evt_args['mimetype'] = event.mimetype + self._store_encrypted_media_keys(event) + if isinstance(event, RoomMessageFormatted): + evt_args['format'] = event.format + evt_args['formatted_body'] = event.formatted_body + + if isinstance(event, (RoomMessageImage, RoomEncryptedImage)): + evt_type = MatrixMessageImageEvent + elif isinstance(event, (RoomMessageAudio, RoomEncryptedAudio)): + evt_type = MatrixMessageAudioEvent + elif isinstance(event, (RoomMessageVideo, RoomEncryptedVideo)): + evt_type = MatrixMessageVideoEvent + elif isinstance(event, (RoomMessageFile, RoomEncryptedFile)): + evt_type = MatrixMessageFileEvent + + get_bus().post(evt_type(**evt_args)) + + def _store_encrypted_media_keys(self, event: RoomEncryptedMedia): + url = event.url.strip('/') + parsed_url = urlparse(url) + homeserver = parsed_url.netloc.strip('/') + media_key = (homeserver, parsed_url.path.strip('/')) + + self._encrypted_attachments_keystore[media_key] = { + 'url': url, + 'body': event.body, + 'key': event.key['k'], + 'hash': event.hashes['sha256'], + 'iv': event.iv, + 'homeserver': homeserver, + 'mimetype': event.mimetype, + } + + if not self._sync_store_timer: + self._sync_store_timer = threading.Timer(5, self._sync_store) + self._sync_store_timer.start() + + async def _on_room_member(self, room: MatrixRoom, event: RoomMemberEvent): + evt_type = None + if event.membership == 'join': + evt_type = MatrixRoomJoinEvent + elif event.membership == 'leave': + evt_type = MatrixRoomLeaveEvent + + if evt_type and self._first_sync_performed.is_set(): + get_bus().post( + evt_type( + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_topic_changed(self, room: MatrixRoom, event: RoomTopicEvent): + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixRoomTopicChangedEvent( + **(await self._event_base_args(room, event)), + topic=event.topic, + ) + ) + + async def _on_call_invite(self, room: MatrixRoom, event: CallInviteEvent): + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallInviteEvent( + call_id=event.call_id, + version=event.version, + invite_validity=event.lifetime / 1000.0, + sdp=event.offer.get('sdp'), + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_call_answer(self, room: MatrixRoom, event: CallAnswerEvent): + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallAnswerEvent( + call_id=event.call_id, + version=event.version, + sdp=event.answer.get('sdp'), + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_call_hangup(self, room: MatrixRoom, event: CallHangupEvent): + if self._first_sync_performed.is_set(): + get_bus().post( + MatrixCallHangupEvent( + call_id=event.call_id, + version=event.version, + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_room_created(self, room: MatrixRoom, event: RoomCreateEvent): + get_bus().post( + MatrixRoomCreatedEvent( + **(await self._event_base_args(room, event)), + ) + ) + + def _get_sas(self, event): + sas = self.key_verifications.get(event.transaction_id) + if not sas: + self.logger.debug( + 'Received a key verification event with no associated transaction ID' + ) + + return sas + + async def _on_key_verification_start(self, event: KeyVerificationStart): + self.logger.info(f'Received a key verification request from {event.sender}') + + if 'emoji' not in event.short_authentication_string: + self.logger.warning( + 'Only emoji verification is supported, but the verifying device ' + 'provided the following authentication methods: %r', + event.short_authentication_string, + ) + return + + sas = self._get_sas(event) + if not sas: + return + + rs = await self.accept_key_verification(sas.transaction_id) + assert not isinstance( + rs, ToDeviceError + ), f'accept_key_verification failed: {rs}' + + rs = await self.to_device(sas.share_key()) + assert not isinstance(rs, ToDeviceError), f'Shared key exchange failed: {rs}' + + async def _on_key_verification_accept(self, event: KeyVerificationAccept): + self.logger.info('Key verification from device %s accepted', event.sender) + + async def _on_key_verification_cancel(self, event: KeyVerificationCancel): + self.logger.info( + 'The device %s cancelled a key verification request. ' 'Reason: %s', + event.sender, + event.reason, + ) + + async def _on_key_verification_key(self, event: KeyVerificationKey): + sas = self._get_sas(event) + if not sas: + return + + self.logger.info( + 'Received emoji verification from device %s: %s', + event.sender, + sas.get_emoji(), + ) + + rs = await self.confirm_short_auth_string(sas.transaction_id) + assert not isinstance( + rs, ToDeviceError + ), f'confirm_short_auth_string failed: {rs}' + + async def _on_key_verification_mac(self, event: KeyVerificationMac): + self.logger.info('Received MAC verification request from %s', event.sender) + sas = self._get_sas(event) + if not sas: + return + + try: + mac = sas.get_mac() + except LocalProtocolError as e: + self.logger.warning( + 'Verification from %s cancelled or unexpected protocol error. ' + 'Reason: %s', + e, + event.sender, + ) + return + + rs = await self.to_device(mac) + assert not isinstance( + rs, ToDeviceError + ), f'Sending of the verification MAC to {event.sender} failed: {rs}' + + self.logger.info('This device has been successfully verified!') + + async def _on_room_upgrade(self, room: MatrixRoom, event: RoomUpgradeEvent): + self.logger.info( + 'The room %s has been moved to %s', room.room_id, event.replacement_room + ) + + await self.room_leave(room.room_id) + await self.join(event.replacement_room) + + async def _on_typing(self, room: MatrixRoom, event: TypingNoticeEvent): + users = set(event.users) + typing_users = self._typing_users_by_room.get(room.room_id, set()) + start_typing_users = users.difference(typing_users) + stop_typing_users = typing_users.difference(users) + + for user in start_typing_users: + event.sender = user # type: ignore + get_bus().post( + MatrixRoomTypingStartEvent( + **(await self._event_base_args(room, event)), # type: ignore + sender=user, + ) + ) + + for user in stop_typing_users: + event.sender = user # type: ignore + get_bus().post( + MatrixRoomTypingStopEvent( + **(await self._event_base_args(room, event)), # type: ignore + ) + ) + + self._typing_users_by_room[room.room_id] = users + + async def _on_receipt(self, room: MatrixRoom, event: ReceiptEvent): + if self._first_sync_performed.is_set(): + for receipt in event.receipts: + event.sender = receipt.user_id # type: ignore + get_bus().post( + MatrixRoomSeenReceiptEvent( + **(await self._event_base_args(room, event)), # type: ignore + ) + ) + + async def _on_presence(self, event: PresenceEvent): + if self._first_sync_performed.is_set(): + last_active = ( + ( + datetime.datetime.now() + - datetime.timedelta(seconds=event.last_active_ago / 1000) + ) + if event.last_active_ago + else None + ) + + event.sender = event.user_id # type: ignore + get_bus().post( + MatrixUserPresenceEvent( + **(await self._event_base_args(None, event)), # type: ignore + is_active=event.currently_active or False, + last_active=last_active, + ) + ) + + async def _on_unknown_encrypted_event( + self, room: MatrixRoom, event: Union[UnknownEncryptedEvent, MegolmEvent] + ): + if self._first_sync_performed.is_set(): + body = getattr(event, 'ciphertext', '') + get_bus().post( + MatrixEncryptedMessageEvent( + body=body, + **(await self._event_base_args(room, event)), + ) + ) + + async def _on_unknown_event(self, room: MatrixRoom, event: UnknownEvent): + evt = None + + if event.type == 'm.reaction' and self._first_sync_performed.is_set(): + # Get the ID of the event this was a reaction to + relation_dict = event.source.get('content', {}).get('m.relates_to', {}) + reacted_to = relation_dict.get('event_id') + if reacted_to and relation_dict.get('rel_type') == 'm.annotation': + event_response = await self.room_get_event(room.room_id, reacted_to) + + if isinstance(event_response, RoomGetEventError): + self.logger.warning( + 'Error getting event that was reacted to (%s)', reacted_to + ) + else: + evt = MatrixReactionEvent( + in_response_to_event_id=event_response.event.event_id, + **(await self._event_base_args(room, event)), + ) + + if evt: + get_bus().post(evt) + else: + self.logger.info( + 'Received an unknown event on room %s: %r', room.room_id, event + ) + + async def upload_file( + self, + file: str, + name: Optional[str] = None, + content_type: Optional[str] = None, + encrypt: bool = False, + ): + file = os.path.expanduser(file) + file_stat = await aiofiles.os.stat(file) + + async with aiofiles.open(file, 'rb') as f: + return await super().upload( + f, # type: ignore + content_type=( + content_type or get_mime_type(file) or 'application/octet-stream' + ), + filename=name or os.path.basename(file), + encrypt=encrypt, + filesize=file_stat.st_size, + ) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/matrix/manifest.yaml b/platypush/plugins/matrix/manifest.yaml new file mode 100644 index 00000000..b3482052 --- /dev/null +++ b/platypush/plugins/matrix/manifest.yaml @@ -0,0 +1,50 @@ +manifest: + events: + platypush.message.event.matrix.MatrixMessageEvent: when a message is + received. + platypush.message.event.matrix.MatrixMessageImageEvent: when a message + containing an image is received. + platypush.message.event.matrix.MatrixMessageAudioEvent: when a message + containing an audio file is received. + platypush.message.event.matrix.MatrixMessageVideoEvent: when a message + containing a video file is received. + platypush.message.event.matrix.MatrixMessageFileEvent: when a message + containing a generic file is received. + platypush.message.event.matrix.MatrixSyncEvent: when the startup + synchronization has been completed and the plugin is ready to use. + platypush.message.event.matrix.MatrixRoomCreatedEvent: when a room is + created. + platypush.message.event.matrix.MatrixRoomJoinEvent: when a user joins a + room. + platypush.message.event.matrix.MatrixRoomLeaveEvent: when a user leaves a + room. + platypush.message.event.matrix.MatrixRoomInviteEvent: when the user is + invited to a room. + platypush.message.event.matrix.MatrixRoomTopicChangedEvent: when the + topic/title of a room changes. + platypush.message.event.matrix.MatrixCallInviteEvent: when the user is + invited to a call. + platypush.message.event.matrix.MatrixCallAnswerEvent: when a called user + wishes to pick the call. + platypush.message.event.matrix.MatrixCallHangupEvent: when a called user + exits the call. + platypush.message.event.matrix.MatrixEncryptedMessageEvent: when a message + is received but the client doesn't have the E2E keys to decrypt it, or + encryption has not been enabled. + platypush.message.event.matrix.MatrixRoomTypingStartEvent: when a user in a + room starts typing. + platypush.message.event.matrix.MatrixRoomTypingStopEvent: when a user in a + room stops typing. + platypush.message.event.matrix.MatrixRoomSeenReceiptEvent: when the last + message seen by a user in a room is updated. + platypush.message.event.matrix.MatrixUserPresenceEvent: when a user comes + online or goes offline. + apt: + - libolm-devel + pacman: + - libolm + pip: + - matrix-nio[e2e] + - async_lru + package: platypush.plugins.matrix + type: plugin diff --git a/platypush/plugins/ntfy/__init__.py b/platypush/plugins/ntfy/__init__.py index 9684b2b1..a24b46a5 100644 --- a/platypush/plugins/ntfy/__init__.py +++ b/platypush/plugins/ntfy/__init__.py @@ -1,20 +1,18 @@ import asyncio import json -import multiprocessing import os -import time from typing import Optional, Collection, Mapping import requests import websockets +import websockets.exceptions from platypush.context import get_bus from platypush.message.event.ntfy import NotificationEvent -from platypush.plugins import RunnablePlugin, action -from platypush.context import get_or_create_event_loop +from platypush.plugins import AsyncRunnablePlugin, action -class NtfyPlugin(RunnablePlugin): +class NtfyPlugin(AsyncRunnablePlugin): """ Ntfy integration. @@ -48,27 +46,17 @@ class NtfyPlugin(RunnablePlugin): ] ) - self._event_loop: Optional[asyncio.AbstractEventLoop] = None self._subscriptions = subscriptions or [] - self._ws_proc = None - - def _connect(self): - if self.should_stop() or (self._ws_proc and self._ws_proc.is_alive()): - self.logger.debug('Already connected') - return - - self._ws_proc = multiprocessing.Process(target=self._ws_process) - self._ws_proc.start() async def _get_ws_handler(self, url): reconnect_wait_secs = 1 reconnect_wait_secs_max = 60 - while True: + while not self.should_stop(): self.logger.debug(f'Connecting to {url}') try: - async with websockets.connect(url) as ws: + async with websockets.connect(url) as ws: # type: ignore reconnect_wait_secs = 1 self.logger.info(f'Connected to {url}') async for msg in ws: @@ -99,39 +87,22 @@ class NtfyPlugin(RunnablePlugin): ) except websockets.exceptions.WebSocketException as e: self.logger.error('Websocket error: %s', e) - time.sleep(reconnect_wait_secs) + await asyncio.sleep(reconnect_wait_secs) reconnect_wait_secs = min( reconnect_wait_secs * 2, reconnect_wait_secs_max ) - async def _ws_processor(self, urls): - await asyncio.wait([self._get_ws_handler(url) for url in urls]) + async def listen(self): + return await asyncio.wait( + [ + self._get_ws_handler(f'{self._ws_url}/{sub}/ws') + for sub in set(self._subscriptions) + ] + ) - def _ws_process(self): - self._event_loop = get_or_create_event_loop() - try: - self._event_loop.run_until_complete( - self._ws_processor( - {f'{self._ws_url}/{sub}/ws' for sub in self._subscriptions} - ) - ) - except KeyboardInterrupt: - pass - - def main(self): - if self._subscriptions: - self._connect() - - while not self._should_stop.is_set(): - self._should_stop.wait(timeout=1) - - def stop(self): - if self._ws_proc: - self._ws_proc.kill() - self._ws_proc.join() - self._ws_proc = None - - super().stop() + @property + def _should_start_runner(self): + return bool(self._subscriptions) @action def send_message( @@ -226,7 +197,6 @@ class NtfyPlugin(RunnablePlugin): """ method = requests.post - click_url = url url = server_url or self._server_url args = {} if username and password: diff --git a/platypush/plugins/websocket/__init__.py b/platypush/plugins/websocket/__init__.py index 1cfa7c0e..5744c075 100644 --- a/platypush/plugins/websocket/__init__.py +++ b/platypush/plugins/websocket/__init__.py @@ -1,75 +1,219 @@ +import asyncio import json +import time -try: - from websockets.exceptions import ConnectionClosed - from websockets import connect as websocket_connect -except ImportError: - from websockets import ConnectionClosed, connect as websocket_connect +from typing import Optional, Collection -from platypush.context import get_or_create_event_loop -from platypush.message import Message -from platypush.plugins import Plugin, action +from websockets import connect as websocket_connect # type: ignore +from websockets.exceptions import ConnectionClosed + +from platypush.context import get_bus +from platypush.message.event.websocket import WebsocketMessageEvent +from platypush.plugins import AsyncRunnablePlugin, action from platypush.utils import get_ssl_client_context -class WebsocketPlugin(Plugin): +class WebsocketPlugin(AsyncRunnablePlugin): """ - Plugin to send messages over a websocket connection. + Plugin to send and receive messages over websocket connections. + + Triggers: + + * :class:`platypush.message.event.websocket.WebsocketMessageEvent` when + a message is received on a subscribed websocket. + """ - def __init__(self, **kwargs): + def __init__(self, subscriptions: Optional[Collection[str]] = None, **kwargs): + """ + :param subscriptions: List of websocket URLs that should be subscribed + at startup, prefixed by ``ws://`` or ``wss://``. + """ super().__init__(**kwargs) + self._subscriptions = subscriptions or [] + + @property + def loop(self): + if not self._loop: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + return self._loop @action - def send(self, url, msg, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None): + def send( + self, + url: str, + msg, + ssl_cert=None, + ssl_key=None, + ssl_cafile=None, + ssl_capath=None, + wait_response=False, + ): """ Sends a message to a websocket. :param url: Websocket URL, e.g. ws://localhost:8765 or wss://localhost:8765 - :type url: str - :param msg: Message to be sent. It can be a list, a dict, or a Message object - - :param ssl_cert: Path to the SSL certificate to be used, if the SSL connection requires client authentication - as well (default: None) :type ssl_cert: str - - :param ssl_key: Path to the SSL key to be used, if the SSL connection requires client authentication as well - (default: None) :type ssl_key: str - - :param ssl_cafile: Path to the certificate authority file if required by the SSL configuration (default: None) - :type ssl_cafile: str - - :param ssl_capath: Path to the certificate authority directory if required by the SSL configuration - (default: None) - :type ssl_capath: str + :param ssl_cert: Path to the SSL certificate to be used, if the SSL + connection requires client authentication as well (default: None) + :param ssl_key: Path to the SSL key to be used, if the SSL connection + requires client authentication as well (default: None) + :param ssl_cafile: Path to the certificate authority file if required + by the SSL configuration (default: None) + :param ssl_capath: Path to the certificate authority directory if + required by the SSL configuration (default: None) + :param wait_response: Set to True if you expect a response to the + delivered message. + :return: The received response if ``wait_response`` is set to True, + otherwise nothing. """ + msg = self._parse_msg(msg) + async def send(): - websocket_args = {} - if ssl_cert: - websocket_args['ssl'] = get_ssl_client_context(ssl_cert=ssl_cert, - ssl_key=ssl_key, - ssl_cafile=ssl_cafile, - ssl_capath=ssl_capath) + websocket_args = { + 'ssl': self._get_ssl_context( + url, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + } - async with websocket_connect(url, **websocket_args) as websocket: + async with websocket_connect(url, **websocket_args) as ws: try: - await websocket.send(str(msg)) + await ws.send(str(msg)) except ConnectionClosed as err: - self.logger.warning('Error on websocket {}: {}'. - format(url, err)) + self.logger.warning('Error on websocket %s: %s', url, err) + if wait_response: + messages = await self._recv(ws, num_messages=1) + if messages: + return self._parse_msg(messages[0]) + + return asyncio.run_coroutine_threadsafe(send(), self.loop).result() + + @action + def recv( + self, + url: str, + ssl_cert=None, + ssl_key=None, + ssl_cafile=None, + ssl_capath=None, + num_messages=0, + timeout=0, + ): + """ + Receive one or more messages from a websocket. + + A :class:`platypush.message.event.websocket.WebsocketMessageEvent` + event will be triggered whenever a new message is received. + + :param url: Websocket URL, e.g. ws://localhost:8765 or wss://localhost:8765 + :param ssl_cert: Path to the SSL certificate to be used, if the SSL + connection requires client authentication as well (default: None) + :param ssl_key: Path to the SSL key to be used, if the SSL connection + requires client authentication as well (default: None) + :param ssl_cafile: Path to the certificate authority file if required + by the SSL configuration (default: None) + :param ssl_capath: Path to the certificate authority directory if + required by the SSL configuration (default: None) + :param num_messages: Exit after receiving this number of messages. + Default: 0, receive forever. + :param timeout: Message receive timeout in seconds. Default: 0 - no timeout. + :return: A list with the messages that have been received, unless + ``num_messages`` is set to 0 or ``None``. + """ + + async def recv(): + websocket_args = { + 'ssl': self._get_ssl_context( + url, + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + } + + async with websocket_connect(url, **websocket_args) as ws: + return await self._recv(ws, timeout=timeout, num_messages=num_messages) + + return self.loop.call_soon_threadsafe(recv) + + async def _recv(self, ws, timeout=0, num_messages=0): + messages = [] + time_start = time.time() + time_end = time_start + timeout if timeout else 0 + url = 'ws{secure}://{host}:{port}{path}'.format( + secure='s' if ws._secure else '', + host=ws.remote_address[0], + port=ws.remote_address[1], + path=ws.path, + ) + + while (not num_messages) or (len(messages) < num_messages): + msg = None + err = None + remaining_timeout = time_end - time.time() if time_end else None + + try: + msg = await asyncio.wait_for(ws.recv(), remaining_timeout) + except (ConnectionClosed, asyncio.exceptions.TimeoutError) as e: + err = e + self.logger.warning('Error on websocket %s: %s', url, e) + + if isinstance(err, ConnectionClosed) or ( + time_end and time.time() > time_end + ): + break + + if msg is None: + continue + + msg = self._parse_msg(msg) + messages.append(msg) + get_bus().post(WebsocketMessageEvent(url=url, message=msg)) + + return messages + + @property + def _should_start_runner(self): + return bool(self._subscriptions) + + @staticmethod + def _parse_msg(msg): try: msg = json.dumps(msg) - except Exception as e: - self.logger.debug(e) + except Exception: + pass - try: - msg = Message.build(json.loads(msg)) - except Exception as e: - self.logger.debug(e) + return msg + + async def listen(self): + async def _recv(url): + async with websocket_connect(url) as ws: + return await self._recv(ws) + + await asyncio.wait([_recv(url) for url in set(self._subscriptions)]) + + @staticmethod + def _get_ssl_context( + url: str, ssl_cert=None, ssl_key=None, ssl_cafile=None, ssl_capath=None + ): + if url.startswith('wss://') or url.startswith('https://'): + return get_ssl_client_context( + ssl_cert=ssl_cert, + ssl_key=ssl_key, + ssl_cafile=ssl_cafile, + ssl_capath=ssl_capath, + ) + + return None - loop = get_or_create_event_loop() - loop.run_until_complete(send()) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/websocket/manifest.yaml b/platypush/plugins/websocket/manifest.yaml index 8c95c60e..973fde3f 100644 --- a/platypush/plugins/websocket/manifest.yaml +++ b/platypush/plugins/websocket/manifest.yaml @@ -1,5 +1,7 @@ manifest: - events: {} + events: + platypush.message.event.websocket.WebsocketMessageEvent: when a message is + received on a subscribed websocket. install: pip: [] package: platypush.plugins.websocket diff --git a/platypush/schemas/__init__.py b/platypush/schemas/__init__.py index ca280562..ce157319 100644 --- a/platypush/schemas/__init__.py +++ b/platypush/schemas/__init__.py @@ -6,7 +6,7 @@ from dateutil.tz import tzutc from marshmallow import fields -class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] +class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): kwargs['serialize'] = self._strip kwargs['deserialize'] = self._strip @@ -21,7 +21,17 @@ class StrippedString(fields.Function): # lgtm [py/missing-call-to-init] return value.strip() -class DateTime(fields.Function): # lgtm [py/missing-call-to-init] +class Function(fields.Function): # lgtm [py/missing-call-to-init] + def _get_attr(self, obj, attr: str, _recursive=True): + if self.attribute and _recursive: + return self._get_attr(obj, self.attribute, False) + if hasattr(obj, attr): + return getattr(obj, attr) + elif hasattr(obj, 'get'): + return obj.get(attr) + + +class DateTime(Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metadata = { @@ -30,7 +40,7 @@ class DateTime(fields.Function): # lgtm [py/missing-call-to-init] } def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]: - value = normalize_datetime(obj.get(attr)) + value = normalize_datetime(self._get_attr(obj, attr)) if value: return value.isoformat() @@ -38,7 +48,7 @@ class DateTime(fields.Function): # lgtm [py/missing-call-to-init] return normalize_datetime(value) -class Date(fields.Function): # lgtm [py/missing-call-to-init] +class Date(Function): # lgtm [py/missing-call-to-init] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metadata = { @@ -47,7 +57,7 @@ class Date(fields.Function): # lgtm [py/missing-call-to-init] } def _serialize(self, value, attr, obj, **kwargs) -> Optional[str]: - value = normalize_datetime(obj.get(attr)) + value = normalize_datetime(self._get_attr(obj, attr)) if value: return date(value.year, value.month, value.day).isoformat() @@ -56,10 +66,12 @@ class Date(fields.Function): # lgtm [py/missing-call-to-init] return date.fromtimestamp(dt.timestamp()) -def normalize_datetime(dt: Union[str, date, datetime]) -> Optional[Union[date, datetime]]: +def normalize_datetime( + dt: Optional[Union[str, date, datetime]] +) -> Optional[Union[date, datetime]]: if not dt: return - if isinstance(dt, datetime) or isinstance(dt, date): + if isinstance(dt, (datetime, date)): return dt try: diff --git a/platypush/schemas/matrix.py b/platypush/schemas/matrix.py new file mode 100644 index 00000000..1f4c2ba7 --- /dev/null +++ b/platypush/schemas/matrix.py @@ -0,0 +1,385 @@ +from marshmallow import fields +from marshmallow.schema import Schema + +from platypush.schemas import DateTime + + +class MillisecondsTimestamp(DateTime): + def _get_attr(self, *args, **kwargs): + value = super()._get_attr(*args, **kwargs) + if isinstance(value, int): + value = float(value / 1000) + return value + + +class MatrixEventIdSchema(Schema): + event_id = fields.String( + required=True, + metadata={ + 'description': 'Event ID', + 'example': '$24KT_aQz6sSKaZH8oTCibRTl62qywDgQXMpz5epXsW5', + }, + ) + + +class MatrixRoomIdSchema(Schema): + room_id = fields.String( + required=True, + metadata={ + 'description': 'Room ID', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + +class MatrixProfileSchema(Schema): + user_id = fields.String( + required=True, + metadata={ + 'description': 'User ID', + 'example': '@myuser:matrix.example.org', + }, + ) + + display_name = fields.String( + attribute='displayname', + metadata={ + 'description': 'User display name', + 'example': 'Foo Bar', + }, + ) + + avatar_url = fields.URL( + metadata={ + 'description': 'User avatar URL', + 'example': 'mxc://matrix.example.org/AbCdEfG0123456789', + } + ) + + +class MatrixMemberSchema(MatrixProfileSchema): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['display_name'].attribute = 'display_name' + + +class MatrixRoomSchema(Schema): + room_id = fields.String( + required=True, + metadata={ + 'description': 'Room ID', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + name = fields.String( + metadata={ + 'description': 'Room name', + 'example': 'My Room', + } + ) + + display_name = fields.String( + metadata={ + 'description': 'Room display name', + 'example': 'My Room', + } + ) + + topic = fields.String( + metadata={ + 'description': 'Room topic', + 'example': 'My Room Topic', + } + ) + + avatar_url = fields.URL( + attribute='room_avatar_url', + metadata={ + 'description': 'Room avatar URL', + 'example': 'mxc://matrix.example.org/AbCdEfG0123456789', + }, + ) + + owner_id = fields.String( + attribute='own_user_id', + metadata={ + 'description': 'Owner user ID', + 'example': '@myuser:matrix.example.org', + }, + ) + + encrypted = fields.Bool() + + +class MatrixDeviceSchema(Schema): + device_id = fields.String( + required=True, + attribute='id', + metadata={ + 'description': 'ABCDEFG', + }, + ) + + user_id = fields.String( + required=True, + metadata={ + 'description': 'User ID associated to the device', + 'example': '@myuser:matrix.example.org', + }, + ) + + display_name = fields.String( + metadata={ + 'description': 'Display name of the device', + 'example': 'Element Android', + }, + ) + + blacklisted = fields.Boolean() + deleted = fields.Boolean(default=False) + ignored = fields.Boolean() + verified = fields.Boolean() + + keys = fields.Dict( + metadata={ + 'description': 'Encryption keys supported by the device', + 'example': { + 'curve25519': 'BtlB0vaQmtYFsvOYkmxyzw9qP5yGjuAyRh4gXh3q', + 'ed25519': 'atohIK2FeVlYoY8xxpZ1bhDbveD+HA2DswNFqUxP', + }, + }, + ) + + +class MatrixMyDeviceSchema(Schema): + device_id = fields.String( + required=True, + attribute='id', + metadata={ + 'description': 'ABCDEFG', + }, + ) + + display_name = fields.String( + metadata={ + 'description': 'Device display name', + 'example': 'My Device', + } + ) + + last_seen_ip = fields.String( + metadata={ + 'description': 'Last IP associated to this device', + 'example': '1.2.3.4', + } + ) + + last_seen_date = DateTime( + metadata={ + 'description': 'The last time that the device was reported online', + 'example': '2022-07-23T17:20:01.254223', + } + ) + + +class MatrixDownloadedFileSchema(Schema): + url = fields.String( + metadata={ + 'description': 'Matrix URL of the original resource', + 'example': 'mxc://matrix.example.org/YhQycHvFOvtiDDbEeWWtEhXx', + }, + ) + + path = fields.String( + metadata={ + 'description': 'Local path where the file has been saved', + 'example': '/home/user/Downloads/image.png', + } + ) + + content_type = fields.String( + metadata={ + 'description': 'Content type of the downloaded file', + 'example': 'image/png', + } + ) + + size = fields.Int( + metadata={ + 'description': 'Length in bytes of the output file', + 'example': 1024, + } + ) + + +class MatrixMessageSchema(Schema): + event_id = fields.String( + required=True, + metadata={ + 'description': 'Event ID associated to this message', + 'example': '$2eOQ5ueafANj91GnPCRkRUOOjM7dI5kFDOlfMNCD2ly', + }, + ) + + room_id = fields.String( + required=True, + metadata={ + 'description': 'The ID of the room containing the message', + 'example': '!aBcDeFgHiJkMnO:matrix.example.org', + }, + ) + + user_id = fields.String( + required=True, + attribute='sender', + metadata={ + 'description': 'ID of the user who sent the message', + 'example': '@myuser:matrix.example.org', + }, + ) + + body = fields.String( + required=True, + metadata={ + 'description': 'Message body', + 'example': 'Hello world!', + }, + ) + + format = fields.String( + metadata={ + 'description': 'Message format', + 'example': 'markdown', + }, + ) + + formatted_body = fields.String( + metadata={ + 'description': 'Formatted body', + 'example': '**Hello world!**', + }, + ) + + url = fields.String( + metadata={ + 'description': 'mxc:// URL if this message contains an attachment', + 'example': 'mxc://matrix.example.org/oarGdlpvcwppARPjzNlmlXkD', + }, + ) + + content_type = fields.String( + attribute='mimetype', + metadata={ + 'description': 'If the message contains an attachment, this field ' + 'will contain its MIME type', + 'example': 'image/jpeg', + }, + ) + + transaction_id = fields.String( + metadata={ + 'description': 'Set if this message a unique transaction_id associated', + 'example': 'mQ8hZR6Dx8I8YDMwONYmBkf7lTgJSMV/ZPqosDNM', + }, + ) + + decrypted = fields.Bool( + metadata={ + 'description': 'True if the message was encrypted and has been ' + 'successfully decrypted', + }, + ) + + verified = fields.Bool( + metadata={ + 'description': 'True if this is an encrypted message coming from a ' + 'verified source' + }, + ) + + hashes = fields.Dict( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains a mapping of its hashes', + 'example': {'sha256': 'yoQLQwcURq6/bJp1xQ/uhn9Z2xeA27KhMhPd/mfT8tR'}, + }, + ) + + iv = fields.String( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains the encryption initial value', + 'example': 'NqJMMdijlLvAAAAAAAAAAA', + }, + ) + + key = fields.Dict( + metadata={ + 'description': 'If the message has been decrypted, this field ' + 'contains the encryption/decryption key', + 'example': { + 'alg': 'A256CTR', + 'ext': True, + 'k': 'u6jjAyNvJoBHE55P5ZfvX49m3oSt9s_L4PSQdprRSJI', + 'key_ops': ['encrypt', 'decrypt'], + 'kty': 'oct', + }, + }, + ) + + timestamp = MillisecondsTimestamp( + required=True, + attribute='server_timestamp', + metadata={ + 'description': 'When the event was registered on the server', + 'example': '2022-07-23T17:20:01.254223', + }, + ) + + +class MatrixMessagesResponseSchema(Schema): + messages = fields.Nested( + MatrixMessageSchema, + many=True, + required=True, + attribute='chunk', + ) + + start = fields.String( + required=True, + nullable=True, + metadata={ + 'description': 'Pointer to the first message. It can be used as a ' + '``start``/``end`` for another ``get_messages`` query.', + 'example': 's10226_143893_619_3648_5951_5_555_7501_0', + }, + ) + + end = fields.String( + required=True, + nullable=True, + metadata={ + 'description': 'Pointer to the last message. It can be used as a ' + '``start``/``end`` for another ``get_messages`` query.', + 'example': 't2-10202_143892_626_3663_5949_6_558_7501_0', + }, + ) + + start_time = MillisecondsTimestamp( + required=True, + nullable=True, + metadata={ + 'description': 'The oldest timestamp of the returned messages', + 'example': '2022-07-23T16:20:01.254223', + }, + ) + + end_time = MillisecondsTimestamp( + required=True, + nullable=True, + metadata={ + 'description': 'The newest timestamp of the returned messages', + 'example': '2022-07-23T18:20:01.254223', + }, + ) diff --git a/setup.cfg b/setup.cfg index 9ad8fe28..eda14bb2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.23.3 +current_version = 0.23.4 commit = True tag = True diff --git a/setup.py b/setup.py index a5e379d7..79054a5f 100755 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ backend = pkg_files('platypush/backend') setup( name="platypush", - version="0.23.3", + version="0.23.4", author="Fabio Manganiello", author_email="info@fabiomanganiello.com", description="Platypush service", @@ -268,5 +268,7 @@ setup( 'ngrok': ['pyngrok'], # Support for IRC integration 'irc': ['irc'], + # Support for the Matrix integration + 'matrix': ['matrix-nio'], }, )