diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index caa693e..671a84c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -22,7 +22,7 @@ jobs:
- name: Install dependencies
run: |
pip install -U pip
- pip install .
+ pip install -r requirements.txt
pip install -r requirements/testing.txt
- name: Run tests
run: |
diff --git a/README.md b/README.md
index 285a3bd..6b3164c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,154 @@
-# python-slack-hooks
-Helper library implementing the contract between the Slack CLI and Bolt for Python
+
Python Slack Hooks
+
+A helper library implementing the contract between the
+[Slack CLI][slack-cli-docs] and
+[Bolt for Python](https://slack.dev/bolt-python/)
+
+## Environment requirements
+
+Before getting started, make sure you have a development workspace where you
+have permissions to install apps. **Please note that leveraging all features in
+this project require that the workspace be part of
+[a Slack paid plan](https://slack.com/pricing).**
+
+### Install the Slack CLI
+
+Install the Slack CLI. Step-by-step instructions can be found in this
+[Quickstart Guide][slack-cli-docs].
+
+### Environment Setup
+
+Create a project folder and a
+[virtual environment](https://docs.python.org/3/library/venv.html#module-venv)
+within it
+
+```zsh
+# Python 3.6+ required
+mkdir myproject
+cd myproject
+python3 -m venv .venv
+```
+
+Activate the environment
+
+```zsh
+source .venv/bin/activate
+```
+
+### Pypi
+
+Install this package using pip.
+
+```zsh
+pip install -U slack-cli-hooks
+```
+
+### Clone
+
+Clone this project using git.
+
+```zsh
+git clone https://github.com/slackapi/python-slack-hooks.git
+```
+
+Follow the
+[Develop Locally](https://github.com/slackapi/python-slack-hooks/blob/main/.github/maintainers_guide.md#develop-locally)
+steps in the maintainers guide to build and use this package.
+
+## Simple project
+
+In the same directory where we installed `slack-cli-hooks`
+
+1. Define basic information and metadata about our app via an
+ [App Manifest](https://api.slack.com/reference/manifests) (`manifest.json`).
+2. Create a `slack.json` file that defines the interface between the
+ [Slack CLI][slack-cli-docs] and [Bolt for Python][bolt-python-docs].
+3. Use an `app.py` file to define the entrypoint for a
+ [Bolt for Python][bolt-python-docs] project.
+
+### Application Configuration
+
+Define your [Application Manifest](https://api.slack.com/reference/manifests) in
+a `manifest.json` file.
+
+```json
+{
+ "display_information": {
+ "name": "simple-app"
+ },
+ "outgoing_domains": [],
+ "settings": {
+ "org_deploy_enabled": true,
+ "socket_mode_enabled": true,
+ },
+ "features": {
+ "bot_user": {
+ "display_name": "simple-app"
+ }
+ },
+ "oauth_config": {
+ "scopes": {
+ "bot": ["chat:write"]
+ }
+ }
+}
+```
+
+### CLI/Bolt Interface Configuration
+
+Define the Slack CLI configuration in a file named `slack.json`.
+
+```json
+{
+ "hooks": {
+ "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks"
+ }
+}
+```
+
+### Source code
+
+Create a [Bolt for Python][bolt-python-docs] app in a file named `app.py`.
+Alternatively you can use an existing app instead.
+
+```python
+from slack_bolt import App
+from slack_bolt.adapter.socket_mode import SocketModeHandler
+
+app = App()
+
+# Add functionality here
+
+if __name__ == "__main__":
+ SocketModeHandler(app).start()
+```
+
+## Running the app
+
+You should now be able to harness the power of the Slack CLI and Bolt.
+
+Run the app this way:
+
+```zsh
+slack run
+```
+
+## Getting Help
+
+If you get stuck we're here to help. Ensure your issue is related to this
+project and not to [Bolt for Python][bolt-python-docs]. The following are the
+best ways to get assistance working through your issue:
+
+- [Issue Tracker](https://github.com/slackapi/python-slack-hooks/issues) for
+ questions, bug reports, feature requests, and general discussion. **Try
+ searching for an existing issue before creating a new one.**
+- Email our developer support team: `support@slack.com`
+
+## Contributing
+
+Contributions are more then welcome. Please look at the
+[contributing guidelines](https://github.com/slackapi/python-slack-hooks/blob/main/.github/CONTRIBUTING.md)
+for more info!
+
+[slack-cli-docs]: https://api.slack.com/automation/cli
+[bolt-python-docs]: https://slack.dev/bolt-python/concepts
diff --git a/requirements/format.txt b/requirements/format.txt
index 6e78bf4..8df1850 100644
--- a/requirements/format.txt
+++ b/requirements/format.txt
@@ -1,6 +1,5 @@
black==22.8.0; python_version=="3.6"
black; python_version>"3.6" # Until we drop Python 3.6 support, we have to stay with this version
-flake8>=5.0.4; python_version=="3.6"
-flake8==6.0.0; python_version>"3.6"
+flake8>=5.0.4, <7;
pytype; (python_version<"3.11" or python_version>"3.11")
pytype==2023.11.29; python_version=="3.11"
diff --git a/requirements/testing.txt b/requirements/testing.txt
index d48df50..22f6998 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -1,3 +1,6 @@
# pip install -r requirements/testing.txt
pytest>=6.2.5,<7
pytest-cov>=3,<4
+Flask>=2.0.3,<4
+gevent>=22.10.2,<24
+gevent-websocket>=0.10.1,<1
diff --git a/slack_cli_hooks/__init__.py b/slack_cli_hooks/__init__.py
index e69de29..7cca419 100644
--- a/slack_cli_hooks/__init__.py
+++ b/slack_cli_hooks/__init__.py
@@ -0,0 +1,7 @@
+"""
+A Slack CLI hooks implementation in Python to build Bolt Slack apps leveraging the full power of the [Slack CLI](https://api.slack.com/automation/cli/install). Look at our [code examples](https://github.com/slackapi/python-slack-hooks/tree/main/examples) to learn how to build apps using the SLack CLI and Bolt.
+
+* Slack CLI: https://api.slack.com/automation/cli/install
+* Bolt Website: https://slack.dev/bolt-python/
+* GitHub repository: https://github.com/slackapi/python-slack-hooks
+""" # noqa: E501
diff --git a/slack_cli_hooks/error/__init__.py b/slack_cli_hooks/error/__init__.py
new file mode 100644
index 0000000..570528a
--- /dev/null
+++ b/slack_cli_hooks/error/__init__.py
@@ -0,0 +1,2 @@
+class CliError(Exception):
+ """General class for cli error"""
diff --git a/slack_cli_hooks/hooks/__init__.py b/slack_cli_hooks/hooks/__init__.py
new file mode 100644
index 0000000..2ec8be8
--- /dev/null
+++ b/slack_cli_hooks/hooks/__init__.py
@@ -0,0 +1,2 @@
+"""Slack CLI contract implementation for Bolt.
+"""
diff --git a/slack_cli_hooks/hooks/get_hooks.py b/slack_cli_hooks/hooks/get_hooks.py
new file mode 100644
index 0000000..df0a36a
--- /dev/null
+++ b/slack_cli_hooks/hooks/get_hooks.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+import json
+from slack_cli_hooks.protocol import Protocol, MessageBoundaryProtocol, DefaultProtocol, build_protocol
+
+PROTOCOL: Protocol
+EXEC = "python3"
+
+
+hooks_payload = {
+ "hooks": {
+ "get-manifest": f"{EXEC} -m slack_cli_hooks.hooks.get_manifest",
+ "start": f"{EXEC} -X dev -m slack_cli_hooks.hooks.start",
+ },
+ "config": {
+ "watch": {"filter-regex": "(^manifest\\.json$)", "paths": ["."]},
+ "protocol-version": [MessageBoundaryProtocol.name, DefaultProtocol.name],
+ "sdk-managed-connection-enabled": True,
+ },
+}
+
+if __name__ == "__main__":
+ PROTOCOL = build_protocol()
+ PROTOCOL.respond(json.dumps(hooks_payload))
diff --git a/slack_cli_hooks/hooks/get_manifest.py b/slack_cli_hooks/hooks/get_manifest.py
new file mode 100644
index 0000000..79a5caa
--- /dev/null
+++ b/slack_cli_hooks/hooks/get_manifest.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+import os
+import re
+from typing import List
+
+from slack_cli_hooks.error import CliError
+from slack_cli_hooks.protocol import Protocol, build_protocol
+
+PROTOCOL: Protocol
+
+EXCLUDED_DIRECTORIES = [
+ "lib",
+ "bin",
+ "include",
+ "node_modules",
+ "packages",
+ "logs",
+ "build",
+ "coverage",
+ "target",
+ "tmp",
+ "test",
+ "tests",
+]
+
+DIRECTORY_IGNORE_REGEX = re.compile(r"(^\.|^\_|^{}$)".format("$|^".join(EXCLUDED_DIRECTORIES)), re.IGNORECASE)
+
+
+def filter_directories(directories: List[str]) -> List[str]:
+ return [directory for directory in directories if not DIRECTORY_IGNORE_REGEX.match(directory)]
+
+
+def find_file_path(path: str, file_name: str) -> str:
+ for root, dirs, files in os.walk(path, topdown=True, followlinks=False):
+ dirs[:] = filter_directories(dirs)
+ if file_name in files:
+ return os.path.join(root, file_name)
+ raise CliError(f"Could not find a {file_name} file")
+
+
+def get_manifest(working_directory: str) -> str:
+ file_path = find_file_path(working_directory, "manifest.json")
+
+ with open(file_path, "r") as manifest:
+ return manifest.read()
+
+
+if __name__ == "__main__":
+ PROTOCOL = build_protocol()
+ PROTOCOL.respond(get_manifest(os.getcwd()))
diff --git a/slack_cli_hooks/hooks/start.py b/slack_cli_hooks/hooks/start.py
new file mode 100644
index 0000000..2866d25
--- /dev/null
+++ b/slack_cli_hooks/hooks/start.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python
+import os
+import runpy
+import sys
+
+from slack_cli_hooks.error import CliError
+from slack_cli_hooks.hooks.utils import ManagedOSEnvVars
+from slack_cli_hooks.protocol import Protocol, build_protocol
+
+PROTOCOL: Protocol
+
+DEFAULT_MAIN_FILE = "app.py"
+
+SLACK_CLI_XOXB = "SLACK_CLI_XOXB"
+SLACK_CLI_XAPP = "SLACK_CLI_XAPP"
+SLACK_BOT_TOKEN = "SLACK_BOT_TOKEN"
+SLACK_APP_TOKEN = "SLACK_APP_TOKEN"
+
+
+def validate_env() -> None:
+ if not os.environ.get(SLACK_CLI_XOXB):
+ raise CliError(f"Missing local run bot token ({SLACK_CLI_XOXB}).")
+ if not os.environ.get(SLACK_CLI_XAPP):
+ raise CliError(f"Missing local run app token ({SLACK_CLI_XAPP}).")
+
+
+def get_main_file() -> str:
+ custom_file = os.environ.get("SLACK_APP_PATH")
+ if custom_file:
+ return custom_file
+ return DEFAULT_MAIN_FILE
+
+
+def get_main_path(working_directory: str) -> str:
+ main_file = get_main_file()
+ main_raw_path = os.path.join(working_directory, main_file)
+ return os.path.abspath(main_raw_path)
+
+
+def start(working_directory: str) -> None:
+ validate_env()
+
+ entrypoint_path = get_main_path(working_directory)
+
+ if not os.path.exists(entrypoint_path):
+ raise CliError(f"Could not find {get_main_file()} file")
+
+ parent_package = os.path.dirname(entrypoint_path)
+ os_env_vars = ManagedOSEnvVars(PROTOCOL)
+
+ try:
+ os_env_vars.set_if_absent(SLACK_BOT_TOKEN, os.environ[SLACK_CLI_XOXB])
+ os_env_vars.set_if_absent(SLACK_APP_TOKEN, os.environ[SLACK_CLI_XAPP])
+ sys.path.insert(0, parent_package) # Add parent package to sys path
+
+ runpy.run_path(entrypoint_path, run_name="__main__")
+ finally:
+ sys.path.remove(parent_package)
+ os_env_vars.clear()
+
+
+if __name__ == "__main__":
+ PROTOCOL = build_protocol()
+ start(os.getcwd())
diff --git a/slack_cli_hooks/hooks/utils/__init__.py b/slack_cli_hooks/hooks/utils/__init__.py
new file mode 100644
index 0000000..4515687
--- /dev/null
+++ b/slack_cli_hooks/hooks/utils/__init__.py
@@ -0,0 +1,5 @@
+from .managed_os_env_vars import ManagedOSEnvVars
+
+__all__ = [
+ "ManagedOSEnvVars",
+]
diff --git a/slack_cli_hooks/hooks/utils/managed_os_env_vars.py b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py
new file mode 100644
index 0000000..8cd3f75
--- /dev/null
+++ b/slack_cli_hooks/hooks/utils/managed_os_env_vars.py
@@ -0,0 +1,22 @@
+import os
+from typing import List
+from slack_cli_hooks.protocol import Protocol
+
+
+class ManagedOSEnvVars:
+ def __init__(self, protocol: Protocol) -> None:
+ self._protocol = protocol
+ self._os_env_vars: List[str] = []
+
+ def set_if_absent(self, os_env_var: str, value: str) -> None:
+ if os_env_var in os.environ:
+ self._protocol.info(
+ f"{os_env_var} environment variable detected in session, using it over the provided one!"
+ )
+ return
+ self._os_env_vars.append(os_env_var)
+ os.environ[os_env_var] = value
+
+ def clear(self) -> None:
+ for os_env_var in self._os_env_vars:
+ os.environ.pop(os_env_var, None)
diff --git a/slack_cli_hooks/protocol/__init__.py b/slack_cli_hooks/protocol/__init__.py
new file mode 100644
index 0000000..f24ce45
--- /dev/null
+++ b/slack_cli_hooks/protocol/__init__.py
@@ -0,0 +1,24 @@
+import argparse
+import sys
+from typing import List
+from .default_protocol import DefaultProtocol
+from .message_boundary_protocol import MessageBoundaryProtocol
+from .protocol import Protocol
+
+__all__ = [
+ "DefaultProtocol",
+ "MessageBoundaryProtocol",
+ "Protocol",
+]
+
+
+def build_protocol(argv: List[str] = sys.argv[1:]) -> Protocol:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--protocol", type=str, required=False)
+ parser.add_argument("--boundary", type=str, required=False)
+
+ args, unknown = parser.parse_known_args(args=argv)
+
+ if args.protocol == MessageBoundaryProtocol.name:
+ return MessageBoundaryProtocol(boundary=args.boundary)
+ return DefaultProtocol()
diff --git a/slack_cli_hooks/protocol/default_protocol.py b/slack_cli_hooks/protocol/default_protocol.py
new file mode 100644
index 0000000..d1911dd
--- /dev/null
+++ b/slack_cli_hooks/protocol/default_protocol.py
@@ -0,0 +1,24 @@
+from .protocol import Protocol
+
+
+class DefaultProtocol(Protocol):
+ name: str = "default"
+
+ def debug(self, msg: str, *args, **kwargs):
+ """Nothing will be logged here"""
+ pass
+
+ def info(self, msg: str, *args, **kwargs):
+ """Nothing will be logged here"""
+ pass
+
+ def warning(self, msg: str, *args, **kwargs):
+ """Nothing will be logged here"""
+ pass
+
+ def error(self, msg: str, *args, **kwargs):
+ """Nothing will be logged here"""
+ pass
+
+ def respond(self, data: str):
+ print(data)
diff --git a/slack_cli_hooks/protocol/message_boundary_protocol.py b/slack_cli_hooks/protocol/message_boundary_protocol.py
new file mode 100644
index 0000000..db987e4
--- /dev/null
+++ b/slack_cli_hooks/protocol/message_boundary_protocol.py
@@ -0,0 +1,24 @@
+import logging
+from .protocol import Protocol
+
+
+class MessageBoundaryProtocol(Protocol):
+ name: str = "message-boundaries"
+
+ def __init__(self, boundary):
+ self.boundary = boundary
+
+ def debug(self, msg: str, *args, **kwargs):
+ logging.debug(msg, *args, **kwargs)
+
+ def info(self, msg: str, *args, **kwargs):
+ logging.info(msg, *args, **kwargs)
+
+ def warning(self, msg: str, *args, **kwargs):
+ logging.warning(msg, *args, **kwargs)
+
+ def error(self, msg: str, *args, **kwargs):
+ logging.error(msg, *args, **kwargs)
+
+ def respond(self, data: str):
+ print(self.boundary + data + self.boundary)
diff --git a/slack_cli_hooks/protocol/protocol.py b/slack_cli_hooks/protocol/protocol.py
new file mode 100644
index 0000000..ae69b0d
--- /dev/null
+++ b/slack_cli_hooks/protocol/protocol.py
@@ -0,0 +1,44 @@
+import abc
+
+
+class Protocol(metaclass=abc.ABCMeta):
+ @classmethod
+ def __subclasshook__(cls, subclass):
+ return (
+ hasattr(subclass, "name")
+ and hasattr(subclass, "debug")
+ and callable(subclass.debug)
+ and hasattr(subclass, "info")
+ and callable(subclass.info)
+ and hasattr(subclass, "warning")
+ and callable(subclass.warning)
+ and hasattr(subclass, "error")
+ and callable(subclass.error)
+ and hasattr(subclass, "respond")
+ and callable(subclass.respond)
+ )
+
+ @abc.abstractmethod
+ def debug(self, msg: str, *args, **kwargs):
+ """Logs a message with level DEBUG"""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def info(self, msg: str, *args, **kwargs):
+ """Logs a message with level INFO"""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def warning(self, msg: str, *args, **kwargs):
+ """Logs a message with level WARNING"""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def error(self, msg: str, *args, **kwargs):
+ """Logs a message with level ERROR on the root logger"""
+ raise NotImplementedError
+
+ @abc.abstractmethod
+ def respond(self, data: str):
+ """Utility method for responding to CLI hook invocations"""
+ raise NotImplementedError
diff --git a/slack_cli_hooks/py.typed b/slack_cli_hooks/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/tests/mock_socket_mode_server.py b/tests/mock_socket_mode_server.py
new file mode 100644
index 0000000..c3b1703
--- /dev/null
+++ b/tests/mock_socket_mode_server.py
@@ -0,0 +1,52 @@
+import json
+import threading
+import time
+from urllib.request import urlopen
+from urllib.error import URLError
+from unittest import TestCase
+from flask import Flask
+from gevent import pywsgi
+from geventwebsocket.handler import WebSocketHandler
+
+
+def start_thread_socket_mode_server(test: TestCase, port: int):
+ def _start_thread_socket_mode_server():
+ app: Flask = Flask(__name__)
+
+ @app.route("/state")
+ def state():
+ return json.dumps({"success": True}), 200, {"ContentType": "application/json"}
+
+ server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler)
+ test.server = server
+ server.serve_forever(stop_timeout=1)
+
+ return _start_thread_socket_mode_server
+
+
+def start_socket_mode_server(test, port: int):
+ test.sm_thread = threading.Thread(target=start_thread_socket_mode_server(test, port))
+ test.sm_thread.daemon = True
+ test.sm_thread.start()
+ wait_for_socket_mode_server(port, 4) # wait for the server
+
+
+def wait_for_socket_mode_server(port: int, secs: int):
+ start_time = time.time()
+ while (time.time() - start_time) < secs:
+ try:
+ urlopen(f"http://localhost:{port}/state")
+ break
+ except URLError:
+ pass
+
+
+def stop_socket_mode_server(test):
+ print(test)
+ test.server.stop()
+ test.server.close()
+
+
+async def stop_socket_mode_server_async(test: TestCase):
+ test.server.stop()
+ test.server.close()
diff --git a/tests/mock_web_api_server.py b/tests/mock_web_api_server.py
new file mode 100644
index 0000000..7b81ab6
--- /dev/null
+++ b/tests/mock_web_api_server.py
@@ -0,0 +1,82 @@
+import json
+import logging
+import threading
+from http import HTTPStatus
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from typing import Type
+from unittest import TestCase
+from urllib.parse import urlparse, ParseResult
+
+
+class MockHandler(SimpleHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+ default_request_version = "HTTP/1.1"
+ logger = logging.getLogger(__name__)
+ received_requests = {}
+
+ def set_common_headers(self):
+ self.send_header("content-type", "application/json;charset=utf-8")
+ self.send_header("connection", "close")
+ self.end_headers()
+
+ def _handle(self):
+ parsed_path: ParseResult = urlparse(self.path)
+ path = parsed_path.path
+ self.received_requests[path] = self.received_requests.get(path, 0) + 1
+ try:
+ body = {"ok": True}
+ self.send_response(HTTPStatus.OK)
+ self.set_common_headers()
+ self.wfile.write(json.dumps(body).encode("utf-8"))
+ self.wfile.close()
+ except Exception as e:
+ self.logger.error(str(e), exc_info=True)
+ raise
+
+ def do_GET(self):
+ self._handle()
+
+ def do_POST(self):
+ self._handle()
+
+
+#
+# threading
+#
+
+
+class MockServerThread(threading.Thread):
+ def __init__(self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler):
+ threading.Thread.__init__(self)
+ self.handler = handler
+ self.test = test
+
+ def run(self):
+ self.server = HTTPServer(("localhost", 8888), self.handler)
+ self.test.mock_received_requests = self.handler.received_requests
+ self.test.server_url = "http://localhost:8888"
+ self.test.host, self.test.port = self.server.socket.getsockname()
+ self.test.server_started.set() # threading.Event()
+
+ self.test = None
+ try:
+ self.server.serve_forever(0.05)
+ finally:
+ self.server.server_close()
+
+ def stop(self):
+ self.handler.received_requests = {}
+ self.server.shutdown()
+ self.join()
+
+
+def setup_mock_web_api_server(test: TestCase):
+ test.server_started = threading.Event()
+ test.thread = MockServerThread(test)
+ test.thread.start()
+ test.server_started.wait()
+
+
+def cleanup_mock_web_api_server(test: TestCase):
+ test.thread.stop()
+ test.thread = None
diff --git a/tests/scenario_test/__init__.py b/tests/scenario_test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/scenario_test/test_app/__init__.py b/tests/scenario_test/test_app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/scenario_test/test_app/app.py b/tests/scenario_test/test_app/app.py
new file mode 100644
index 0000000..342a370
--- /dev/null
+++ b/tests/scenario_test/test_app/app.py
@@ -0,0 +1,27 @@
+import os
+
+from slack_sdk import WebClient
+from slack_bolt.app import App
+from utils import get_test_socket_mode_handler, wait_for_test_socket_connection
+
+assert "SLACK_BOT_TOKEN" in os.environ
+assert "SLACK_APP_TOKEN" in os.environ
+
+web_client = WebClient(base_url="http://localhost:8888", token=os.environ.get("SLACK_BOT_TOKEN"))
+
+app = App(signing_secret="valid", client=web_client)
+
+result = {"shortcut": False}
+
+
+@app.shortcut("do-something")
+def shortcut_handler(ack):
+ result["shortcut"] = True
+ ack()
+
+
+if __name__ == "__main__":
+ print("ran as __main__")
+ handler = get_test_socket_mode_handler(3012, app, os.environ.get("SLACK_APP_TOKEN"))
+ handler.connect()
+ wait_for_test_socket_connection(handler, 2)
diff --git a/tests/scenario_test/test_app/manifest.json b/tests/scenario_test/test_app/manifest.json
new file mode 100644
index 0000000..17331e7
--- /dev/null
+++ b/tests/scenario_test/test_app/manifest.json
@@ -0,0 +1,6 @@
+{
+ "_metadata": {},
+ "display_information": {
+ "name": "Bolt app"
+ }
+}
\ No newline at end of file
diff --git a/tests/scenario_test/test_app/my_app.py b/tests/scenario_test/test_app/my_app.py
new file mode 100644
index 0000000..0639a73
--- /dev/null
+++ b/tests/scenario_test/test_app/my_app.py
@@ -0,0 +1,18 @@
+import os
+
+from slack_sdk import WebClient
+from slack_bolt.app import App
+from utils import get_test_socket_mode_handler, wait_for_test_socket_connection
+
+assert "SLACK_BOT_TOKEN" in os.environ
+assert "SLACK_APP_TOKEN" in os.environ
+
+web_client = WebClient(base_url="http://localhost:8888", token=os.environ.get("SLACK_BOT_TOKEN"))
+
+app = App(signing_secret="valid", client=web_client)
+
+if __name__ == "__main__":
+ print("ran as __main__")
+ handler = get_test_socket_mode_handler(3012, app, os.environ.get("SLACK_APP_TOKEN"))
+ handler.connect()
+ wait_for_test_socket_connection(handler, 2)
diff --git a/tests/scenario_test/test_app/utils.py b/tests/scenario_test/test_app/utils.py
new file mode 100644
index 0000000..201036c
--- /dev/null
+++ b/tests/scenario_test/test_app/utils.py
@@ -0,0 +1,23 @@
+import time
+
+from typing import Optional
+from slack_bolt.adapter.socket_mode.builtin import SocketModeHandler
+from slack_bolt.app.app import App
+
+
+def get_test_socket_mode_handler(port: int, app: App, app_token: Optional[str]) -> SocketModeHandler:
+ handler = SocketModeHandler(
+ app,
+ app_token,
+ trace_enabled=True,
+ )
+ handler.client.wss_uri = f"ws://localhost:{str(port)}/link"
+ handler.client.default_auto_reconnect_enabled = False
+ return handler
+
+
+def wait_for_test_socket_connection(handler: SocketModeHandler, secs: int):
+ start_time = time.time()
+ while (time.time() - start_time) < secs:
+ if handler.client.is_connected() is True:
+ break
diff --git a/tests/scenario_test/test_app_no_manifest/no_app_here.py b/tests/scenario_test/test_app_no_manifest/no_app_here.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/scenario_test/test_get_hooks.py b/tests/scenario_test/test_get_hooks.py
new file mode 100644
index 0000000..26d358e
--- /dev/null
+++ b/tests/scenario_test/test_get_hooks.py
@@ -0,0 +1,24 @@
+import json
+import runpy
+
+from slack_cli_hooks.hooks import get_hooks, get_manifest, start
+
+
+class TestGetHooks:
+ def test_get_manifest(self, capsys):
+ runpy.run_module(get_hooks.__name__, run_name="__main__")
+
+ out, err = capsys.readouterr()
+ json_response = json.loads(out)
+ assert err == ""
+ assert "hooks" in json_response
+ assert get_manifest.__name__ in json_response["hooks"]["get-manifest"]
+
+ def test_start(self, capsys):
+ runpy.run_module(get_hooks.__name__, run_name="__main__")
+
+ out, err = capsys.readouterr()
+ json_response = json.loads(out)
+ assert err == ""
+ assert "hooks" in json_response
+ assert start.__name__ in json_response["hooks"]["start"]
diff --git a/tests/scenario_test/test_get_manifest.py b/tests/scenario_test/test_get_manifest.py
new file mode 100644
index 0000000..bc26522
--- /dev/null
+++ b/tests/scenario_test/test_get_manifest.py
@@ -0,0 +1,33 @@
+import json
+import os
+import runpy
+import pytest
+from slack_cli_hooks.error import CliError
+from slack_cli_hooks.hooks import get_manifest
+
+
+class TestGetManifest:
+ def setup_method(self):
+ self.cwd = os.getcwd()
+
+ def teardown_method(self):
+ os.chdir(self.cwd)
+
+ def test_get_manifest_script(self, capsys):
+ working_directory = "tests/scenario_test/test_app"
+ os.chdir(working_directory)
+
+ runpy.run_module(get_manifest.__name__, run_name="__main__")
+
+ out, err = capsys.readouterr()
+ assert err == ""
+ assert {"_metadata": {}, "display_information": {"name": "Bolt app"}} == json.loads(out)
+
+ def test_get_manifest_script_no_manifest(self):
+ working_directory = "tests/scenario_test/test_app_no_manifest"
+ os.chdir(working_directory)
+
+ with pytest.raises(CliError) as e:
+ runpy.run_module(get_manifest.__name__, run_name="__main__")
+
+ assert str(e.value) == "Could not find a manifest.json file"
diff --git a/tests/scenario_test/test_start.py b/tests/scenario_test/test_start.py
new file mode 100644
index 0000000..e88f047
--- /dev/null
+++ b/tests/scenario_test/test_start.py
@@ -0,0 +1,60 @@
+import runpy
+import pytest
+import os
+from slack_cli_hooks.error import CliError
+
+from slack_cli_hooks.hooks import start
+from tests.mock_socket_mode_server import start_socket_mode_server, stop_socket_mode_server
+from tests.mock_web_api_server import cleanup_mock_web_api_server, setup_mock_web_api_server
+from tests.utils import remove_os_env_temporarily, restore_os_env
+
+
+class TestStart:
+ working_directory = "tests/scenario_test/test_app"
+
+ def setup_method(self):
+ self.old_os_env = remove_os_env_temporarily()
+ os.environ["SLACK_CLI_XOXB"] = "xoxb-valid"
+ os.environ["SLACK_CLI_XAPP"] = "xapp-A111-222-xyz"
+ setup_mock_web_api_server(self)
+ start_socket_mode_server(self, 3012)
+ self.cwd = os.getcwd()
+
+ def teardown_method(self):
+ os.chdir(self.cwd)
+ os.environ.pop("SLACK_CLI_XOXB", None)
+ os.environ.pop("SLACK_CLI_XAPP", None)
+ os.environ.pop("SLACK_APP_PATH", None)
+ cleanup_mock_web_api_server(self)
+ stop_socket_mode_server(self)
+ restore_os_env(self.old_os_env)
+
+ def test_start_script(self, capsys, caplog):
+ os.chdir(self.working_directory)
+
+ runpy.run_module(start.__name__, run_name="__main__")
+
+ captured_sys = capsys.readouterr()
+
+ assert "ran as __main__" in captured_sys.out
+ assert "INFO A new session has been established" in caplog.text
+
+ def test_start_with_entrypoint(self, capsys, caplog):
+ os.environ["SLACK_APP_PATH"] = "my_app.py"
+ os.chdir(self.working_directory)
+
+ runpy.run_module(start.__name__, run_name="__main__")
+
+ captured_sys = capsys.readouterr()
+
+ assert "ran as __main__" in captured_sys.out
+ assert "INFO A new session has been established" in caplog.text
+
+ def test_start_no_entrypoint(self):
+ working_directory = "tests/scenario_test/"
+ os.chdir(working_directory)
+
+ with pytest.raises(CliError) as e:
+ runpy.run_module(start.__name__, run_name="__main__")
+
+ assert "Could not find app.py file" in str(e.value)
diff --git a/tests/slack_cli_hooks/__init__.py b/tests/slack_cli_hooks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/slack_cli_hooks/hooks/__init__.py b/tests/slack_cli_hooks/hooks/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/slack_cli_hooks/hooks/test_get_hooks.py b/tests/slack_cli_hooks/hooks/test_get_hooks.py
new file mode 100644
index 0000000..2486865
--- /dev/null
+++ b/tests/slack_cli_hooks/hooks/test_get_hooks.py
@@ -0,0 +1,24 @@
+import re
+from slack_cli_hooks.hooks.get_hooks import hooks_payload
+
+
+class TestGetHooks:
+ def test_hooks_payload(self):
+ hooks = hooks_payload["hooks"]
+
+ assert "slack_cli_hooks.hooks.get_manifest" in hooks["get-manifest"]
+ assert "slack_cli_hooks.hooks.start" in hooks["start"]
+
+ def test_hooks_payload_config(self):
+ config = hooks_payload["config"]
+
+ assert config["sdk-managed-connection-enabled"] is True
+ assert config["protocol-version"] == ["message-boundaries", "default"]
+
+ def test_hooks_watch_regex(self):
+ config = hooks_payload["config"]
+
+ assert config["watch"] is not None
+
+ filter_regex = config["watch"]["filter-regex"]
+ assert re.match(filter_regex, "manifest.json") is not None
diff --git a/tests/slack_cli_hooks/hooks/test_get_manifest.py b/tests/slack_cli_hooks/hooks/test_get_manifest.py
new file mode 100644
index 0000000..783a29e
--- /dev/null
+++ b/tests/slack_cli_hooks/hooks/test_get_manifest.py
@@ -0,0 +1,47 @@
+from unittest import mock
+
+import pytest
+from slack_cli_hooks.error import CliError
+from slack_cli_hooks.hooks.get_manifest import filter_directories, find_file_path
+
+
+class TestGetManifest:
+ def test_filter_directories(self):
+ test_directories = [
+ "lib",
+ "bin",
+ "include",
+ "src",
+ "tmp",
+ "test",
+ "tests",
+ ]
+
+ actual = filter_directories(test_directories)
+
+ assert actual == ["src"]
+
+ def test_find_file_path(self):
+ with mock.patch("os.walk") as mock_walk:
+ mock_walk.return_value = [
+ ["/dir", ["subdir"], ["utils.py", "my_app.py", "manifest.json", "app.py"]],
+ ["/dir/subdir", [], ["spam.json", "hello.txt"]],
+ ]
+
+ manifest_path = find_file_path("/dir", "manifest.json")
+ spam_path = find_file_path("/dir", "spam.json")
+
+ assert manifest_path == "/dir/manifest.json"
+ assert spam_path == "/dir/subdir/spam.json"
+
+ def test_find_file_path_error(self):
+ test_file_name = "test.json"
+
+ with mock.patch("os.walk") as mock_walk:
+ mock_walk.return_value = [
+ ["/dir", [], ["utils.py"]],
+ ]
+ with pytest.raises(CliError) as e:
+ find_file_path("/dir", test_file_name)
+
+ assert str(e.value) == f"Could not find a {test_file_name} file"
diff --git a/tests/slack_cli_hooks/hooks/test_start.py b/tests/slack_cli_hooks/hooks/test_start.py
new file mode 100644
index 0000000..c475117
--- /dev/null
+++ b/tests/slack_cli_hooks/hooks/test_start.py
@@ -0,0 +1,55 @@
+import pytest
+import os
+from slack_cli_hooks.error import CliError
+
+from slack_cli_hooks.hooks.start import validate_env, get_main_file, get_main_path
+from tests.utils import remove_os_env_temporarily, restore_os_env
+
+SLACK_CLI_XOXB = "SLACK_CLI_XOXB"
+SLACK_CLI_XAPP = "SLACK_CLI_XAPP"
+SLACK_APP_PATH = "SLACK_APP_PATH"
+
+
+class TestStart:
+ working_directory = "tests/slack_bolt/cli/test_app"
+
+ def setup_method(self):
+ self.old_os_env = remove_os_env_temporarily()
+
+ def teardown_method(self):
+ os.environ.pop(SLACK_CLI_XOXB, None)
+ os.environ.pop(SLACK_CLI_XAPP, None)
+ os.environ.pop(SLACK_APP_PATH, None)
+ restore_os_env(self.old_os_env)
+
+ def test_validate_env(self):
+ os.environ[SLACK_CLI_XOXB] = "xoxb-valid"
+ os.environ[SLACK_CLI_XAPP] = "xapp-A111-222-xyz"
+
+ assert validate_env() is None
+
+ def test_validate_env_with_missing_xoxb(self):
+ os.environ[SLACK_CLI_XAPP] = "xapp-A111-222-xyz"
+ with pytest.raises(CliError) as e:
+ validate_env()
+ assert str(e.value) == f"Missing local run bot token ({SLACK_CLI_XOXB})."
+
+ def test_validate_env_with_missing_xapp(self):
+ os.environ[SLACK_CLI_XOXB] = "xoxb-valid"
+ with pytest.raises(CliError) as e:
+ validate_env()
+ assert str(e.value) == f"Missing local run app token ({SLACK_CLI_XAPP})."
+
+ def test_get_main_file(self):
+ assert get_main_file() == "app.py"
+
+ def test_get_main_file_with_override(self):
+ os.environ[SLACK_APP_PATH] = "my_app.py"
+ assert get_main_file() == "my_app.py"
+
+ def test_get_main_path(self):
+ assert get_main_path("/dir") == "/dir/app.py"
+
+ def test_get_main_path_from_var(self):
+ os.environ[SLACK_APP_PATH] = "my_app.py"
+ assert get_main_path("/dir") == "/dir/my_app.py"
diff --git a/tests/slack_cli_hooks/hooks/utils/__init__.py b/tests/slack_cli_hooks/hooks/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py b/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py
new file mode 100644
index 0000000..38370f9
--- /dev/null
+++ b/tests/slack_cli_hooks/hooks/utils/test_managed_os_env_vars.py
@@ -0,0 +1,76 @@
+import os
+from slack_cli_hooks.hooks.utils import ManagedOSEnvVars
+from slack_cli_hooks.protocol import DefaultProtocol
+from tests.utils import remove_os_env_temporarily, restore_os_env
+
+TEST_VAR = "TEST"
+TEST_VAR2 = "TEST2"
+
+
+class TestManagedOSEnvVars:
+ def setup_method(self):
+ self.old_os_env = remove_os_env_temporarily()
+
+ def teardown_method(self):
+ os.environ.pop(TEST_VAR, None)
+ os.environ.pop(TEST_VAR2, None)
+ restore_os_env(self.old_os_env)
+
+ def test_set_if_absent(self):
+ expected = "expected test value"
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+ os_env_vars.set_if_absent(TEST_VAR, expected)
+
+ assert TEST_VAR in os.environ
+ assert os.environ[TEST_VAR] == expected
+
+ def test_set_default_does_not_overwrite(self):
+ expected = "default test value"
+ os.environ[TEST_VAR] = expected
+
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+ os_env_vars.set_if_absent(TEST_VAR, "nothing")
+
+ assert TEST_VAR in os.environ
+ assert os.environ[TEST_VAR] == expected
+
+ def test_clear(self):
+ expected = "expected test value"
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+ os_env_vars.set_if_absent(TEST_VAR, expected)
+
+ os_env_vars.clear()
+
+ assert not (TEST_VAR in os.environ)
+
+ def test_clear_does_not_overwrite(self):
+ expected = "expected test value"
+ os.environ[TEST_VAR] = expected
+
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+ os_env_vars.set_if_absent(TEST_VAR, "nothing")
+
+ os_env_vars.clear()
+
+ assert TEST_VAR in os.environ
+ assert os.environ[TEST_VAR] == expected
+
+ def test_clear_only_clears_absent_vars(self):
+ expected = "expected test value"
+ os.environ[TEST_VAR] = expected
+
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+ os_env_vars.set_if_absent(TEST_VAR, "nothing")
+ os_env_vars.set_if_absent(TEST_VAR2, expected)
+
+ os_env_vars.clear()
+
+ assert TEST_VAR in os.environ
+ assert os.environ[TEST_VAR] == expected
+ assert not (TEST_VAR2 in os.environ)
+
+ def test_no_env_var_set(self):
+ os_env_vars = ManagedOSEnvVars(DefaultProtocol())
+
+ os_env_vars.clear()
+ assert TEST_VAR not in os.environ
diff --git a/tests/slack_cli_hooks/protocol/__init__.py b/tests/slack_cli_hooks/protocol/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/slack_cli_hooks/protocol/test_default_protocol.py b/tests/slack_cli_hooks/protocol/test_default_protocol.py
new file mode 100644
index 0000000..2a45b0c
--- /dev/null
+++ b/tests/slack_cli_hooks/protocol/test_default_protocol.py
@@ -0,0 +1,41 @@
+from slack_cli_hooks.protocol import DefaultProtocol
+
+
+class TestDefaultProtocol:
+ def test_name(self):
+ assert DefaultProtocol.name == "default"
+
+ def test_debug(self, capsys):
+ DefaultProtocol().debug("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+
+ def test_info(self, capsys):
+ DefaultProtocol().info("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+
+ def test_warning(self, capsys):
+ DefaultProtocol().warning("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+
+ def test_error(self, capsys):
+ DefaultProtocol().error("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+
+ def test_respond(self, capsys):
+ DefaultProtocol().respond("test")
+
+ out, err = capsys.readouterr()
+ assert out == "test\n"
+ assert err == ""
diff --git a/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py b/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py
new file mode 100644
index 0000000..fcd8504
--- /dev/null
+++ b/tests/slack_cli_hooks/protocol/test_mssage_boundary_protocol.py
@@ -0,0 +1,56 @@
+from slack_cli_hooks.protocol import MessageBoundaryProtocol
+
+
+class TestMessageBoundaryProtocol:
+ def test_name(self):
+ assert MessageBoundaryProtocol.name == "message-boundaries"
+
+ def test_debug(self, capsys, caplog):
+ protocol = MessageBoundaryProtocol("bound")
+
+ protocol.debug("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+ assert "DEBUG test" in caplog.text
+
+ def test_info(self, capsys, caplog):
+ protocol = MessageBoundaryProtocol("bound")
+
+ protocol.info("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+ assert "INFO test" in caplog.text
+
+ def test_warning(self, capsys, caplog):
+ protocol = MessageBoundaryProtocol("bound")
+
+ protocol.warning("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+ assert "WARNING test" in caplog.text
+
+ def test_error(self, capsys, caplog):
+ protocol = MessageBoundaryProtocol("bound")
+
+ protocol.error("test")
+
+ out, err = capsys.readouterr()
+ assert out == ""
+ assert err == ""
+ assert "ERROR test" in caplog.text
+
+ def test_respond(self, capsys, caplog):
+ protocol = MessageBoundaryProtocol("bound")
+
+ protocol.respond("test")
+
+ out, err = capsys.readouterr()
+ assert out == "boundtestbound\n"
+ assert err == ""
+ assert "" in caplog.text
diff --git a/tests/slack_cli_hooks/protocol/test_protocol_factory.py b/tests/slack_cli_hooks/protocol/test_protocol_factory.py
new file mode 100644
index 0000000..c926a24
--- /dev/null
+++ b/tests/slack_cli_hooks/protocol/test_protocol_factory.py
@@ -0,0 +1,15 @@
+from slack_cli_hooks.protocol import build_protocol, DefaultProtocol, MessageBoundaryProtocol, Protocol
+
+
+class TestProtocolFactory:
+ def test_default(self):
+ args = []
+ protocol = build_protocol(args)
+ assert isinstance(protocol, Protocol)
+ assert isinstance(protocol, DefaultProtocol)
+
+ def test_message_boundaries(self):
+ args = [f"--protocol={MessageBoundaryProtocol.name}", "--bound=boundary"]
+ protocol = build_protocol(args)
+ assert isinstance(protocol, Protocol)
+ assert isinstance(protocol, MessageBoundaryProtocol)
diff --git a/tests/test_dummy.py b/tests/test_dummy.py
deleted file mode 100644
index 22ed597..0000000
--- a/tests/test_dummy.py
+++ /dev/null
@@ -1,9 +0,0 @@
-class TestDummy:
- def setup_method(self):
- pass
-
- def teardown_method(self):
- pass
-
- def test_dummy(self):
- assert True
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
index 0000000..185e41b
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,11 @@
+import os
+
+
+def remove_os_env_temporarily() -> dict:
+ old_env = os.environ.copy()
+ os.environ.clear()
+ return old_env
+
+
+def restore_os_env(old_env: dict) -> None:
+ os.environ.update(old_env)