Commit 0f1f3c9f authored by Birger Schacht's avatar Birger Schacht

Merge branch 'upstream/6.0+ds'

parents 98d4b857 21471e0e
......@@ -19,6 +19,8 @@ nohup.out
/.coverage
/data
/config.py.*
/.pytest_cache
/.mypy_cache
# tools
/tools/Home.md
......
......@@ -2,12 +2,10 @@ sudo: false
language: python
matrix:
include:
- python: 3.4
env: TOXENV=py34
- python: 3.5
env: TOXENV=py35
- python: 3.6
env: TOXENV=py36
- python: 3.7-dev
env: TOXENV=py37
- python: 3.6
env: TOXENV=pypi-lint
- python: 3.6
......
v6.0.0 (2019-03-23)
-------------------
features:
- TestBot: Implement inject_mocks method (#1235)
- TestBot: Add multi-line command test support (#1238)
- Added optional room arg to inroom
- Adds ability to go back to a previous room
- Pass telegram message id to the callback
fixes:
- Remove extra spaces in uptime output
- Fix/backend import error messages (#1248)
- Add docker support for installing package dependencies (#1245)
- variable name typo (#1244)
- Fix invalid variable name (#1241)
- sanitize comma quotation marks too (#1236)
- Fix missing string formatting in "Command not found" output (#1259)
- Fix webhook test to not call fixture directly
- fix: arg_botcmd decorator now can be used as plain method
- setup: removing dnspython
- pin markdown <3.0 because safe is deprecated
v6.0.0-alpha (2018-06-10)
-------------------------
major refactoring:
- Removed Yapsy dependency
- Replaced back Bottle and Rocket by Flask
- new Pep8 compliance
- added Python 3.7 support
- removed Python 3.5 support
- removed old compatibility cruft
- ported formats and % str ops to f-strings
- Started to add field types to improve type visibility across the codebase
- removed cross dependencies between PluginManager & RepoManager
fixes:
- Use sys.executable explicitly instead of just 'pip' (thx Bruno Oliveira)
- Pycodestyle fixes (thx Nitanshu)
- Help: don't add bot prefix to non-prefixed re cmds (#1199) (thx Robin Gloster)
- split_string_after: fix empty string handling (thx Robin Gloster)
- Escaping bug in dynamic plugins
- botmatch is now visible from the errbot module (fp to Guillaume Binet)
- flows: hint boolean was not forwarded
- Fix possible event without bot_id (#1073) (thx Roi Dayan)
- decorators were working only if kwargs were empty
- Message.clone was ignoring partial and flows
features:
- partial boolean to flag partial mesages (thx Meet Mangukiya)
- Slack: room joined callback (thx Jeremy Kenyon)
- XMPP: real_jid to get the jid the users logged in (thx Robin Gloster)
- The callback order set in the config is not globally respected
- Added a default parameter to the storage context manager
v5.2.0 (2018-04-04)
-------------------
......
......@@ -59,6 +59,7 @@ Chat servers support
- `Gitter support <https://gitter.im/>`_ (See `instructions <https://github.com/errbotio/err-backend-gitter>`__)
- `Matrix <https://matrix.org/>`_ (See `instructions <https://github.com/SShrike/err-backend-matrix>`__)
- `Mattermost <https://about.mattermost.com/>`_ (See `instructions <https://github.com/Vaelor/errbot-mattermost-backend>`__)
- `RocketChat <https://rocket.chat/>`_ (See `instructions <https://github.com/cardoso/errbot-rocketchat>`__)
- `Skype <https://www.skype.com/>`_ (See `instructions <https://github.com/errbotio/errbot-backend-skype>`__)
- `TOX <https://tox.im/>`_ (See `instructions <https://github.com/errbotio/err-backend-tox>`__)
- `VK <https://vk.com/>`_ (See `instructions <https://github.com/Ax3Effect/errbot-vk>`__)
......@@ -137,7 +138,7 @@ It will show you a prompt `>>>` so you can talk to your bot directly! Try `!help
Adding support for a chat system
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
For the built-ins, just use one of those options `slack, hipchap, telegram, IRC, XMPP` with pip, you can still do it
For the built-ins, just use one of those options `slack, hipchat, telegram, IRC, XMPP` with pip, you can still do it
after the initial installation to add the missing support for example ::
$ pip install "errbot[slack]"
......@@ -215,10 +216,10 @@ As an example, this is all it takes to create a "Hello, world!" plugin for Errbo
.. code:: python
from errbot import BotPlugin, botcmd
class Hello(BotPlugin):
"""Example 'Hello, world!' plugin for Errbot"""
@botcmd
def hello(self, msg, args):
"""Return the phrase "Hello, world!" to you"""
......
......@@ -76,11 +76,8 @@ Extensive plugin framework
.. _VK: https://vk.com/
.. _Zulip: https://zulipchat.com/
.. _`logged to Sentry`: https://github.com/errbotio/errbot/wiki/Logging-with-Sentry
.. _bottle: http://bottlepy.org/
.. _irc: https://pypi.python.org/pypi/irc/
.. _jabberbot: http://thp.io/2007/python-jabberbot/
.. _jinja2: http://jinja.pocoo.org/
.. _rocket: https://pypi.python.org/pypi/rocket
.. _six: https://pypi.python.org/pypi/six/
.. _sleekxmpp: http://sleekxmpp.com/
.. _yapsy: http://yapsy.sourceforge.net/
......@@ -125,6 +125,11 @@ Some commands are hardcoded to be admin-only so the people listed here will be g
More advanced access controls can be set up using the `ACCESS_CONTROLS` and `ACCESS_CONTROLS_DEFAULT` options which allow you to set up sophisticated rules.
The example :download:`config.py <config-template.py>` file contains more information about the format of these options.
If you don't like encoding access controls into the config file, a member
of the errbot community has also created a `dynamic ACL module
<https://github.com/shengis/err-profiles>`_ which can be administered
through chat commands instead.
.. note::
Different backends have different formats to identify users.
Refer to the backend-specific notes at the end of the :ref:`configuration <configuration>` chapter to see which format you should use.
......
......@@ -32,5 +32,5 @@ If you don't specify any predicate when you build your flow, every single step i
wait for the user to execute one of the possible commands at every step to advance along the graph.
Predicates can be used to trigger a command automatically. Predicates are simple functions saying to Errbot,
"this command has enough in the context to be able to executed without any user intervention".
"this command has enough in the context to be able to execute without any user intervention".
At any time if a predicate is verified after a step is executed, Errbot will proceed and execute the next step.
......@@ -25,7 +25,7 @@ For example, with the Telegram backend this would be an instance of :class:`~err
.. code-block:: python
>>> type(self._bot)
<class 'yapsy_loaded_plugin_Telegram_0.TelegramBackend'>
<class 'errbot.backends.TelegramBackend'>
To find out what methods each bot backend has, you can take a look at the documentation of the various backends in the :mod:`errbot.backends` package.
......
......@@ -23,7 +23,7 @@ on in the **Core** section of your plug file like this:
Using dependencies
------------------
Once a dependent plugin has been declared, you can use it at soon as your plugin is activated.
Once a dependent plugin has been declared, you can use it as soon as your plugin is activated.
.. code-block:: python
......
......@@ -119,7 +119,40 @@ What we need now is get access to the instance of our plugin itself. Fortunately
result = plugin.mycommand_helper()
assert result == expected
There we go, we first grab out plugin thanks to a helper method on :mod:`~errbot.plugin_manager` and then simply execute the method and compare what we get with what we expect. You can also access `@classmethod` or `@staticmethod` methods this way, you just don't have to.
There we go, we first grab our plugin using a helper method on :mod:`~errbot.plugin_manager` and then simply execute the method and compare the result with the expected result. You can also access `@classmethod` or `@staticmethod` methods this way, but you don't have to.
Sometimes a helper method will be making HTTP or API requests which might not be possible to test directly. In that case, we need to mock that particular method and make it return the expected value without actually making the request.
.. code-block:: python
URL = 'http://errbot.io'
class MyPlugin(BotPlugin):
@botcmd
def mycommand(self, message, args):
return self.mycommand_helper()
def mycommand_helper(self):
return (requests.get(URL).status_code)
What we need now is to somehow replace the method making the request with our mock object and `inject_mocks` method comes in handy.
Refer `unittest.mock <https://docs.python.org/3/library/unittest.mock.html>`_ for more information about mock.
.. code-block:: python
from unittest.mock import MagicMock
extra_plugin_dir = '.'
def test_mycommand_helper(testbot):
helper_mock = MagicMock(return_value='200')
mock_dict = {'mycommand_helper': helper_mock}
testbot.inject_mocks('MyPlugin', mock_dict)
testbot.push_message('!mycommand')
expected = '200'
result = testbot.pop_message()
assert result == expected
Pattern
-------
......@@ -252,5 +285,4 @@ Both Travis-CI and Coveralls easily integrate with Github hosted code.
.. _conftest.py: http://doc.pytest.org/en/latest/writing_plugins.html#conftest-py-local-per-directory-plugins
.. _Coveralls.io: https://coveralls.io
.. _Travis-CI: https://travis-ci.org
.. _Yapsy: http://yapsy.sourceforge.net
.. _wheels: http://www.python.org/dev/peps/pep-0427/
......@@ -57,10 +57,10 @@ parameter:
def test(self, request, name, action):
return "User %s is performing %s" % (name, action)
Refer to the documentation on Bottle's
`request routing <http://bottlepy.org/docs/dev/routing.html>`_
Refer to the documentation on Flask's
`route <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.route>`_
for details on the supported syntax
(Errbot uses Bottle internally).
(Errbot uses Flask internally).
Handling JSON request
......@@ -123,31 +123,33 @@ Returning custom headers and status codes
Adjusting the response headers, setting cookies or returning a
different status code can all be done by manipulating the
`bottle.response <http://bottlepy.org/docs/dev/api.html#bottle.response>`_
object. The bottle docs on `the response object
<http://bottlepy.org/docs/dev/tutorial.html#the-response-object>`_
`flask response <http://flask.pocoo.org/docs/1.0/patterns/deferredcallbacks/>`_
object. The Flask docs on `the response object
<http://flask.pocoo.org/docs/1.0/api/#response-objects>`_
explain this in more detail. Here's an example of setting a
custom header:
.. code-block:: python
from errbot import BotPlugin, webhook
from bottle import response
from flask import after_this_request
class PluginExample(BotPlugin):
@webhook
def example(self, incoming_request):
response.set_header("X-Powered-By", "Errbot")
@after_this_request
def add_header(response):
response.headers['X-Powered-By'] = 'Errbot'
return "OK"
Bottle also has various helpers such as the `abort()` method.
Flask also has various helpers such as the `abort()` method.
Using this method we could, for example, return a 403 forbidden
response like so:
.. code-block:: python
from errbot import BotPlugin, webhook
from bottle import abort
from flask import abort
class PluginExample(BotPlugin):
@webhook
......
This diff is collapsed.
import logging
import sys
from pathlib import Path
from typing import Any, Type
from errbot.plugin_info import PluginInfo
from .utils import collect_roots
log = logging.getLogger(__name__)
class PluginNotFoundException(Exception):
pass
class BackendPluginManager:
"""
This is a one shot plugin manager for Backends and Storage plugins.
"""
def __init__(self, bot_config, base_module: str, plugin_name: str, base_class: Type,
base_search_dir, extra_search_dirs=()):
self._config = bot_config
self._base_module = base_module
self._base_class = base_class
self.plugin_info = None
all_plugins_paths = collect_roots((base_search_dir, extra_search_dirs))
plugin_places = [Path(root) for root in all_plugins_paths]
for path in plugin_places:
plugfiles = path.glob('**/*.plug')
for plugfile in plugfiles:
plugin_info = PluginInfo.load(plugfile)
if plugin_info.name == plugin_name:
self.plugin_info = plugin_info
return
raise PluginNotFoundException(f'Could not find the plugin named {plugin_name} in {all_plugins_paths}.')
def load_plugin(self) -> Any:
plugin_path = self.plugin_info.location.parent
if plugin_path not in sys.path:
sys.path.append(plugin_path)
plugin_classes = self.plugin_info.load_plugin_classes(self._base_module, self._base_class)
if len(plugin_classes) != 1:
raise PluginNotFoundException(f'Found more that one plugin for {self._base_class}.')
_, clazz = plugin_classes[0]
return clazz(self._config)
......@@ -6,9 +6,7 @@ from typing import Any, Mapping, BinaryIO, List, Sequence, Tuple
from abc import ABC, abstractmethod
from collections import deque, defaultdict
# Can't use __name__ because of Yapsy
log = logging.getLogger('errbot.backends.base')
log = logging.getLogger(__name__)
class Identifier(ABC):
......@@ -89,7 +87,7 @@ class Room(Identifier):
This class represents a Multi-User Chatroom.
"""
def join(self, username: str=None, password: str=None) -> None:
def join(self, username: str = None, password: str = None) -> None:
"""
Join the room.
......@@ -98,7 +96,7 @@ class Room(Identifier):
"""
raise NotImplementedError("It should be implemented specifically for your backend")
def leave(self, reason: str=None) -> None:
def leave(self, reason: str = None) -> None:
"""
Leave the room.
......@@ -222,12 +220,13 @@ class Message(object):
"""
def __init__(self,
body: str='',
frm: Identifier=None,
to: Identifier=None,
parent: 'Message'=None,
delayed: bool=False,
extras: Mapping=None,
body: str = '',
frm: Identifier = None,
to: Identifier = None,
parent: 'Message' = None,
delayed: bool = False,
partial: bool = False,
extras: Mapping = None,
flow=None):
"""
:param body:
......@@ -238,6 +237,9 @@ class Message(object):
The flow in which this message has been triggered.
:param parent:
The parent message of this message in a thread. (Not supported by all backends)
:param partial:
Indicates whether the message was obtained by breaking down the message to fit
the ``MESSAGE_SIZE_LIMIT``.
"""
self._body = body
self._from = frm
......@@ -246,6 +248,7 @@ class Message(object):
self._delayed = delayed
self._extras = extras or dict()
self._flow = flow
self._partial = partial
# Convenience shortcut to the flow context
if flow:
......@@ -254,7 +257,8 @@ class Message(object):
self.ctx = {}
def clone(self):
return Message(self._body, self._from, self._to, self._parent, self._delayed, self.extras)
return Message(body=self._body, frm=self._from, to=self._to, parent=self._parent,
delayed=self._delayed, partial=self._partial, extras=self._extras, flow=self._flow)
@property
def to(self) -> Identifier:
......@@ -356,6 +360,14 @@ class Message(object):
def is_threaded(self) -> bool:
return self._parent is not None
@property
def partial(self) -> bool:
return self._partial
@partial.setter
def partial(self, partial):
self._partial = partial
class Card(Message):
"""
......@@ -363,18 +375,19 @@ class Card(Message):
Slack or Hipchat it will be rendered natively, otherwise it will be sent as a regular message formatted with
the card.md template.
"""
def __init__(self,
body: str='',
frm: Identifier=None,
to: Identifier=None,
parent: Message=None,
summary: str=None,
title: str='',
link: str=None,
image: str=None,
thumbnail: str=None,
color: str=None,
fields: Tuple[Tuple[str, str]]=()):
body: str = '',
frm: Identifier = None,
to: Identifier = None,
parent: Message = None,
summary: str = None,
title: str = '',
link: str = None,
image: str = None,
thumbnail: str = None,
color: str = None,
fields: Tuple[Tuple[str, str]] = ()):
"""
Creates a Card.
:param body: main text of the card in markdown.
......@@ -449,8 +462,8 @@ class Presence(object):
def __init__(self,
identifier: Identifier,
status: str=None,
message: str=None):
status: str = None,
message: str = None):
if identifier is None:
raise ValueError('Presence: identifiers is None')
if status is None and message is None:
......@@ -487,11 +500,11 @@ class Presence(object):
def __str__(self):
response = ''
if self._identifier:
response += 'identifier: "%s" ' % self._identifier
response += f'identifier: "{self._identifier}" '
if self._status:
response += 'status: "%s" ' % self._status
response += f'status: "{self._status}" '
if self._message:
response += 'message: "%s" ' % self._message
response += f'message: "{self._message}" '
return response
def __unicode__(self):
......@@ -519,9 +532,9 @@ class Stream(io.BufferedReader):
def __init__(self,
identifier: Identifier,
fsource: BinaryIO,
name: str=None,
size: int=None,
stream_type: str=None):
name: str = None,
size: int = None,
stream_type: str = None):
super().__init__(fsource)
self._identifier = identifier
self._name = name
......@@ -632,23 +645,23 @@ class Backend(ABC):
""" Those arguments will be directly those put in BOT_IDENTITY
"""
log.debug("Backend init.")
self._reconnection_count = 0 # Increments with each failed (re)connection
self._reconnection_delay = 1 # Amount of seconds the bot will sleep on the
self._reconnection_count = 0 # Increments with each failed (re)connection
self._reconnection_delay = 1 # Amount of seconds the bot will sleep on the
# # next reconnection attempt
self._reconnection_max_delay = 600 # Maximum delay between reconnection attempts
self._reconnection_max_delay = 600 # Maximum delay between reconnection attempts
self._reconnection_multiplier = 1.75 # Delay multiplier
self._reconnection_jitter = (0, 3) # Random jitter added to delay (min, max)
self._reconnection_jitter = (0, 3) # Random jitter added to delay (min, max)
@abstractmethod
def send_message(self, msg: Message) -> None:
"""Should be overridden by backends with a super().send_message() call."""
@abstractmethod
def change_presence(self, status: str=ONLINE, message: str='') -> None:
def change_presence(self, status: str = ONLINE, message: str = '') -> None:
"""Signal a presence change for the bot. Should be overridden by backends with a super().send_message() call."""
@abstractmethod
def build_reply(self, msg: Message, text: str=None, private: bool=False, threaded: bool=False):
def build_reply(self, msg: Message, text: str = None, private: bool = False, threaded: bool = False):
""" Should be implemented by the backend """
@abstractmethod
......@@ -686,23 +699,21 @@ class Backend(ABC):
if self.serve_once():
break # Truth-y exit from serve_once means shutdown was requested
except KeyboardInterrupt:
log.info("Interrupt received, shutting down..")
log.info('Interrupt received, shutting down..')
break
except Exception:
log.exception("Exception occurred in serve_once:")
log.exception('Exception occurred in serve_once:')
log.info(
"Reconnecting in {delay} seconds ({count} attempted reconnections so far)".format(
delay=self._reconnection_delay, count=self._reconnection_count)
)
log.info('Reconnecting in %d seconds (%d attempted reconnections so far).', self._reconnection_delay,
self._reconnection_count)
try:
self._delay_reconnect()
self._reconnection_count += 1
except KeyboardInterrupt:
log.info("Interrupt received, shutting down..")
log.info('Interrupt received, shutting down..')
break
log.info("Trigger shutdown")
log.info('Trigger shutdown')
self.shutdown()
def _delay_reconnect(self):
......
......@@ -3,4 +3,4 @@ Name = Graphic
Module = graphic
[Documentation]
Description = This is the graphic backend for Err.
Description = This is the graphic backend for Errbot.
......@@ -12,8 +12,7 @@ from errbot.rendering import xhtml
CARD_TMPL = Environment(loader=FileSystemLoader(os.path.dirname(__file__)),
autoescape=True).get_template('graphic_card.html')
# Can't use __name__ because of Yapsy
log = logging.getLogger('errbot.backends.graphic')
log = logging.getLogger(__name__)
try:
from PySide import QtCore, QtGui, QtWebKit
......@@ -97,12 +96,12 @@ class CommandBox(QtGui.QPlainTextEdit, object):
if key == Qt.Key_Up:
if self.history_index > 0:
self.history_index -= 1
self.setPlainText('%s%s' % (self.prefix, ' '.join(self.history[self.history_index])))
self.setPlainText(f'{self.prefix}{" ".join(self.history[self.history_index])}')
return
elif key == Qt.Key_Down:
if self.history_index < len(self.history) - 1:
self.history_index += 1
self.setPlainText('%s%s' % (self.prefix, ' '.join(self.history[self.history_index])))
self.setPlainText(f'{self.prefix}{" ".join(self.history[self.history_index])}')
return
elif key == QtCore.Qt.Key_Return and (ctrl or alt):
self.newCommand.emit(self.toPlainText())
......@@ -123,7 +122,7 @@ style_path = os.path.join(backends_path, 'styles')
css_path = os.path.join(style_path, 'style.css')
demo_css_path = os.path.join(style_path, 'style-demo.css')
TOP = '<html><body style="background-image: url(\'file://%s\');">' % bg_path
TOP = f'<html><body style="background-image: url(\'file://{bg_path}\');">'
BOTTOM = '</body></html>'
......@@ -173,11 +172,10 @@ class ChatApplication(QtGui.QApplication):
def new_message(self, text, receiving=True):
size = 50 if self.demo_mode else 25
user = '<img src="file://%s" height=%d />' % (prompt_path, size)
bot = '<img src="file://%s" height=%d/>' % (icon_path, size)
self.buffer += '<div class="%s">%s<br/>%s</div>' % ('receiving' if receiving else 'sending',
bot if receiving else user,
text)
user = f'<img src="file://{prompt_path}" height={size:d} />'
bot = f'<img src="file://{icon_path}" height={size:d}/>'
self.buffer += f'<div class="{"receiving" if receiving else "sending"}">{bot if receiving else user}' \
f'<br/>{text}</div>'
self.update_webpage()
......@@ -249,4 +247,4 @@ class GraphicBackend(TextBackend):
def prefix_groupchat_reply(self, message, identifier):
super().prefix_groupchat_reply(message, identifier)
message.body = '@{0} {1}'.format(identifier.nick, message.body)
message.body = f'@{identifier.nick} {message.body}'
......@@ -3,4 +3,4 @@ Name = Hipchat
Module = hipchat
[Documentation]
Description = This is the hipchat backend for Err.
Description = This is the hipchat backend for Errbot.
This diff is collapsed.
......@@ -3,4 +3,4 @@ Name = IRC
Module = irc
[Documentation]
Description = This is the IRC backend for Err.
Description = This is the IRC backend for Errbot.
This diff is collapsed.
......@@ -3,4 +3,4 @@ Name = Null
Module = null
[Documentation]
Description = This is the Null backend for Err.
Description = This is the Null backend for Errbot.
......@@ -5,9 +5,7 @@ from errbot.backends.base import ONLINE
from errbot.backends.test import TestPerson
from errbot.core import ErrBot
# Can't use __name__ because of Yapsy
log = logging.getLogger('errbot.backends.null')
log = logging.getLogger(__name__)
class ConnectionMock(object):
......
......@@ -3,4 +3,4 @@ Name = Slack
Module = slack
[Documentation]
Description = This is the slack backend for Err.
Description = This is the slack backend for Errbot.
This diff is collapsed.
......@@ -3,4 +3,4 @@ Name = Telegram
Module = telegram_messenger
[Documentation]
Description = This is the Telegram backend for Err.
Description = This is the Telegram backend for Errbot.
......@@ -6,9 +6,7 @@ from errbot.core import ErrBot
from errbot.rendering import text
from errbot.rendering.ansiext import enable_format, TEXT_CHRS
# Can't use __name__ because of Yapsy
log = logging.getLogger('errbot.backends.telegram')
log = logging.getLogger(__name__)
TELEGRAM_MESSAGE_SIZE_LIMIT = 1024
UPDATES_OFFSET_KEY = '_telegram_updates_offset'
......@@ -125,13 +123,13 @@ class TelegramRoom(TelegramIdentifier, Room):
"""Return the groupchat title (only applies to groupchats)"""
return self._title
def join(self, username: str=None, password: str=None):
def join(self, username: str = None, password: str = None):
raise RoomsNotSupportedError()
def create(self):
raise RoomsNotSupportedError()
def leave(self, reason: str=None):
def leave(self, reason: str = None):
raise RoomsNotSupportedError()
def destroy(self):
......@@ -161,6 +159,7 @@ class TelegramMUCOccupant(TelegramPerson, RoomOccupant):
"""
This class represents a person inside a MUC.
"""
def __init__(self, id, room, first_name=None, last_name=None, username=None):
super().__init__(id=id, first_name=first_name, last_name=last_name, username=username)
self._room = room
......@@ -275,6 +274,7 @@ class TelegramBackend(ErrBot):
username=message.from_user.username
)
message_instance.to = room
message_instance.extras['message_id'] = message.message_id
self.callback_message(message_instance)
def send_message(self, msg):
......@@ -284,9 +284,7 @@ class TelegramBackend(ErrBot):
self.telegram.sendMessage(msg.to.id, body)
except Exception:
log.exception(
"An exception occurred while trying to send the following message "
"to %s: %s" % (msg.to.id, msg.body)
)
f'An exception occurred while trying to send the following message to {msg.to.id}: {msg.body}')
raise
def change_presence(self, status: str = ONLINE, message: str = '') -> None:
......@@ -297,9 +295,9 @@ class TelegramBackend(ErrBot):