diff --git a/slackronyms/.slack/.gitignore b/slackronyms/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/slackronyms/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/slackronyms/.slack/config.json b/slackronyms/.slack/config.json new file mode 100644 index 0000000..fa7669f --- /dev/null +++ b/slackronyms/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "725579d7-7881-4412-8090-c011a560fa42" +} diff --git a/slackronyms/.slack/hooks.json b/slackronyms/.slack/hooks.json new file mode 100644 index 0000000..ce474c9 --- /dev/null +++ b/slackronyms/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/slackronyms/app.py b/slackronyms/app.py new file mode 100644 index 0000000..4780df2 --- /dev/null +++ b/slackronyms/app.py @@ -0,0 +1,25 @@ +import logging +import os + +from listeners import register_listeners +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +logging.basicConfig(level=logging.DEBUG) + +# From OAuth & Permissions tab +bot_user_oauth_token = os.environ.get("SLACK_BOT_TOKEN") + +# From Basic Information tab's App-Level Tokens section +app_token = os.environ.get("SLACK_APP_TOKEN") + +# Initialization +app = App(token=bot_user_oauth_token) + +# Register Listeners +register_listeners(app) + +# Start Bolt app +if __name__ == "__main__": + # app.start(port=8080) + SocketModeHandler(app, app_token).start() diff --git a/slackronyms/data/.gitkeep b/slackronyms/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/slackronyms/listeners/__init__.py b/slackronyms/listeners/__init__.py new file mode 100644 index 0000000..b647612 --- /dev/null +++ b/slackronyms/listeners/__init__.py @@ -0,0 +1,5 @@ +from listeners import commands + + +def register_listeners(app): + commands.register(app) diff --git a/slackronyms/listeners/commands/__init__.py b/slackronyms/listeners/commands/__init__.py new file mode 100644 index 0000000..204b0a6 --- /dev/null +++ b/slackronyms/listeners/commands/__init__.py @@ -0,0 +1,6 @@ +from slack_bolt import App + +from .define import define_callback + +def register(app: App): + app.command("/define")(define_callback) diff --git a/slackronyms/listeners/commands/define.py b/slackronyms/listeners/commands/define.py new file mode 100644 index 0000000..a49d9ee --- /dev/null +++ b/slackronyms/listeners/commands/define.py @@ -0,0 +1,57 @@ +import json +from datetime import datetime as dt +from logging import Logger + +from slack_bolt import Ack, Respond + + +def define_callback(command, ack: Ack, respond: Respond, logger: Logger): + logger.debug(command) + + # TODO: DETERMINE HOW TO HANDLE REMOTE STORAGE OF THIS JSON FILE + + # load the current list of terms into a dict + with open("data/terms.json", mode="r", encoding="utf-8") as read_file: + terms = json.load(read_file) + + # split command text at the first colon (if any) and detemine whether we have a term + text = command["text"] + text = text.split(":", maxsplit=1) + term = text[0].strip() + + # check whether the term we ened up with isn't just an empty string + if term: + try: + if len(text) == 2: + # the split resulted in the list `text` having two items, so this is a request to set a definition + definition = text[1].strip() + if definition: + ack() + terms[term] = { + "definition": definition, + "added_by": command["user_name"], + "added_on": dt.now().strftime("%Y-%m-%d %H:%M:%S"), + } + with open("data/terms.json", mode="w", encoding="utf-8") as write_file: + json.dump(terms, write_file, indent=2) + respond(f'*{term}* is now defined as "{definition}"', response_type="ephemeral") + else: + ack("Please specify a definition after the colon.") + else: + # the split resulted in the list `text` having only one item, so this is a request to output a definition + ack() + + # TODO: MATCH TERMS CASE INSENSITIVELY + + if term_dict := terms.get(term): + # definition exists, so spit it out publicly + respond( + f"Someone asked what *{term}* means. It means: *{term_dict['definition']}*", response_type="in_channel" + ) + else: + # definition doesn't exist, let the requester know that privately + respond(f"*{term}* not found. Use `/define {term}: ` to add it.", response_type="ephemeral") + except Exception as e: + logger.error(e) + else: + ack("You must specify a term.") diff --git a/slackronyms/manifest.json b/slackronyms/manifest.json new file mode 100644 index 0000000..470e2a9 --- /dev/null +++ b/slackronyms/manifest.json @@ -0,0 +1,58 @@ +{ + "_metadata": { + "major_version": 1, + "minor_version": 1 + }, + "display_information": { + "name": "Slackronyms" + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": false, + "messages_tab_read_only_enabled": true + }, + "bot_user": { + "display_name": "Slackronyms", + "always_online": false + }, + "shortcuts": [ + { + "name": "Run sample shortcut", + "type": "global", + "callback_id": "sample_shortcut_id", + "description": "Runs a sample shortcut" + } + ], + "slash_commands": [ + { + "command": "/define", + "description": "Explain the given term. Add a colon and definition to set it.", + "usage_hint": "term[: definition]", + "should_escape": false + }, + { + "command": "/sample-command", + "description": "Runs a sample command", + "should_escape": false + } + ] + }, + "oauth_config": { + "scopes": { + "bot": ["chat:write", "chat:write.public", "commands"] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": ["app_home_opened", "message.channels"] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "is_mcp_enabled": false + } +} diff --git a/slackronyms/requirements.txt b/slackronyms/requirements.txt new file mode 100644 index 0000000..0b4fef7 --- /dev/null +++ b/slackronyms/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.3 +slack-bolt==1.28.0 +slack-cli-hooks<1.0.0 +slack-sdk==3.42.0 diff --git a/slackronyms/tests/__init__.py b/slackronyms/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slackronyms/tests/listeners/__init__.py b/slackronyms/tests/listeners/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slackronyms/tests/listeners/commands/__init__.py b/slackronyms/tests/listeners/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/slackronyms/tests/listeners/commands/test_define.py b/slackronyms/tests/listeners/commands/test_define.py new file mode 100644 index 0000000..4e25691 --- /dev/null +++ b/slackronyms/tests/listeners/commands/test_define.py @@ -0,0 +1,41 @@ +import logging +from unittest.mock import Mock + +from listeners.commands.define import define_callback +from slack_bolt import Ack, Respond + +test_logger = logging.getLogger(__name__) + + +class TestDefine: + def setup_method(self): + self.fake_ack = Mock(Ack) + self.fake_respond = Mock(Respond) + self.fake_command = {"text": "TEST"} + + def test_define_callback_definition(self): + command = {"text": "who"} + define_callback( + command, + ack=self.fake_ack, + respond=self.fake_respond, + logger=test_logger, + ) + + self.fake_ack.assert_called_once() + + self.fake_respond.assert_called_once() + args = self.fake_respond.call_args.args + assert "he's on first" in args[0] + + def test_ack_exception(self, caplog): + self.fake_ack.side_effect = Exception("test exception") + define_callback( + self.fake_command, + ack=self.fake_ack, + respond=self.fake_respond, + logger=test_logger, + ) + + self.fake_ack.assert_called_once() + assert str(self.fake_ack.side_effect) in caplog.text