From 8c3ac1d678ac121e1cde95a117ab393f96d9bdfd Mon Sep 17 00:00:00 2001 From: Michael Krukov Date: Wed, 15 Jan 2020 03:37:16 +0300 Subject: [PATCH] Updated to 4.1.0; Fixed bug in commands; Improved backends's public API (#48) * Updated to 4.1.0; Fixed bug in commands; Improved backends's public API * Made 'resolve_screen_name' public * Changed commend in stream.py --- CHANGELOG.md | 5 ++ example/plugins/quest.py | 4 +- example/plugins/stream.py | 41 +++++++++++++++ kutana/backends/telegram.py | 33 +++++++++--- kutana/backends/vkontakte/backend.py | 78 +++++++++++++++++----------- kutana/plugin.py | 3 ++ kutana/routers.py | 7 ++- setup.py | 2 +- tests/test_backends_api.py | 52 +++++++++++++++++++ tests/test_telegram.py | 14 ++--- tests/test_vkontakte.py | 43 ++++++++------- 11 files changed, 214 insertions(+), 68 deletions(-) create mode 100644 example/plugins/stream.py create mode 100644 tests/test_backends_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 378f603..12849a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ > Changes to public API is marked as `^` +- v4.1.0 + - Fixed case mistake in CommandsRouter + - Added better API for backends + - Added example for background actions + - v4.0.0 - ^ Dramatically changed API - Fixed unknown memory leak diff --git a/example/plugins/quest.py b/example/plugins/quest.py index 5d9db61..900af0e 100644 --- a/example/plugins/quest.py +++ b/example/plugins/quest.py @@ -4,7 +4,7 @@ pl = Plugin("Quest") -@pl.on_commands(["start"], user_state="") +@pl.on_commands(["quest"], user_state="") async def _(msg, ctx): await ctx.set_state(user_state="quest:1") await ctx.reply("Choose: left or right") @@ -52,7 +52,7 @@ async def _(msg, ctx): await ctx.reply("Bye") -@pl.on_any_message(user_state="quest:end") +@pl.on_any_unprocessed_message(user_state="quest:end") async def _(msg, ctx): await ctx.reply("Write '.OK'") diff --git a/example/plugins/stream.py b/example/plugins/stream.py new file mode 100644 index 0000000..5a5f108 --- /dev/null +++ b/example/plugins/stream.py @@ -0,0 +1,41 @@ +import asyncio +from kutana import Plugin, Attachment, get_path + + +# This example shows how to access existing backend instance and perform +# some actions outside of handlers. + + +plugin = Plugin(name="Stream", description="Send images to subscribers") + + +subscribers = [] + + +async def bg_loop(vk): + while True: + with open(get_path(__file__, "assets/pizza.png"), "rb") as fh: + temp_a = Attachment.new(fh.read(), "pizza.png") + + a = await vk.upload_attachment(temp_a, peer_id=None) + + for sub in subscribers: + await vk.send_message(sub, "", a) + + await asyncio.sleep(5) + +@plugin.on_start() +async def _(app): + vk = app.get_backends()[0] + asyncio.ensure_future(bg_loop(vk)) + +@plugin.on_commands(["stream sub"]) +async def _(msg, ctx): + subscribers.append(msg.receiver_id) + await ctx.reply("OK") + + +@plugin.on_commands(["stream unsub"]) +async def _(msg, ctx): + subscribers.remove(msg.receiver_id) + await ctx.reply("OK") diff --git a/kutana/backends/telegram.py b/kutana/backends/telegram.py index 7e8ad9c..b4952e8 100644 --- a/kutana/backends/telegram.py +++ b/kutana/backends/telegram.py @@ -34,7 +34,7 @@ def __init__( self.api_url = f"https://api.telegram.org/bot{token}/{{}}" self.file_url = f"https://api.telegram.org/file/bot{token}/{{}}" - async def request(self, method, kwargs={}): + async def _request(self, method, kwargs={}): if not self.session: self.session = aiohttp.ClientSession() @@ -55,7 +55,7 @@ async def request(self, method, kwargs={}): return res async def _request_file(self, file_id): - file = await self.request("getFile", {"file_id": file_id}) + file = await self._request("getFile", {"file_id": file_id}) url = self.file_url.format(file["file_path"]) @@ -158,7 +158,7 @@ def _make_update(self, raw_update): async def perform_updates_request(self, submit_update): try: - response = await self.request( + response = await self._request( "getUpdates", {"timeout": 25, "offset": self.offset} ) except (json.JSONDecodeError, aiohttp.ClientError): @@ -183,7 +183,7 @@ async def perform_send(self, target_id, message, attachments, kwargs): async with self.api_messages_lock: if message: - result.append(await self.request("sendMessage", { + result.append(await self._request("sendMessage", { "chat_id": chat_id, "text": message, **kwargs, @@ -215,7 +215,7 @@ async def perform_send(self, target_id, message, attachments, kwargs): raise ValueError("Can't upload attachment '{atype}'") result.append( - await self.request( + await self._request( "send" + atype.capitalize(), {"chat_id": chat_id, atype: acontent} ) @@ -226,10 +226,10 @@ async def perform_send(self, target_id, message, attachments, kwargs): return result async def perform_api_call(self, method, kwargs): - return await self.request(method, kwargs) + return await self._request(method, kwargs) async def on_start(self, app): - me = await self.request("getMe") + me = await self._request("getMe") name = me["first_name"] if me.get("last_name"): @@ -243,6 +243,25 @@ async def on_start(self, app): self.api_messages_lock = asyncio.Lock(loop=app.get_loop()) + async def send_message(self, target_id, message, attachments=(), **kwargs): + """ + Send message to specified `target_id` with text `message` and + attachments `attachments`. + + This method will forward all excessive keyword arguments to + sending method. + """ + + return await self.perform_send(target_id, message, attachments, kwargs) + + async def request(self, method, **kwargs): + """ + Call specified method from Telegram api with specified + kwargs and return response's data. + """ + + return await self._request(method, kwargs) + async def on_shutdown(self, app): if self._is_session_local: await self.session.close() diff --git a/kutana/backends/vkontakte/backend.py b/kutana/backends/vkontakte/backend.py index d730b80..5a9aea8 100644 --- a/kutana/backends/vkontakte/backend.py +++ b/kutana/backends/vkontakte/backend.py @@ -134,17 +134,7 @@ async def _execute_loop_perform_execute(self, code, requests): else: req.set_result(res) - async def request(self, method, kwargs, timeout=None): - """ - Call specified method from VKontakte api with specified - kwargs and return response's data. - - This method respects limits. - - This method raises RequestException if response - contains error. - """ - + async def _request(self, method, kwargs, timeout=None): req = VKRequest(method, kwargs) self.requests_queue.append(req) @@ -162,11 +152,11 @@ async def update_longpoll_data(self): self.longpoll_data = longpoll.copy() - async def _resolve_screen_name(self, screen_name): + async def resolve_screen_name(self, screen_name): if screen_name in NAIVE_CACHE: return NAIVE_CACHE[screen_name] - result = await self.request( + result = await self._request( "utils.resolveScreenName", {"screen_name": screen_name} ) @@ -188,7 +178,7 @@ async def _update_group_data(self): self.group_screen_name = groups[0]["screen_name"] def prepare_context(self, ctx): - ctx.resolve_screen_name = self._resolve_screen_name + ctx.resolve_screen_name = self.resolve_screen_name def _make_getter(self, url): async def getter(): @@ -301,7 +291,14 @@ async def _upload_file_to_vk(self, upload_url, data): async with self.session.post(upload_url, data=data) as resp: return await resp.json(content_type=None) - async def _upload_attachment(self, attachment, peer_id=None): + async def upload_attachment(self, attachment, peer_id=None): + """ + Upload specified attachment to VKontakte with specified peer_id and + return newly uploaded attachment. + + This method doesn't change passed attachments. + """ + attachment_type = attachment.type if attachment_type == "voice": @@ -315,12 +312,12 @@ async def _upload_attachment(self, attachment, peer_id=None): if attachment_type == "doc": if peer_id and doctype != "graffiti": - upload_data = await self.request( + upload_data = await self._request( "docs.getMessagesUploadServer", {"peer_id": peer_id, "type": doctype}, ) else: - upload_data = await self.request( + upload_data = await self._request( "docs.getWallUploadServer", {"group_id": self.group_id, "type": doctype}, ) @@ -334,14 +331,14 @@ async def _upload_attachment(self, attachment, peer_id=None): upload_data["upload_url"], data ) - attachment = await self.request( + attachment = await self._request( "docs.save", upload_result ) return self._make_attachment(attachment) if attachment_type == "image": - upload_data = await self.request( + upload_data = await self._request( "photos.getMessagesUploadServer", {"peer_id": peer_id} ) @@ -355,14 +352,14 @@ async def _upload_attachment(self, attachment, peer_id=None): ) try: - attachments = await self.request( + attachments = await self._request( "photos.saveMessagesPhoto", upload_result ) except RequestException as e: if not peer_id or not e.error or e.error["error_code"] != 1: raise - return await self._upload_attachment(attachment, peer_id=None) + return await self.upload_attachment(attachment, peer_id=None) return self._make_attachment({ "type": "photo", @@ -428,10 +425,8 @@ async def perform_updates_request(self, submit_update): async def perform_send(self, target_id, message, attachments, kwargs): # Form proper arguments - true_kwargs = kwargs.copy() - - true_kwargs["message"] = message - true_kwargs["peer_id"] = target_id + true_kwargs = {"message": message, "peer_id": target_id} + true_kwargs.update(kwargs) if "random_id" not in kwargs: true_kwargs["random_id"] = int(random() * 4294967296) - 2147483648 @@ -449,7 +444,7 @@ async def perform_send(self, target_id, message, attachments, kwargs): continue if not a.uploaded: - a = await self._upload_attachment(a, peer_id=target_id) + a = await self.upload_attachment(a, peer_id=target_id) if not a.id: raise ValueError("Attachment has no ID") @@ -466,12 +461,13 @@ async def perform_send(self, target_id, message, attachments, kwargs): true_attachments += "," # Add attachments to arguments - true_kwargs["attachment"] = true_attachments[:-1] + if true_attachments[:-1]: + true_kwargs["attachment"] = true_attachments[:-1] - return await self.request("messages.send", true_kwargs) + return await self._request("messages.send", true_kwargs) async def perform_api_call(self, method, kwargs): - return await self.request(method, kwargs) + return await self._request(method, kwargs) async def on_start(self, app): if not self.session: @@ -523,6 +519,30 @@ async def on_start(self, app): loop=loop ) + async def send_message(self, target_id, message, attachments=(), **kwargs): + """ + Send message to specified `target_id` with text `message` and + attachments `attachments`. + + This method will forward all excessive keyword arguments to + sending method. + """ + + return await self.perform_send(target_id, message, attachments, kwargs) + + async def request(self, method, _timeout=None, **kwargs): + """ + Call specified method from VKontakte api with specified + kwargs and return response's data. + + This method respects limits. + + This method raises RequestException if response + contains error. + """ + + return await self._request(method, kwargs, _timeout) + async def on_shutdown(self, app): if self._is_session_local: await self.session.close() diff --git a/kutana/plugin.py b/kutana/plugin.py index 8f168e4..39f8ca7 100644 --- a/kutana/plugin.py +++ b/kutana/plugin.py @@ -118,6 +118,9 @@ def on_commands( incoming update is message, starts with prefix and one of provided commands. + If case_insensitive is True, commands will be processed with ignored + case. + Context is automatically populated with following values: - prefix diff --git a/kutana/routers.py b/kutana/routers.py index 5be5424..5c57230 100644 --- a/kutana/routers.py +++ b/kutana/routers.py @@ -20,9 +20,12 @@ def _populate_cache(self, ctx): prefix="|".join(re.escape(p) for p in prefixes), command="|".join(re.escape(c) for c in commands), ), - flags=re.I, + flags=re.IGNORECASE, ) + def add_handler(self, handler, key): + return super().add_handler(handler, key.lower()) + def _get_keys(self, update, ctx): if update.type != UpdateType.MSG: return () @@ -40,7 +43,7 @@ def _get_keys(self, update, ctx): ctx.body = (match.group(3) or "").strip() ctx.match = match - return (ctx.command,) + return (ctx.command.lower(),) class AttachmentsRouter(MapRouter): diff --git a/setup.py b/setup.py index c312877..e670218 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ import setuptools -VERSION = "4.0.0" +VERSION = "4.1.0" with open("README.md", "r") as fh: diff --git a/tests/test_backends_api.py b/tests/test_backends_api.py new file mode 100644 index 0000000..58a15a7 --- /dev/null +++ b/tests/test_backends_api.py @@ -0,0 +1,52 @@ +import asyncio +from asynctest import patch +from kutana.backends import Vkontakte, Telegram +from kutana.backends.vkontakte.backend import VKRequest + + +@patch("kutana.backends.Vkontakte._request") +def test_vk_backend_api(mock): + async def test(): + fut = VKRequest("", "") + fut.set_result("OK") + mock.return_value = fut + + vk = Vkontakte("token") + + resp = await vk.request("method", arg1="val1") + assert resp == "OK" + mock.assert_awaited_with("method", {"arg1": "val1"}, None) + + resp = await vk.send_message("user1", "msg", arg1="val1", random_id="1") + assert resp == "OK" + mock.assert_awaited_with( + "messages.send", { + "peer_id": "user1", + "message": "msg", + "arg1": "val1", + "random_id": "1" + }, + ) + + asyncio.get_event_loop().run_until_complete(test()) + + +@patch("kutana.backends.Telegram._request") +def test_tg_backend_api(mock): + async def test(): + mock.return_value = "OK" + + tg = Telegram("token") + tg.api_messages_lock = asyncio.Lock() + + resp = await tg.request("method", arg1="val1") + assert resp == "OK" + mock.assert_awaited_with("method", {"arg1": "val1"}) + + resp = await tg.send_message("user1", "msg", arg1="val1") + assert resp == ["OK"] + mock.assert_awaited_with( + "sendMessage", {"chat_id": "user1", "text": "msg", "arg1": "val1"}, + ) + + asyncio.get_event_loop().run_until_complete(test()) diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 18c3e57..34c686f 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -21,10 +21,10 @@ def test_request(mock_post): async def test(): telegram = Telegram(token="token") - assert await telegram.request("method1", {"arg": "val1"}) == 1 + assert await telegram._request("method1", {"arg": "val1"}) == 1 with pytest.raises(RequestException): - await telegram.request("method2", {"arg": "val2"}) + await telegram._request("method2", {"arg": "val2"}) await telegram.session.close() @@ -43,7 +43,7 @@ async def test(): async def req(method, kwargs={}): if method == "getFile" and kwargs["file_id"] == "file_id": return {"file_path": "123"} - telegram.request = req + telegram._request = req assert await telegram._request_file("file_id") == b"content" assert await telegram._make_getter("file_id")() == b"content" @@ -111,7 +111,7 @@ async def test(): async def req(method, kwargs): requests.append((method, kwargs)) - telegram.request = req + telegram._request = req attachment = Attachment.new(b"file") await telegram.perform_send(1, "", attachment, {}) @@ -125,7 +125,7 @@ async def req(method, kwargs): assert len(requests) == 3 assert requests[0] == ("sendPhoto", { - "chat_id": '1', "photo": b'file' + "chat_id": "1", "photo": b"file" }) assert requests[1] == ("sendDocument", { "chat_id": '1', "document": b'file' @@ -157,7 +157,7 @@ async def req(method, kwargs): assert method == "method" assert kwargs["arg"] == "val" return 1 - telegram.request = req + telegram._request = req result = asyncio.get_event_loop().run_until_complete( telegram.perform_api_call("method", {"arg": "val"}) @@ -178,7 +178,7 @@ def test_happy_path(): answers = [] class _Telegram(Telegram): - async def request(self, method, kwargs={}): + async def _request(self, method, kwargs={}): if method == "getMe": return {"first_name": "te", "last_name": "st", "username": "a"} diff --git a/tests/test_vkontakte.py b/tests/test_vkontakte.py index 7718689..90ab124 100644 --- a/tests/test_vkontakte.py +++ b/tests/test_vkontakte.py @@ -83,7 +83,7 @@ async def test(): asyncio.get_event_loop().run_until_complete(test()) -@patch("kutana.backends.Vkontakte.request") +@patch("kutana.backends.Vkontakte._request") def test_resolve_screen_name(mock_request): data = { "type": "user", @@ -95,12 +95,12 @@ def test_resolve_screen_name(mock_request): async def test(): vkontakte = Vkontakte(token="token", session=aiohttp.ClientSession()) - assert await vkontakte._resolve_screen_name("durov") == data - assert await vkontakte._resolve_screen_name("durov") == data + assert await vkontakte.resolve_screen_name("durov") == data + assert await vkontakte.resolve_screen_name("durov") == data NAIVE_CACHE.update({i: None for i in range(500_000)}) - assert await vkontakte._resolve_screen_name("krukov") == data + assert await vkontakte.resolve_screen_name("krukov") == data assert len(NAIVE_CACHE) == 1 assert next(mock_request.side_effect, None) is None @@ -157,7 +157,7 @@ async def test(): def test_upload_attachment(): class _Vkontakte(Vkontakte): - async def request(self, method, kwargs): + async def _request(self, method, kwargs): if method == "photos.getMessagesUploadServer": return {"upload_url": "upload_url_photo"} @@ -204,28 +204,28 @@ async def _upload_file_to_vk(self, url, data): async def test(): attachment = Attachment.new(b"content") - image = await vkontakte._upload_attachment(attachment, peer_id=123) + image = await vkontakte.upload_attachment(attachment, peer_id=123) assert image.type == "image" assert image.id is not None assert image.file is None attachment = Attachment.new(b"content", type="doc") - doc = await vkontakte._upload_attachment(attachment, peer_id=123) + doc = await vkontakte.upload_attachment(attachment, peer_id=123) assert doc.type == "doc" assert doc.id is not None assert doc.file is None attachment = Attachment.new(b"content", type="voice") - voice = await vkontakte._upload_attachment(attachment, peer_id=123) + voice = await vkontakte.upload_attachment(attachment, peer_id=123) assert voice.type == "voice" assert voice.id is not None assert voice.file is None attachment = Attachment.new(b"content", type="graffiti") - voice = await vkontakte._upload_attachment(attachment, peer_id=123) + voice = await vkontakte.upload_attachment(attachment, peer_id=123) assert voice.type == "graffiti" assert voice.id == "graffiti87641997_497831521" @@ -233,7 +233,7 @@ async def test(): attachment = Attachment.new(b"content", type="video") with pytest.raises(ValueError): - await vkontakte._upload_attachment(attachment, peer_id=123) + await vkontakte.upload_attachment(attachment, peer_id=123) asyncio.get_event_loop().run_until_complete(test()) @@ -288,7 +288,7 @@ async def req(method, kwargs): assert method == "messages.send" assert kwargs["attachment"] == "hey,hoy" return 1 - vkontakte.request = req + vkontakte._request = req result = asyncio.get_event_loop().run_until_complete( vkontakte.perform_send(1, "text", ("hey", "hoy"), {}) @@ -302,10 +302,10 @@ def test_perform_send_sticker(): async def req(method, kwargs): assert method == "messages.send" - assert kwargs["attachment"] == "" + assert "attachment" not in kwargs assert kwargs["sticker_id"] == "123" return 1 - vkontakte.request = req + vkontakte._request = req sticker_attachment = Attachment.existing("123", "sticker") @@ -321,13 +321,13 @@ def test_perform_send_new(): async def _upl_att(attachment, peer_id): return attachment._replace(id=1, raw={"ok": "ok"}) - vkontakte._upload_attachment = _upl_att + vkontakte.upload_attachment = _upl_att async def req(method, kwargs): assert method == "messages.send" assert kwargs["attachment"] == "1" return 1 - vkontakte.request = req + vkontakte._request = req attachment = Attachment.new(b"content", "image") @@ -345,7 +345,7 @@ async def req(method, kwargs): assert method == "method" assert kwargs["arg"] == "val" return 1 - vkontakte.request = req + vkontakte._request = req result = asyncio.get_event_loop().run_until_complete( vkontakte.perform_api_call("method", {"arg": "val"}) @@ -354,7 +354,7 @@ async def req(method, kwargs): assert result == 1 -@patch("kutana.backends.Vkontakte.request") +@patch("kutana.backends.Vkontakte._request") @patch("kutana.backends.Vkontakte._upload_file_to_vk") def test_upload_attachment_error_no_retry( mock_upload_file_to_vk, @@ -373,11 +373,11 @@ def test_upload_attachment_error_no_retry( with pytest.raises(RequestException): asyncio.get_event_loop().run_until_complete( - vkontakte._upload_attachment(Attachment.new(b"")) + vkontakte.upload_attachment(Attachment.new(b"")) ) -@patch("kutana.backends.Vkontakte.request") +@patch("kutana.backends.Vkontakte._request") @patch("kutana.backends.Vkontakte._upload_file_to_vk") @patch("kutana.backends.Vkontakte._make_attachment") def test_upload_attachment_error_retry( @@ -402,7 +402,7 @@ def test_upload_attachment_error_retry( vkontakte = Vkontakte("token") result = asyncio.get_event_loop().run_until_complete( - vkontakte._upload_attachment(Attachment.new(b""), peer_id=123) + vkontakte.upload_attachment(Attachment.new(b""), peer_id=123) ) assert result == "ok" @@ -485,6 +485,9 @@ async def _get_response(self, method, kwargs={}): @echo_plugin.on_commands(["echo", "ec"]) async def _(message, ctx): + assert ctx.resolve_screen_name + assert ctx.reply + await ctx.reply(message.text, attachments=message.attachments) app.add_plugin(echo_plugin)