diff --git a/slackbot/__init__.py b/slackbot/__init__.py index f0ad6b9..ff83e31 100644 --- a/slackbot/__init__.py +++ b/slackbot/__init__.py @@ -1,6 +1,6 @@ from functools import lru_cache -__version__ = "0.1.6" +__version__ = "0.1.7" @lru_cache diff --git a/slackbot/base.py b/slackbot/base.py index e9316ca..2c6a72f 100644 --- a/slackbot/base.py +++ b/slackbot/base.py @@ -27,6 +27,25 @@ def post_ephemeral(self, **kwargs): kwargs["as_user"] = kwargs.get("as_user", 1) return self.web.chat_postEphemeral(**kwargs) + def process_reaction(self, reaction_event: dict) -> Optional[Union[int, tuple[int, int]]]: + """ + Handles reactions added or removed in Slack messages. + + :param reaction_event: A dictionary containing reaction event data. + :return: None, self.STOP, self.PROCESSED, or tuple (self.PROCESSED, self.STOP) + + PROCESSED if anything was done with input + STOP if no other processor should be called after this one + """ + if "reaction" not in reaction_event: + return # Ignore events that are not reactions + + return self.handle_reaction(reaction_event) + + def handle_reaction(self, reaction_event) -> Optional[Union[int, tuple[int, int]]]: + # This method is currently a placeholder. + pass + def process(self, message, **kw) -> Optional[Union[int, tuple[int, int]]]: """ :return: None, self.STOP, self.PROCESSED, or tuple PROCESSED,STOP diff --git a/slackbot/management/commands/run_bot.py b/slackbot/management/commands/run_bot.py index fdbc077..cae007f 100644 --- a/slackbot/management/commands/run_bot.py +++ b/slackbot/management/commands/run_bot.py @@ -40,6 +40,23 @@ def handle_message(self, **payload): close_old_connections() self.handle_message_really(**payload) + def handle_reaction(self, **payload): + event = payload.get("event", {}) + processed_at_least_one = False + for p in self.processors: + try: + r = p.process_reaction(event) + if r: + if not isinstance(r, tuple): + r = (r,) + if MessageProcessor.PROCESSED in r: + processed_at_least_one = True + if MessageProcessor.STOP in r: + break + except Exception as e: + self.log_exception("Processor failed for reaction event: %s %s", event, str(e)) + return processed_at_least_one + def handle_message_really(self, **payload): event = payload.get("event") @@ -80,8 +97,8 @@ def handle_message_really(self, **payload): processed_at_least_one = True if MessageProcessor.STOP in r: break - except Exception as exc: - self.log_exception(f"Processor {str(p)} failed with {str(exc)} for message {message}") + except Exception as e: + self.log_exception("Processor failed for message: %s %s", message, str(e)) # If private DM if channel[0] == "D": @@ -118,9 +135,19 @@ def process(self, client: SocketModeClient, req: SocketModeRequest): response = SocketModeResponse(envelope_id=req.envelope_id) client.send_socket_mode_response(response) - if req.payload["event"]["type"] == "message" and req.payload["event"].get("subtype") is None: + event = req.payload["event"] + + if event["type"] == "message" and event.get("subtype") is None: return self.handle_message(**req.payload) + def process_reaction(self, client: SocketModeClient, req: SocketModeRequest): + if req.type == "events_api": + response = SocketModeResponse(envelope_id=req.envelope_id) + client.send_socket_mode_response(response) + event = req.payload["event"] + if event["type"] in ("reaction_added", "reaction_removed"): + return self.handle_reaction(**req.payload) + def handle(self, *args, **options): # faster cold boot from slack_sdk.web.client import WebClient @@ -133,6 +160,7 @@ def handle(self, *args, **options): ) self.set_up() self.client.socket_mode_request_listeners.append(self.process) + self.client.socket_mode_request_listeners.append(self.process_reaction) self.stdout.write("Connecting...\n") self.client.connect() diff --git a/testapp/tests/test_run_bot.py b/testapp/tests/test_run_bot.py new file mode 100644 index 0000000..f6d1461 --- /dev/null +++ b/testapp/tests/test_run_bot.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import patch, MagicMock +from slackbot.management.commands.run_bot import Command +from slack_sdk.socket_mode.request import SocketModeRequest + + +class TestSlackBotCommand(unittest.TestCase): + def setUp(self): + self.command = Command() + self.command.web = MagicMock() + self.command.client = MagicMock() + self.command.my_id = "U123456" + self.command.my_id_match = "<@U123456>" + self.command.processors = [] + + @patch("slackbot.management.commands.run_bot.close_old_connections") + def test_handle_message(self, mock_close_old_connections): + payload = { + "event": { + "channel": "C12345", + "user": "U67890", + "text": "Hello, bot!", + "ts": "123456.789", + } + } + result = self.command.handle_message(**payload) + self.assertFalse(result) # No processors, so should return False + + @patch("threading.Event.wait", return_value=None) + @patch("slackbot.management.commands.run_bot.SocketModeClient") + @patch("slackbot.management.commands.run_bot.settings") + def test_handle(self, mock_settings, mock_socket_client, mock_event_wait): + mock_settings.SLACKBOT_BOT_TOKEN = "xoxb-123" + mock_settings.SLACKBOT_APP_TOKEN = "xapp-123" + + mock_client_instance = mock_socket_client.return_value + self.command.set_up = MagicMock() + self.command.handle() + + self.command.set_up.assert_called_once() + mock_client_instance.connect.assert_called_once() + + def test_handle_reaction(self): + payload = { + "event": { + "type": "reaction_added", + "item": {"channel": "C12345"}, + "user": "U67890", + "event_ts": "123456.789", + } + } + result = self.command.handle_reaction(**payload) + self.assertFalse(result) # No processors, so should return False + + def test_process_event_message(self): + request = SocketModeRequest( + type="events_api", + envelope_id="envelope123", + payload={"event": {"type": "message", "subtype": None, "text": "Hello!"}}, + ) + + self.command.handle_message = MagicMock(return_value=True) + result = self.command.process(self.command.client, request) + + self.command.handle_message.assert_called_once() + self.assertTrue(result) + + def test_post_message(self): + self.command.post_message(channel="C12345", text="Hello!") + self.command.web.chat_postMessage.assert_called_once_with(channel="C12345", text="Hello!", as_user=1) + + def test_post_ephemeral(self): + self.command.post_ephemeral(channel="C12345", text="Hello!", user="U67890") + self.command.web.chat_postEphemeral.assert_called_once_with( + channel="C12345", text="Hello!", user="U67890", as_user=True + ) + + @patch( + "slackbot.management.commands.run_bot.unicodedata.normalize", + return_value="Hello bot!", + ) + def test_handle_message_really_reacts_when_no_processors(self, mock_normalize): + self.command.web.reactions_add = MagicMock() + self.command.post_ephemeral = MagicMock() + + payload = { + "event": { + "channel": "D12345", + "user": "U67890", + "text": "Hello!", + "ts": "123456.789", + "team": "T123", + } + } + + self.command.handle_message_really(**payload) + + self.command.web.reactions_add.assert_called_once_with( + name="surface_not_found", channel="D12345", timestamp="123456.789" + ) + self.command.post_ephemeral.assert_called_once() + + +if __name__ == "__main__": + unittest.main()