diff --git a/CHANGELOG.md b/CHANGELOG.md index a698cff..b5284d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ > Changes to public API are marked as `^`. Possible changes > to public API are marked as `^?`. +- v5.1.0 + - (VKontakte) Added decorator `@plugin.vk.on_callbacks` (closes #58). + - (VKontakte) Changed API version from `5.122` to `5.131`. + - Added requests capturing to debug backend. + - Updated `reply` to raise if no `default_target_id` found. + - v5.0.5 - ^(VKontakte) Attachments with type `image` now returns largest image. - Added support for expect_sender for telegram 'callback_query'. diff --git a/kutana/backends/debug.py b/kutana/backends/debug.py index 48dabc6..5a718b3 100644 --- a/kutana/backends/debug.py +++ b/kutana/backends/debug.py @@ -19,6 +19,7 @@ def __init__(self, messages=None, on_complete=None, save_replies=True, **kwargs) self.answers_count = 0 self.responses = [] + self.requests = [] self.on_complete = on_complete @@ -60,6 +61,9 @@ async def execute_send(self, target_id, message, attachments, kwargs): self.check_if_complete() + async def execute_request(self, method, kwargs): + self.requests.append((method, kwargs)) + def check_if_complete(self): if self.messages or not self.on_complete: return diff --git a/kutana/backends/vkontakte/backend.py b/kutana/backends/vkontakte/backend.py index 93ea845..8c5b061 100644 --- a/kutana/backends/vkontakte/backend.py +++ b/kutana/backends/vkontakte/backend.py @@ -27,7 +27,7 @@ def __init__( token, session=None, requests_per_second=19, - api_version="5.122", + api_version="5.131", api_url="https://api.vk.com", **kwargs, ): @@ -53,10 +53,10 @@ def __init__( self.default_updates_settings = dict( message_new=1, message_reply=0, message_allow=0, - message_deny=0, message_edit=0, photo_new=0, audio_new=0, - video_new=0, wall_reply_new=0, wall_reply_edit=0, - wall_reply_delete=0, wall_reply_restore=0, wall_post_new=0, - wall_repost=0, board_post_new=0, board_post_edit=0, + message_deny=0, message_edit=0, message_event=1, + photo_new=0, audio_new=0, video_new=0, wall_reply_new=0, + wall_reply_edit=0, wall_reply_delete=0, wall_reply_restore=0, + wall_post_new=0, wall_repost=0, board_post_new=0, board_post_edit=0, board_post_restore=0, board_post_delete=0, photo_comment_new=0, photo_comment_edit=0, photo_comment_delete=0, photo_comment_restore=0, video_comment_new=0, diff --git a/kutana/backends/vkontakte/extensions.py b/kutana/backends/vkontakte/extensions.py index 087e8ac..d9a4ca5 100644 --- a/kutana/backends/vkontakte/extensions.py +++ b/kutana/backends/vkontakte/extensions.py @@ -36,8 +36,10 @@ def _to_hashable(self, obj): return tuple( (k, self._to_hashable(v)) for k, v in sorted(obj.items()) ) + if isinstance(obj, list): return tuple(self._to_hashable(o) for o in obj) + return obj def _get_keys(self, update, ctx): @@ -64,6 +66,23 @@ def add_handler(self, handler, key): return super().add_handler(handler, self._to_hashable(key)) +class CallbackPayloadRouter(PayloadRouter): + def _get_keys(self, update, ctx): + if ctx.backend.get_identity() != "vkontakte": + return + + if update.type != UpdateType.UPD or update.raw["type"] != "message_event": + return + + payload = update.raw["object"]["payload"] + + if isinstance(payload, dict): + for key_set in self.possible_key_sets: + yield self._to_hashable(pick(payload, key_set)) + else: + yield self._to_hashable(payload) + + class ActionMessageRouter(MapRouter): __slots__ = () @@ -143,6 +162,70 @@ async def wrapper(update, ctx): return decorator + def on_callbacks( + self, + payloads, + priority=0, + router_priority=None, + ): + """ + Decorator for registering coroutine to be called when + incoming update is message event and payload have specified + content. Excessive fields in objects are ignored. Use + strings and numbers for exact matching. + + Context is automatically populated with following values: + + - payload + - message_event (object with fields event_id, user_id and peer_id) + - conversation_message_id + - sendMessageEventAnswer (helper method for "messages.sendMessageEventAnswer") + + Currently wildcards are not supported. You can always use objects in your + buttons, and then "@plugin.vk.on_callbacks([{}])" will catch all callbacks, + because provided snippet only checks that payload is dict. + + See :class:`kutana.plugin.Plugin.on_commands` for details + about 'priority' and 'router_priority'. + """ + + def decorator(func): + async def wrapper(update, ctx): + obj = update.raw["object"] + + ctx.payload = obj.get("payload") + + ctx.message_event = { + "event_id": obj["event_id"], + "user_id": obj["user_id"], + "peer_id": obj["peer_id"], + } + + ctx.conversation_message_id = obj.get("conversation_message_id") + + async def send_message_event_answer(event_data, **kwargs): + return await ctx.request("messages.sendMessageEventAnswer", **{ + "event_data": json.dumps(event_data), + **ctx.message_event, + **kwargs, + }) + + ctx.send_message_event_answer = send_message_event_answer + + return await func(update, ctx) + + for payload in payloads: + self.plugin._add_handler_for_router( + CallbackPayloadRouter, + handler=Handler(wrapper, priority), + handler_key=payload, + router_priority=router_priority, + ) + + return func + + return decorator + def on_message_actions( self, action_types, diff --git a/kutana/context.py b/kutana/context.py index 98f31cf..b7805a4 100644 --- a/kutana/context.py +++ b/kutana/context.py @@ -121,6 +121,9 @@ async def reply(self, message, attachments=(), **kwargs): If message is too long - it will be splitted. """ + if self.default_target_id is None: + raise RuntimeError("Target for reply is not found") + return await self.send_message( self.default_target_id, message, diff --git a/kutana/plugin.py b/kutana/plugin.py index c5c85d0..478efa8 100644 --- a/kutana/plugin.py +++ b/kutana/plugin.py @@ -54,10 +54,10 @@ def _get_or_add_router(self, router_cls, priority=None): def _add_handler_for_router(self, router, handler, handler_key=None, router_priority=None): router = self._get_or_add_router(router, priority=router_priority) - if handler_key: - router.add_handler(handler, handler_key) - else: + if handler_key is None: router.add_handler(handler) + else: + router.add_handler(handler, handler_key) @property def storage(self): diff --git a/setup.py b/setup.py index b5a960c..f809c94 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ import setuptools -VERSION = "5.0.5" +VERSION = "5.1.0.dev0" with open("README.md", "r") as fh: diff --git a/tests/test_context.py b/tests/test_context.py index da658a1..589ea8f 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -8,7 +8,17 @@ def test_reply_non_str(): ctx = Context(backend=MagicMock(execute_send=CoroutineMock())) ctx.default_target_id = 1 asyncio.get_event_loop().run_until_complete(ctx.reply(123)) - ctx.backend.execute_send.assert_called_with(1, '123', (), {}) + ctx.backend.execute_send.assert_called_with(1, "123", (), {}) + + +def test_reply_no_default_target(): + ctx = Context(backend=MagicMock(execute_send=CoroutineMock())) + ctx.default_target_id = None + + with pytest.raises(RuntimeError): + asyncio.get_event_loop().run_until_complete(ctx.reply("hey")) + + ctx.backend.execute_send.assert_not_called() def test_context(): diff --git a/tests/test_vkontakte.py b/tests/test_vkontakte.py index 451d5a2..55df23c 100644 --- a/tests/test_vkontakte.py +++ b/tests/test_vkontakte.py @@ -667,7 +667,6 @@ async def _get_response(self, method, kwargs={}): 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) diff --git a/tests/test_vkontakte_data.py b/tests/test_vkontakte_data.py index ad21720..0849406 100644 --- a/tests/test_vkontakte_data.py +++ b/tests/test_vkontakte_data.py @@ -14,4 +14,7 @@ ".echo": {'type': 'message_new', 'object': {"message": {'date': 1569715006, 'from_id': 87641997, 'id': 47101, 'out': 0, 'peer_id': 87641997, 'text': '.echo', 'conversation_message_id': 42164, 'fwd_messages': [], 'important': False, 'random_id': 0, 'attachments': [], 'is_hidden': False}, 'group_id': 342296307}}, ".echo chat": {'type': 'message_new', 'object': {"message": {'date': 1569715006, 'from_id': 87641997, 'id': 47101, 'out': 0, 'peer_id': 2000000000 + 87641997, 'text': '[sdffff23f23|Спасибо 2] .echo chat [michaelkrukov|Михаил]', 'conversation_message_id': 42164, 'fwd_messages': [], 'important': False, 'random_id': 0, 'attachments': [], 'is_hidden': False}, 'group_id': 342296307}}, ".echo wa": {'type': 'message_new', 'object': {"message": {'date': 1569715006, 'from_id': 87641997, 'id': 47101, 'out': 0, 'peer_id': 87641997, 'text': '.echo wa', 'conversation_message_id': 42164, 'fwd_messages': [], 'important': False, 'random_id': 0, 'attachments': [ATTACHMENTS["image"]], 'is_hidden': False}, 'group_id': 342296307}}, + "inline_callback_1": {'type': 'message_event', 'object': {'user_id': 87641997, 'peer_id': 87641997, 'event_id': '3159dc190b1g', 'payload': {'val': 1}, 'conversation_message_id': 42164}, 'group_id': 1, 'event_id': '728189fc2e495db59e4b169g881b6666c205ee71'}, + "inline_callback_2": {'type': 'message_event', 'object': {'user_id': 87641997, 'peer_id': 87641997, 'event_id': '3159dc190b1g', 'payload': {'val': 2}, 'conversation_message_id': 42164}, 'group_id': 1, 'event_id': '728189fc2e495db59e4b169g881b6666c205ee71'}, + "inline_callback_val": {'type': 'message_event', 'object': {'user_id': 87641997, 'peer_id': 87641997, 'event_id': '3159dc190b1g', 'payload': 'val', 'conversation_message_id': 42164}, 'group_id': 1, 'event_id': '728189fc2e495db59e4b169g881b6666c205ee71'}, } diff --git a/tests/test_vkontakte_extensions.py b/tests/test_vkontakte_extensions.py index dc857ce..f89c1c4 100644 --- a/tests/test_vkontakte_extensions.py +++ b/tests/test_vkontakte_extensions.py @@ -1,7 +1,8 @@ import pytest from kutana import ( - Plugin, Message, Update, UpdateType, Attachment, HandlerResponse as hr, + Plugin, Message, Update, UpdateType, HandlerResponse as hr, ) +from test_vkontakte_data import MESSAGES from testing_tools import make_kutana_no_run @@ -178,3 +179,73 @@ async def __(msg, ctx): assert len(debug.answers[1]) == 2 assert debug.answers[1][0] == ("hello", (), {}) assert debug.answers[1][1] == ("sup", (), {}) + + +def test_on_callback(): + app, debug, hu = make_kutana_no_run(backend_source="vkontakte") + + pl = Plugin("") + + @pl.vk.on_callbacks([{"val": 1}]) + async def __(upd, ctx): + await ctx.send_message_event_answer({ + "type": "show_snackbar", + "text": "hey hey hey", + }) + + @pl.vk.on_callbacks(['val']) + async def __(upd, ctx): + await ctx.send_message_event_answer({ + "type": "show_snackbar", + "text": "val val val", + }) + + app.add_plugin(pl) + + hu(Update(MESSAGES["inline_callback_1"], UpdateType.UPD)) + hu(Update(MESSAGES["inline_callback_2"], UpdateType.UPD)) + hu(Update(MESSAGES["inline_callback_val"], UpdateType.UPD)) + hu(Message(make_message_update({"val": 1}), UpdateType.MSG, "hey3", (), 1, 0, 0, 0)) + + assert debug.requests == [ + ("messages.sendMessageEventAnswer", { + 'event_data': '{"type": "show_snackbar", "text": "hey hey hey"}', + 'event_id': '3159dc190b1g', + 'user_id': 87641997, + 'peer_id': 87641997, + }), + ("messages.sendMessageEventAnswer", { + 'event_data': '{"type": "show_snackbar", "text": "val val val"}', + 'event_id': '3159dc190b1g', + 'user_id': 87641997, + 'peer_id': 87641997, + }), + ] + + +def test_ignore_non_vkontakte(): + app, debug, hu = make_kutana_no_run() + + pl = Plugin("") + + @pl.vk.on_payloads([{"command": "echo"}]) + async def __(msg, ctx): + await ctx.reply(ctx.payload["text"]) + + @pl.vk.on_callbacks([{"val": 1}]) + async def __(upd, ctx): + await ctx.send_message_event_answer({ + "type": "show_snackbar", + "text": "hey hey hey", + }) + + app.add_plugin(pl) + + raw1 = make_message_update('{"command": "echo", "text": "hello"}') + raw2 = MESSAGES["inline_callback_1"] + + hu(Message(raw1, UpdateType.MSG, "hey1", (), 1, 0, 0, 0)) + hu(Update(raw2, UpdateType.UPD)) + + assert not debug.answers + assert not debug.requests