Merge branch 'master' into 191-support-for-general-entities-backend-and-plugin

This commit is contained in:
Fabio Manganiello 2022-08-29 01:41:47 +02:00
commit 1880a99052
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
27 changed files with 3172 additions and 361 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,4 +2,4 @@
==========================
.. automodule:: platypush.plugins.dbus
:members:
:exclude-members: DBusService, BusType

View File

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

View File

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

View File

@ -25,7 +25,7 @@ from .message.response import Response
from .utils import set_thread_name, get_enabled_plugins
__author__ = 'Fabio Manganiello <info@fabiomanganiello.com>'
__version__ = '0.23.3'
__version__ = '0.23.4'
logger = logging.getLogger('platypush')

View File

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

View File

@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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://<server>/<media_id>`` 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://<server>/<media_id>``. 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://<server>/<media_id>``.
: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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

385
platypush/schemas/matrix.py Normal file
View File

@ -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',
},
)

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.23.3
current_version = 0.23.4
commit = True
tag = True

View File

@ -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'],
},
)