From 292d301d52ff7263f9dfffe56f5dadafc4d878b2 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 16:49:39 -0400 Subject: [PATCH 1/9] refactor: register listeners with decorators and load them automatically Each listener now decorates its own callback with @app.(...) and bolt_app.py imports every module under listeners/ with walk_packages, so adding a listener means adding a file instead of editing register() functions. bolt_app.py owns the App for both Socket Mode and OAuth: the OAuth settings turn on when SLACK_CLIENT_ID is set, and app_oauth.py is now a thin starter you rename to app.py. Tests turn off token verification through SLACK_TOKEN_VERIFICATION_ENABLED. Co-Authored-By: Claude --- README.md | 23 +++++++++- app.py | 12 +---- app_oauth.py | 49 +-------------------- bolt_app.py | 61 ++++++++++++++++++++++++++ listeners/__init__.py | 10 ----- listeners/actions/__init__.py | 7 --- listeners/actions/sample_action.py | 3 ++ listeners/commands/__init__.py | 7 --- listeners/commands/sample_command.py | 3 ++ listeners/events/__init__.py | 7 --- listeners/events/app_home_opened.py | 3 ++ listeners/messages/__init__.py | 10 ----- listeners/messages/sample_message.py | 5 +++ listeners/shortcuts/__init__.py | 7 --- listeners/shortcuts/sample_shortcut.py | 3 ++ listeners/views/__init__.py | 7 --- listeners/views/sample_view.py | 3 ++ pyproject.toml | 3 ++ tests/conftest.py | 4 ++ tests/test_bolt_app.py | 27 ++++++++++++ 20 files changed, 140 insertions(+), 114 deletions(-) create mode 100644 bolt_app.py create mode 100644 tests/conftest.py create mode 100644 tests/test_bolt_app.py diff --git a/README.md b/README.md index 95e4430..ab51f7f 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,34 @@ pytest . `app.py` is the entry point for the application and is the file you'll run to start the server. This project aims to keep this file as thin as possible, primarily using it as a way to route inbound requests. +### `bolt_app.py` + +`bolt_app.py` creates the Bolt `app` and loads every module in `/listeners`. Listener modules import `app` from here, so it is configured in one place for both Socket Mode and OAuth. + ### `/listeners` Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. +Each listener registers itself with a decorator, and `bolt_app.py` imports every module in this directory on startup. To add a listener, create a new file in the matching directory: + +```python +from bolt_app import app + + +@app.event("app_mention") +def app_mention_callback(say): + say("Hi there!") +``` + ## App Distribution / OAuth -Only implement OAuth if you plan to distribute your application across multiple workspaces. A separate `app_oauth.py` file can be found with relevant OAuth settings. +Only implement OAuth if you plan to distribute your application across multiple workspaces. The OAuth settings live in `bolt_app.py` and turn on when `SLACK_CLIENT_ID` is set. To start the app over HTTP with OAuth, rename `app_oauth.py` to `app.py` (replacing the Socket Mode one) and set these environment variables from your app's **Basic Information** page: + +```zsh +export SLACK_CLIENT_ID= +export SLACK_CLIENT_SECRET= +export SLACK_SIGNING_SECRET= +``` When using OAuth, Slack requires a public URL where it can send requests. In this template app, we've used [`ngrok`](https://ngrok.com/download). Checkout [this guide](https://ngrok.com/docs#getting-started-expose) for setting it up. diff --git a/app.py b/app.py index ed1b002..599508c 100644 --- a/app.py +++ b/app.py @@ -1,18 +1,8 @@ -import logging import os -from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from listeners import register_listeners - -logging.basicConfig(level=logging.DEBUG) - -# Initialization -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) - -# Register Listeners -register_listeners(app) +from bolt_app import app # Start Bolt app if __name__ == "__main__": diff --git a/app_oauth.py b/app_oauth.py index c48fab8..1b9f880 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -1,50 +1,5 @@ -import logging -import os +from bolt_app import app -from slack_bolt import App, BoltResponse -from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs -from slack_bolt.oauth.oauth_settings import OAuthSettings -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore - -from listeners import register_listeners - -logging.basicConfig(level=logging.DEBUG) - - -# Callback to run on successful installation -def success(args: SuccessArgs) -> BoltResponse: - # Call default handler to return an HTTP response - return args.default.success(args) - # return BoltResponse(status=200, body="Installation successful!") - - -# Callback to run on failed installation -def failure(args: FailureArgs) -> BoltResponse: - return args.default.failure(args) - # return BoltResponse(status=args.suggested_status_code, body=args.reason) - - -# Initialization -app = App( - signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - installation_store=FileInstallationStore(), - oauth_settings=OAuthSettings( - client_id=os.environ.get("SLACK_CLIENT_ID"), - client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["channels:history", "chat:write", "commands"], - user_scopes=[], - redirect_uri=None, - install_path="/slack/install", - redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600), - callback_options=CallbackOptions(success=success, failure=failure), - ), -) - -# Register Listeners -register_listeners(app) - -# Start Bolt app +# Rename this file to app.py to start the app with OAuth over HTTP if __name__ == "__main__": app.start(3000) diff --git a/bolt_app.py b/bolt_app.py new file mode 100644 index 0000000..cec9f86 --- /dev/null +++ b/bolt_app.py @@ -0,0 +1,61 @@ +import logging +import os +from importlib import import_module +from pkgutil import walk_packages + +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +import listeners + +logging.basicConfig(level=logging.DEBUG) + + +# Callback to run on successful installation +def success(args: SuccessArgs) -> BoltResponse: + # Call default handler to return an HTTP response + return args.default.success(args) + # return BoltResponse(status=200, body="Installation successful!") + + +# Callback to run on failed installation +def failure(args: FailureArgs) -> BoltResponse: + return args.default.failure(args) + # return BoltResponse(status=args.suggested_status_code, body=args.reason) + + +def oauth_settings() -> OAuthSettings | None: + if "SLACK_CLIENT_ID" not in os.environ: + return None + return OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["channels:history", "chat:write", "commands"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600), + installation_store=FileInstallationStore(), + callback_options=CallbackOptions(success=success, failure=failure), + ) + + +# Initialization +app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + oauth_settings=oauth_settings(), + token_verification_enabled=os.environ.get("SLACK_TOKEN_VERIFICATION_ENABLED", "true") == "true", +) + + +def load_listeners() -> None: + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): + import_module(module_info.name) + + +load_listeners() diff --git a/listeners/__init__.py b/listeners/__init__.py index 465e1f2..e69de29 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,10 +0,0 @@ -from listeners import actions, commands, events, messages, shortcuts, views - - -def register_listeners(app): - actions.register(app) - commands.register(app) - events.register(app) - messages.register(app) - shortcuts.register(app) - views.register(app) diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py index 4a85b1f..e69de29 100644 --- a/listeners/actions/__init__.py +++ b/listeners/actions/__init__.py @@ -1,7 +0,0 @@ -from slack_bolt import App - -from .sample_action import sample_action_callback - - -def register(app: App): - app.action("sample_action_id")(sample_action_callback) diff --git a/listeners/actions/sample_action.py b/listeners/actions/sample_action.py index 57d92f5..dc73b40 100644 --- a/listeners/actions/sample_action.py +++ b/listeners/actions/sample_action.py @@ -3,7 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient +from bolt_app import app + +@app.action("sample_action_id") def sample_action_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): try: ack() diff --git a/listeners/commands/__init__.py b/listeners/commands/__init__.py index e4da248..e69de29 100644 --- a/listeners/commands/__init__.py +++ b/listeners/commands/__init__.py @@ -1,7 +0,0 @@ -from slack_bolt import App - -from .sample_command import sample_command_callback - - -def register(app: App): - app.command("/sample-command")(sample_command_callback) diff --git a/listeners/commands/sample_command.py b/listeners/commands/sample_command.py index a3d47b9..1904752 100644 --- a/listeners/commands/sample_command.py +++ b/listeners/commands/sample_command.py @@ -2,7 +2,10 @@ from slack_bolt import Ack, Respond +from bolt_app import app + +@app.command("/sample-command") def sample_command_callback(command, ack: Ack, respond: Respond, logger: Logger): try: ack() diff --git a/listeners/events/__init__.py b/listeners/events/__init__.py index d638683..e69de29 100644 --- a/listeners/events/__init__.py +++ b/listeners/events/__init__.py @@ -1,7 +0,0 @@ -from slack_bolt import App - -from .app_home_opened import app_home_opened_callback - - -def register(app: App): - app.event("app_home_opened")(app_home_opened_callback) diff --git a/listeners/events/app_home_opened.py b/listeners/events/app_home_opened.py index 9f8bae9..a8c757c 100644 --- a/listeners/events/app_home_opened.py +++ b/listeners/events/app_home_opened.py @@ -2,7 +2,10 @@ from slack_sdk import WebClient +from bolt_app import app + +@app.event("app_home_opened") def app_home_opened_callback(client: WebClient, event: dict, logger: Logger): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": diff --git a/listeners/messages/__init__.py b/listeners/messages/__init__.py index 734945a..e69de29 100644 --- a/listeners/messages/__init__.py +++ b/listeners/messages/__init__.py @@ -1,10 +0,0 @@ -import re - -from slack_bolt import App - -from .sample_message import sample_message_callback - - -# To receive messages from a channel or dm your app must be a member! -def register(app: App): - app.message(re.compile("(hi|hello|hey)"))(sample_message_callback) diff --git a/listeners/messages/sample_message.py b/listeners/messages/sample_message.py index 1855d36..8a65e98 100644 --- a/listeners/messages/sample_message.py +++ b/listeners/messages/sample_message.py @@ -1,8 +1,13 @@ +import re from logging import Logger from slack_bolt import BoltContext, Say +from bolt_app import app + +# To receive messages from a channel or dm your app must be a member! +@app.message(re.compile("(hi|hello|hey)")) def sample_message_callback(context: BoltContext, say: Say, logger: Logger): try: greeting = context["matches"][0] diff --git a/listeners/shortcuts/__init__.py b/listeners/shortcuts/__init__.py index 1397414..e69de29 100644 --- a/listeners/shortcuts/__init__.py +++ b/listeners/shortcuts/__init__.py @@ -1,7 +0,0 @@ -from slack_bolt import App - -from .sample_shortcut import sample_shortcut_callback - - -def register(app: App): - app.shortcut("sample_shortcut_id")(sample_shortcut_callback) diff --git a/listeners/shortcuts/sample_shortcut.py b/listeners/shortcuts/sample_shortcut.py index ccb6a49..6081dd8 100644 --- a/listeners/shortcuts/sample_shortcut.py +++ b/listeners/shortcuts/sample_shortcut.py @@ -3,7 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient +from bolt_app import app + +@app.shortcut("sample_shortcut_id") def sample_shortcut_callback(body: dict, ack: Ack, client: WebClient, logger: Logger): try: ack() diff --git a/listeners/views/__init__.py b/listeners/views/__init__.py index 7b2d4b3..e69de29 100644 --- a/listeners/views/__init__.py +++ b/listeners/views/__init__.py @@ -1,7 +0,0 @@ -from slack_bolt import App - -from .sample_view import sample_view_callback - - -def register(app: App): - app.view("sample_view_id")(sample_view_callback) diff --git a/listeners/views/sample_view.py b/listeners/views/sample_view.py index e2e8c25..2e5f5f5 100644 --- a/listeners/views/sample_view.py +++ b/listeners/views/sample_view.py @@ -3,7 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient +from bolt_app import app + +@app.view("sample_view_id") def sample_view_callback(view, ack: Ack, body: dict, client: WebClient, logger: Logger): try: ack() diff --git a/pyproject.toml b/pyproject.toml index a334bb1..b7e682d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,9 @@ dev = [ "ruff==0.16.9", ] +[tool.setuptools] +py-modules = ["bolt_app"] + [tool.setuptools.packages.find] include = ["listeners*"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e6af3b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import os + +os.environ["SLACK_BOT_TOKEN"] = "test-bot-token" +os.environ["SLACK_TOKEN_VERIFICATION_ENABLED"] = "false" diff --git a/tests/test_bolt_app.py b/tests/test_bolt_app.py new file mode 100644 index 0000000..ea0a5c8 --- /dev/null +++ b/tests/test_bolt_app.py @@ -0,0 +1,27 @@ +import sys +from pkgutil import walk_packages + +import listeners +from bolt_app import load_listeners, oauth_settings + + +class TestBoltApp: + def test_load_listeners_imports_every_listener_module(self): + load_listeners() + + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): + assert module_info.name in sys.modules + + def test_oauth_settings_without_client_id(self, monkeypatch): + monkeypatch.delenv("SLACK_CLIENT_ID", raising=False) + + assert oauth_settings() is None + + def test_oauth_settings_with_client_id(self, monkeypatch): + monkeypatch.setenv("SLACK_CLIENT_ID", "test-client-id") + monkeypatch.setenv("SLACK_CLIENT_SECRET", "test-client-secret") + + settings = oauth_settings() + + assert settings.client_id == "test-client-id" + assert settings.scopes == ["channels:history", "chat:write", "commands"] From d51ad792b56168bc1d6f065d8ad4cbeb53d9fa79 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 17:01:36 -0400 Subject: [PATCH 2/9] refactor: inject the app into listeners instead of a bolt_app module Each entry file builds its own App again and passes it to load_listeners, which sets listeners.app before importing the listener modules. Listeners decorate with `from listeners import app`, and since the listeners package is never __main__ there is one App whether the app starts from app.py, slack run, or app_oauth.py. Switching to OAuth is now: delete app.py and rename app_oauth.py to app.py. This drops bolt_app.py, the env-switched OAuth settings, and the token verification flag; tests build their own App in conftest. Co-Authored-By: Claude --- README.md | 10 ++--- app.py | 12 ++++- app_oauth.py | 49 ++++++++++++++++++++- bolt_app.py | 61 -------------------------- listeners/__init__.py | 13 ++++++ listeners/actions/sample_action.py | 2 +- listeners/commands/sample_command.py | 2 +- listeners/events/app_home_opened.py | 2 +- listeners/messages/sample_message.py | 2 +- listeners/shortcuts/sample_shortcut.py | 2 +- listeners/views/sample_view.py | 2 +- pyproject.toml | 3 -- tests/conftest.py | 7 +-- tests/listeners/test_load_listeners.py | 13 ++++++ tests/test_bolt_app.py | 27 ------------ 15 files changed, 97 insertions(+), 110 deletions(-) delete mode 100644 bolt_app.py create mode 100644 tests/listeners/test_load_listeners.py delete mode 100644 tests/test_bolt_app.py diff --git a/README.md b/README.md index ab51f7f..42bd7cd 100644 --- a/README.md +++ b/README.md @@ -124,18 +124,14 @@ pytest . `app.py` is the entry point for the application and is the file you'll run to start the server. This project aims to keep this file as thin as possible, primarily using it as a way to route inbound requests. -### `bolt_app.py` - -`bolt_app.py` creates the Bolt `app` and loads every module in `/listeners`. Listener modules import `app` from here, so it is configured in one place for both Socket Mode and OAuth. - ### `/listeners` Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator, and `bolt_app.py` imports every module in this directory on startup. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `app.py` passes its `app` to `load_listeners`, which imports every module in this directory on startup. To add a listener, create a new file in the matching directory: ```python -from bolt_app import app +from listeners import app @app.event("app_mention") @@ -145,7 +141,7 @@ def app_mention_callback(say): ## App Distribution / OAuth -Only implement OAuth if you plan to distribute your application across multiple workspaces. The OAuth settings live in `bolt_app.py` and turn on when `SLACK_CLIENT_ID` is set. To start the app over HTTP with OAuth, rename `app_oauth.py` to `app.py` (replacing the Socket Mode one) and set these environment variables from your app's **Basic Information** page: +Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: ```zsh export SLACK_CLIENT_ID= diff --git a/app.py b/app.py index 599508c..56a26c4 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,18 @@ +import logging import os +from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from bolt_app import app +from listeners import load_listeners + +logging.basicConfig(level=logging.DEBUG) + +# Initialization +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + +# Load Listeners +load_listeners(app) # Start Bolt app if __name__ == "__main__": diff --git a/app_oauth.py b/app_oauth.py index 1b9f880..c898833 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -1,5 +1,50 @@ -from bolt_app import app +import logging +import os -# Rename this file to app.py to start the app with OAuth over HTTP +from slack_bolt import App, BoltResponse +from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore + +from listeners import load_listeners + +logging.basicConfig(level=logging.DEBUG) + + +# Callback to run on successful installation +def success(args: SuccessArgs) -> BoltResponse: + # Call default handler to return an HTTP response + return args.default.success(args) + # return BoltResponse(status=200, body="Installation successful!") + + +# Callback to run on failed installation +def failure(args: FailureArgs) -> BoltResponse: + return args.default.failure(args) + # return BoltResponse(status=args.suggested_status_code, body=args.reason) + + +# Initialization +app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + installation_store=FileInstallationStore(), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["channels:history", "chat:write", "commands"], + user_scopes=[], + redirect_uri=None, + install_path="/slack/install", + redirect_uri_path="/slack/oauth_redirect", + state_store=FileOAuthStateStore(expiration_seconds=600), + callback_options=CallbackOptions(success=success, failure=failure), + ), +) + +# Load Listeners +load_listeners(app) + +# Start Bolt app if __name__ == "__main__": app.start(3000) diff --git a/bolt_app.py b/bolt_app.py deleted file mode 100644 index cec9f86..0000000 --- a/bolt_app.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -import os -from importlib import import_module -from pkgutil import walk_packages - -from slack_bolt import App, BoltResponse -from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs -from slack_bolt.oauth.oauth_settings import OAuthSettings -from slack_sdk.oauth.installation_store import FileInstallationStore -from slack_sdk.oauth.state_store import FileOAuthStateStore - -import listeners - -logging.basicConfig(level=logging.DEBUG) - - -# Callback to run on successful installation -def success(args: SuccessArgs) -> BoltResponse: - # Call default handler to return an HTTP response - return args.default.success(args) - # return BoltResponse(status=200, body="Installation successful!") - - -# Callback to run on failed installation -def failure(args: FailureArgs) -> BoltResponse: - return args.default.failure(args) - # return BoltResponse(status=args.suggested_status_code, body=args.reason) - - -def oauth_settings() -> OAuthSettings | None: - if "SLACK_CLIENT_ID" not in os.environ: - return None - return OAuthSettings( - client_id=os.environ.get("SLACK_CLIENT_ID"), - client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["channels:history", "chat:write", "commands"], - user_scopes=[], - redirect_uri=None, - install_path="/slack/install", - redirect_uri_path="/slack/oauth_redirect", - state_store=FileOAuthStateStore(expiration_seconds=600), - installation_store=FileInstallationStore(), - callback_options=CallbackOptions(success=success, failure=failure), - ) - - -# Initialization -app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - oauth_settings=oauth_settings(), - token_verification_enabled=os.environ.get("SLACK_TOKEN_VERIFICATION_ENABLED", "true") == "true", -) - - -def load_listeners() -> None: - for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): - import_module(module_info.name) - - -load_listeners() diff --git a/listeners/__init__.py b/listeners/__init__.py index e69de29..4f5580f 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -0,0 +1,13 @@ +from importlib import import_module +from pkgutil import walk_packages + +from slack_bolt import App + +app: App + + +def load_listeners(bolt_app: App) -> None: + global app + app = bolt_app + for module_info in walk_packages(__path__, f"{__name__}."): + import_module(module_info.name) diff --git a/listeners/actions/sample_action.py b/listeners/actions/sample_action.py index dc73b40..d361e1d 100644 --- a/listeners/actions/sample_action.py +++ b/listeners/actions/sample_action.py @@ -3,7 +3,7 @@ from slack_bolt import Ack from slack_sdk import WebClient -from bolt_app import app +from listeners import app @app.action("sample_action_id") diff --git a/listeners/commands/sample_command.py b/listeners/commands/sample_command.py index 1904752..3e5964e 100644 --- a/listeners/commands/sample_command.py +++ b/listeners/commands/sample_command.py @@ -2,7 +2,7 @@ from slack_bolt import Ack, Respond -from bolt_app import app +from listeners import app @app.command("/sample-command") diff --git a/listeners/events/app_home_opened.py b/listeners/events/app_home_opened.py index a8c757c..f62a06f 100644 --- a/listeners/events/app_home_opened.py +++ b/listeners/events/app_home_opened.py @@ -2,7 +2,7 @@ from slack_sdk import WebClient -from bolt_app import app +from listeners import app @app.event("app_home_opened") diff --git a/listeners/messages/sample_message.py b/listeners/messages/sample_message.py index 8a65e98..092ff21 100644 --- a/listeners/messages/sample_message.py +++ b/listeners/messages/sample_message.py @@ -3,7 +3,7 @@ from slack_bolt import BoltContext, Say -from bolt_app import app +from listeners import app # To receive messages from a channel or dm your app must be a member! diff --git a/listeners/shortcuts/sample_shortcut.py b/listeners/shortcuts/sample_shortcut.py index 6081dd8..879a1b2 100644 --- a/listeners/shortcuts/sample_shortcut.py +++ b/listeners/shortcuts/sample_shortcut.py @@ -3,7 +3,7 @@ from slack_bolt import Ack from slack_sdk import WebClient -from bolt_app import app +from listeners import app @app.shortcut("sample_shortcut_id") diff --git a/listeners/views/sample_view.py b/listeners/views/sample_view.py index 2e5f5f5..0a5cd01 100644 --- a/listeners/views/sample_view.py +++ b/listeners/views/sample_view.py @@ -3,7 +3,7 @@ from slack_bolt import Ack from slack_sdk import WebClient -from bolt_app import app +from listeners import app @app.view("sample_view_id") diff --git a/pyproject.toml b/pyproject.toml index b7e682d..a334bb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,6 @@ dev = [ "ruff==0.16.9", ] -[tool.setuptools] -py-modules = ["bolt_app"] - [tool.setuptools.packages.find] include = ["listeners*"] diff --git a/tests/conftest.py b/tests/conftest.py index e6af3b3..ed346ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ -import os +from slack_bolt import App -os.environ["SLACK_BOT_TOKEN"] = "test-bot-token" -os.environ["SLACK_TOKEN_VERIFICATION_ENABLED"] = "false" +from listeners import load_listeners + +load_listeners(App(token="test-bot-token", token_verification_enabled=False)) diff --git a/tests/listeners/test_load_listeners.py b/tests/listeners/test_load_listeners.py new file mode 100644 index 0000000..5493a1a --- /dev/null +++ b/tests/listeners/test_load_listeners.py @@ -0,0 +1,13 @@ +import sys +from pkgutil import walk_packages + +import listeners + + +class TestLoadListeners: + def test_imports_every_listener_module(self): + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): + assert module_info.name in sys.modules + + def test_registers_every_listener_on_the_injected_app(self): + assert len(listeners.app._listeners) == 6 diff --git a/tests/test_bolt_app.py b/tests/test_bolt_app.py deleted file mode 100644 index ea0a5c8..0000000 --- a/tests/test_bolt_app.py +++ /dev/null @@ -1,27 +0,0 @@ -import sys -from pkgutil import walk_packages - -import listeners -from bolt_app import load_listeners, oauth_settings - - -class TestBoltApp: - def test_load_listeners_imports_every_listener_module(self): - load_listeners() - - for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): - assert module_info.name in sys.modules - - def test_oauth_settings_without_client_id(self, monkeypatch): - monkeypatch.delenv("SLACK_CLIENT_ID", raising=False) - - assert oauth_settings() is None - - def test_oauth_settings_with_client_id(self, monkeypatch): - monkeypatch.setenv("SLACK_CLIENT_ID", "test-client-id") - monkeypatch.setenv("SLACK_CLIENT_SECRET", "test-client-secret") - - settings = oauth_settings() - - assert settings.client_id == "test-client-id" - assert settings.scopes == ["channels:history", "chat:write", "commands"] From 0f788ee907f3f3a470bf2f7740d3223edbc206d6 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 17:06:16 -0400 Subject: [PATCH 3/9] refactor: set listeners.app in the entry file and keep load_listeners a plain walker The entry file now hands its App over with `listeners.app = app` before calling `listeners.load_listeners()`, so load_listeners only walks the package and the happy path reads as two plain lines. Co-Authored-By: Claude --- README.md | 2 +- app.py | 5 +++-- app_oauth.py | 5 +++-- listeners/__init__.py | 4 +--- tests/conftest.py | 5 +++-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 42bd7cd..d76d6a4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `app.py` passes its `app` to `load_listeners`, which imports every module in this directory on startup. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `app.py` sets `listeners.app` and then calls `listeners.load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: ```python from listeners import app diff --git a/app.py b/app.py index 56a26c4..24cd0cf 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from listeners import load_listeners +import listeners logging.basicConfig(level=logging.DEBUG) @@ -12,7 +12,8 @@ app = App(token=os.environ.get("SLACK_BOT_TOKEN")) # Load Listeners -load_listeners(app) +listeners.app = app +listeners.load_listeners() # Start Bolt app if __name__ == "__main__": diff --git a/app_oauth.py b/app_oauth.py index c898833..5c08864 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -7,7 +7,7 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -from listeners import load_listeners +import listeners logging.basicConfig(level=logging.DEBUG) @@ -43,7 +43,8 @@ def failure(args: FailureArgs) -> BoltResponse: ) # Load Listeners -load_listeners(app) +listeners.app = app +listeners.load_listeners() # Start Bolt app if __name__ == "__main__": diff --git a/listeners/__init__.py b/listeners/__init__.py index 4f5580f..0c4d03f 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -6,8 +6,6 @@ app: App -def load_listeners(bolt_app: App) -> None: - global app - app = bolt_app +def load_listeners() -> None: for module_info in walk_packages(__path__, f"{__name__}."): import_module(module_info.name) diff --git a/tests/conftest.py b/tests/conftest.py index ed346ae..4085470 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from slack_bolt import App -from listeners import load_listeners +import listeners -load_listeners(App(token="test-bot-token", token_verification_enabled=False)) +listeners.app = App(token="test-bot-token", token_verification_enabled=False) +listeners.load_listeners() From bd0bd06098d132477c76d229c1d188227e9be81e Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 17:09:05 -0400 Subject: [PATCH 4/9] refactor: create the default socket mode app in the listeners package listeners/__init__.py now builds the default App from SLACK_BOT_TOKEN, so app.py only imports app and load_listeners. Token verification is deferred to the first request so importing listeners makes no API call. app_oauth.py overrides listeners.app with its OAuth app before loading the listeners, and tests set SLACK_BOT_TOKEN in pytest_configure. Co-Authored-By: Claude --- README.md | 4 ++-- app.py | 9 ++------- listeners/__init__.py | 3 ++- tests/conftest.py | 7 +++---- tests/listeners/test_load_listeners.py | 5 ++++- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d76d6a4..08c70a9 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `app.py` sets `listeners.app` and then calls `listeners.load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `listeners/__init__.py` creates the default Socket Mode `app`, and `app.py` calls `load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: ```python from listeners import app @@ -141,7 +141,7 @@ def app_mention_callback(say): ## App Distribution / OAuth -Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: +Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings and replaces the default `listeners.app` with an OAuth app before loading the listeners. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: ```zsh export SLACK_CLIENT_ID= diff --git a/app.py b/app.py index 24cd0cf..1dc5fb1 100644 --- a/app.py +++ b/app.py @@ -1,19 +1,14 @@ import logging import os -from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -import listeners +from listeners import app, load_listeners logging.basicConfig(level=logging.DEBUG) -# Initialization -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) - # Load Listeners -listeners.app = app -listeners.load_listeners() +load_listeners() # Start Bolt app if __name__ == "__main__": diff --git a/listeners/__init__.py b/listeners/__init__.py index 0c4d03f..515d615 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,9 +1,10 @@ +import os from importlib import import_module from pkgutil import walk_packages from slack_bolt import App -app: App +app = App(token=os.environ.get("SLACK_BOT_TOKEN"), token_verification_enabled=False) def load_listeners() -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 4085470..6bc94da 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ -from slack_bolt import App +import os -import listeners -listeners.app = App(token="test-bot-token", token_verification_enabled=False) -listeners.load_listeners() +def pytest_configure(): + os.environ["SLACK_BOT_TOKEN"] = "test-bot-token" diff --git a/tests/listeners/test_load_listeners.py b/tests/listeners/test_load_listeners.py index 5493a1a..992df88 100644 --- a/tests/listeners/test_load_listeners.py +++ b/tests/listeners/test_load_listeners.py @@ -5,9 +5,12 @@ class TestLoadListeners: + def setup_method(self): + listeners.load_listeners() + def test_imports_every_listener_module(self): for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): assert module_info.name in sys.modules - def test_registers_every_listener_on_the_injected_app(self): + def test_registers_every_listener_on_the_app(self): assert len(listeners.app._listeners) == 6 From 653e35eb4936472349ce5190c92620a4b024be3a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 17:11:12 -0400 Subject: [PATCH 5/9] revert: create the app in app.py again This reverts commit bd0bd06. The App belongs in the entry file, so app.py builds it with token verification on and hands it to the listeners with `listeners.app = app`. Co-Authored-By: Claude --- README.md | 4 ++-- app.py | 9 +++++++-- listeners/__init__.py | 3 +-- tests/conftest.py | 7 ++++--- tests/listeners/test_load_listeners.py | 5 +---- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 08c70a9..d76d6a4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `listeners/__init__.py` creates the default Socket Mode `app`, and `app.py` calls `load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `app.py` sets `listeners.app` and then calls `listeners.load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: ```python from listeners import app @@ -141,7 +141,7 @@ def app_mention_callback(say): ## App Distribution / OAuth -Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings and replaces the default `listeners.app` with an OAuth app before loading the listeners. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: +Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: ```zsh export SLACK_CLIENT_ID= diff --git a/app.py b/app.py index 1dc5fb1..24cd0cf 100644 --- a/app.py +++ b/app.py @@ -1,14 +1,19 @@ import logging import os +from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from listeners import app, load_listeners +import listeners logging.basicConfig(level=logging.DEBUG) +# Initialization +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) + # Load Listeners -load_listeners() +listeners.app = app +listeners.load_listeners() # Start Bolt app if __name__ == "__main__": diff --git a/listeners/__init__.py b/listeners/__init__.py index 515d615..0c4d03f 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,10 +1,9 @@ -import os from importlib import import_module from pkgutil import walk_packages from slack_bolt import App -app = App(token=os.environ.get("SLACK_BOT_TOKEN"), token_verification_enabled=False) +app: App def load_listeners() -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 6bc94da..4085470 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ -import os +from slack_bolt import App +import listeners -def pytest_configure(): - os.environ["SLACK_BOT_TOKEN"] = "test-bot-token" +listeners.app = App(token="test-bot-token", token_verification_enabled=False) +listeners.load_listeners() diff --git a/tests/listeners/test_load_listeners.py b/tests/listeners/test_load_listeners.py index 992df88..5493a1a 100644 --- a/tests/listeners/test_load_listeners.py +++ b/tests/listeners/test_load_listeners.py @@ -5,12 +5,9 @@ class TestLoadListeners: - def setup_method(self): - listeners.load_listeners() - def test_imports_every_listener_module(self): for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): assert module_info.name in sys.modules - def test_registers_every_listener_on_the_app(self): + def test_registers_every_listener_on_the_injected_app(self): assert len(listeners.app._listeners) == 6 From 09932bd98756499ab6aaa6f3055561d99e52a3ab Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 20 Oct 2026 17:11:37 -0400 Subject: [PATCH 6/9] test: stub auth.test in conftest instead of turning off token verification Co-Authored-By: Claude --- tests/conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4085470..71202a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,10 @@ +from unittest.mock import patch + from slack_bolt import App +from slack_sdk import WebClient import listeners -listeners.app = App(token="test-bot-token", token_verification_enabled=False) +with patch.object(WebClient, "auth_test"): + listeners.app = App(token="test-bot-token") listeners.load_listeners() From 0181e74703fa06dc05a345ad000910a00360f3da Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 11:11:01 -0400 Subject: [PATCH 7/9] refactor: register listeners on a registry and pass the app to load_listeners Listener modules no longer import a shared listeners.app that app.py must set first. Decorators record onto a registry, and load_listeners(app) applies them, so any number of App instances can load the same listeners (for example in tests). Co-Authored-By: Claude --- README.md | 6 ++--- app.py | 5 ++-- app_oauth.py | 5 ++-- listeners/__init__.py | 29 +++++++++++++++++++-- listeners/actions/sample_action.py | 4 +-- listeners/commands/sample_command.py | 4 +-- listeners/events/app_home_opened.py | 4 +-- listeners/messages/sample_message.py | 4 +-- listeners/shortcuts/sample_shortcut.py | 4 +-- listeners/views/sample_view.py | 4 +-- tests/conftest.py | 10 ------- tests/listeners/test_load_listeners.py | 29 +++++++++++++++++++-- tests/listeners/test_registry.py | 36 ++++++++++++++++++++++++++ 13 files changed, 109 insertions(+), 35 deletions(-) delete mode 100644 tests/conftest.py create mode 100644 tests/listeners/test_registry.py diff --git a/README.md b/README.md index d76d6a4..6ece1f0 100644 --- a/README.md +++ b/README.md @@ -128,13 +128,13 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `app.py` sets `listeners.app` and then calls `listeners.load_listeners()`, which imports every module in this directory. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `app.py` calls `load_listeners(app)`, which imports every module in this directory and registers its listeners on the app. To add a listener, create a new file in the matching directory: ```python -from listeners import app +from listeners import registry -@app.event("app_mention") +@registry.event("app_mention") def app_mention_callback(say): say("Hi there!") ``` diff --git a/app.py b/app.py index 24cd0cf..56a26c4 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -import listeners +from listeners import load_listeners logging.basicConfig(level=logging.DEBUG) @@ -12,8 +12,7 @@ app = App(token=os.environ.get("SLACK_BOT_TOKEN")) # Load Listeners -listeners.app = app -listeners.load_listeners() +load_listeners(app) # Start Bolt app if __name__ == "__main__": diff --git a/app_oauth.py b/app_oauth.py index 5c08864..c898833 100644 --- a/app_oauth.py +++ b/app_oauth.py @@ -7,7 +7,7 @@ from slack_sdk.oauth.installation_store import FileInstallationStore from slack_sdk.oauth.state_store import FileOAuthStateStore -import listeners +from listeners import load_listeners logging.basicConfig(level=logging.DEBUG) @@ -43,8 +43,7 @@ def failure(args: FailureArgs) -> BoltResponse: ) # Load Listeners -listeners.app = app -listeners.load_listeners() +load_listeners(app) # Start Bolt app if __name__ == "__main__": diff --git a/listeners/__init__.py b/listeners/__init__.py index 0c4d03f..9151b3f 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,11 +1,36 @@ +from collections.abc import Callable from importlib import import_module from pkgutil import walk_packages from slack_bolt import App -app: App +class Registry: + def __init__(self) -> None: + self._registrations: list[Callable[[App], None]] = [] -def load_listeners() -> None: + def __getattr__(self, method: str) -> Callable[..., Callable[[Callable], Callable]]: + if not hasattr(App, method): + raise AttributeError(method) + + def decorator_factory(*args, **kwargs): + def decorator(func): + self._registrations.append(lambda app: getattr(app, method)(*args, **kwargs)(func)) + return func + + return decorator + + return decorator_factory + + def apply(self, app: App) -> None: + for register in self._registrations: + register(app) + + +registry = Registry() + + +def load_listeners(app: App) -> None: for module_info in walk_packages(__path__, f"{__name__}."): import_module(module_info.name) + registry.apply(app) diff --git a/listeners/actions/sample_action.py b/listeners/actions/sample_action.py index d361e1d..36c65b6 100644 --- a/listeners/actions/sample_action.py +++ b/listeners/actions/sample_action.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import app +from listeners import registry -@app.action("sample_action_id") +@registry.action("sample_action_id") def sample_action_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): try: ack() diff --git a/listeners/commands/sample_command.py b/listeners/commands/sample_command.py index 3e5964e..f9c0471 100644 --- a/listeners/commands/sample_command.py +++ b/listeners/commands/sample_command.py @@ -2,10 +2,10 @@ from slack_bolt import Ack, Respond -from listeners import app +from listeners import registry -@app.command("/sample-command") +@registry.command("/sample-command") def sample_command_callback(command, ack: Ack, respond: Respond, logger: Logger): try: ack() diff --git a/listeners/events/app_home_opened.py b/listeners/events/app_home_opened.py index f62a06f..6d2ad74 100644 --- a/listeners/events/app_home_opened.py +++ b/listeners/events/app_home_opened.py @@ -2,10 +2,10 @@ from slack_sdk import WebClient -from listeners import app +from listeners import registry -@app.event("app_home_opened") +@registry.event("app_home_opened") def app_home_opened_callback(client: WebClient, event: dict, logger: Logger): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": diff --git a/listeners/messages/sample_message.py b/listeners/messages/sample_message.py index 092ff21..9f4601e 100644 --- a/listeners/messages/sample_message.py +++ b/listeners/messages/sample_message.py @@ -3,11 +3,11 @@ from slack_bolt import BoltContext, Say -from listeners import app +from listeners import registry # To receive messages from a channel or dm your app must be a member! -@app.message(re.compile("(hi|hello|hey)")) +@registry.message(re.compile("(hi|hello|hey)")) def sample_message_callback(context: BoltContext, say: Say, logger: Logger): try: greeting = context["matches"][0] diff --git a/listeners/shortcuts/sample_shortcut.py b/listeners/shortcuts/sample_shortcut.py index 879a1b2..e4419fe 100644 --- a/listeners/shortcuts/sample_shortcut.py +++ b/listeners/shortcuts/sample_shortcut.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import app +from listeners import registry -@app.shortcut("sample_shortcut_id") +@registry.shortcut("sample_shortcut_id") def sample_shortcut_callback(body: dict, ack: Ack, client: WebClient, logger: Logger): try: ack() diff --git a/listeners/views/sample_view.py b/listeners/views/sample_view.py index 0a5cd01..9bc028f 100644 --- a/listeners/views/sample_view.py +++ b/listeners/views/sample_view.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import app +from listeners import registry -@app.view("sample_view_id") +@registry.view("sample_view_id") def sample_view_callback(view, ack: Ack, body: dict, client: WebClient, logger: Logger): try: ack() diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 71202a8..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -from unittest.mock import patch - -from slack_bolt import App -from slack_sdk import WebClient - -import listeners - -with patch.object(WebClient, "auth_test"): - listeners.app = App(token="test-bot-token") -listeners.load_listeners() diff --git a/tests/listeners/test_load_listeners.py b/tests/listeners/test_load_listeners.py index 5493a1a..7682e2a 100644 --- a/tests/listeners/test_load_listeners.py +++ b/tests/listeners/test_load_listeners.py @@ -1,13 +1,38 @@ import sys from pkgutil import walk_packages +from unittest.mock import patch + +from slack_bolt import App +from slack_sdk import WebClient import listeners +from listeners import load_listeners + + +def make_app() -> App: + with patch.object(WebClient, "auth_test"): + return App(token="test-bot-token") class TestLoadListeners: def test_imports_every_listener_module(self): + load_listeners(make_app()) + for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): assert module_info.name in sys.modules - def test_registers_every_listener_on_the_injected_app(self): - assert len(listeners.app._listeners) == 6 + def test_registers_every_listener_on_the_app(self): + app = make_app() + + load_listeners(app) + + assert len(app._listeners) == 6 + + def test_registers_every_listener_on_each_app(self): + first_app, second_app = make_app(), make_app() + + load_listeners(first_app) + load_listeners(second_app) + + assert len(first_app._listeners) == 6 + assert len(second_app._listeners) == 6 diff --git a/tests/listeners/test_registry.py b/tests/listeners/test_registry.py new file mode 100644 index 0000000..8af1bf8 --- /dev/null +++ b/tests/listeners/test_registry.py @@ -0,0 +1,36 @@ +from unittest.mock import patch + +import pytest +from slack_bolt import App +from slack_sdk import WebClient + +from listeners import Registry + + +def make_app() -> App: + with patch.object(WebClient, "auth_test"): + return App(token="test-bot-token") + + +def app_mention_callback(say): + say("Hi there!") + + +class TestRegistry: + def test_decorator_returns_the_original_function(self): + registry = Registry() + + assert registry.event("app_mention")(app_mention_callback) is app_mention_callback + + def test_apply_registers_the_listener_on_the_app(self): + registry = Registry() + registry.event("app_mention")(app_mention_callback) + app = make_app() + + registry.apply(app) + + assert [listener.ack_function for listener in app._listeners] == [app_mention_callback] + + def test_unknown_app_method_raises_attribute_error(self): + with pytest.raises(AttributeError): + Registry().evnt("app_mention") From c4920230e4164a31bc570c4d6911391f6b4df4ae Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 13:35:14 -0400 Subject: [PATCH 8/9] refactor: share the app through load_listeners instead of a registry load_listeners(app) stores the app as listeners.app and then imports every listener module, so each listener file imports the app and decorates its callback directly. Drops the registry, which added more indirection than the starter template needs. Co-Authored-By: Claude --- README.md | 6 ++--- listeners/__init__.py | 31 +++------------------- listeners/actions/sample_action.py | 4 +-- listeners/commands/sample_command.py | 4 +-- listeners/events/app_home_opened.py | 4 +-- listeners/messages/sample_message.py | 4 +-- listeners/shortcuts/sample_shortcut.py | 4 +-- listeners/views/sample_view.py | 4 +-- tests/conftest.py | 9 +++++++ tests/listeners/test_load_listeners.py | 27 +------------------ tests/listeners/test_registry.py | 36 -------------------------- 11 files changed, 29 insertions(+), 104 deletions(-) create mode 100644 tests/conftest.py delete mode 100644 tests/listeners/test_registry.py diff --git a/README.md b/README.md index 6ece1f0..8372c8d 100644 --- a/README.md +++ b/README.md @@ -128,13 +128,13 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `app.py` calls `load_listeners(app)`, which imports every module in this directory and registers its listeners on the app. To add a listener, create a new file in the matching directory: +Each listener registers itself with a decorator. `app.py` calls `load_listeners(app)`, which shares the app as `listeners.app` and imports every module in this directory. To add a listener, create a new file in the matching directory: ```python -from listeners import registry +from listeners import app -@registry.event("app_mention") +@app.event("app_mention") def app_mention_callback(say): say("Hi there!") ``` diff --git a/listeners/__init__.py b/listeners/__init__.py index 9151b3f..4f5580f 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,36 +1,13 @@ -from collections.abc import Callable from importlib import import_module from pkgutil import walk_packages from slack_bolt import App +app: App -class Registry: - def __init__(self) -> None: - self._registrations: list[Callable[[App], None]] = [] - def __getattr__(self, method: str) -> Callable[..., Callable[[Callable], Callable]]: - if not hasattr(App, method): - raise AttributeError(method) - - def decorator_factory(*args, **kwargs): - def decorator(func): - self._registrations.append(lambda app: getattr(app, method)(*args, **kwargs)(func)) - return func - - return decorator - - return decorator_factory - - def apply(self, app: App) -> None: - for register in self._registrations: - register(app) - - -registry = Registry() - - -def load_listeners(app: App) -> None: +def load_listeners(bolt_app: App) -> None: + global app + app = bolt_app for module_info in walk_packages(__path__, f"{__name__}."): import_module(module_info.name) - registry.apply(app) diff --git a/listeners/actions/sample_action.py b/listeners/actions/sample_action.py index 36c65b6..d361e1d 100644 --- a/listeners/actions/sample_action.py +++ b/listeners/actions/sample_action.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import registry +from listeners import app -@registry.action("sample_action_id") +@app.action("sample_action_id") def sample_action_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): try: ack() diff --git a/listeners/commands/sample_command.py b/listeners/commands/sample_command.py index f9c0471..3e5964e 100644 --- a/listeners/commands/sample_command.py +++ b/listeners/commands/sample_command.py @@ -2,10 +2,10 @@ from slack_bolt import Ack, Respond -from listeners import registry +from listeners import app -@registry.command("/sample-command") +@app.command("/sample-command") def sample_command_callback(command, ack: Ack, respond: Respond, logger: Logger): try: ack() diff --git a/listeners/events/app_home_opened.py b/listeners/events/app_home_opened.py index 6d2ad74..f62a06f 100644 --- a/listeners/events/app_home_opened.py +++ b/listeners/events/app_home_opened.py @@ -2,10 +2,10 @@ from slack_sdk import WebClient -from listeners import registry +from listeners import app -@registry.event("app_home_opened") +@app.event("app_home_opened") def app_home_opened_callback(client: WebClient, event: dict, logger: Logger): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": diff --git a/listeners/messages/sample_message.py b/listeners/messages/sample_message.py index 9f4601e..092ff21 100644 --- a/listeners/messages/sample_message.py +++ b/listeners/messages/sample_message.py @@ -3,11 +3,11 @@ from slack_bolt import BoltContext, Say -from listeners import registry +from listeners import app # To receive messages from a channel or dm your app must be a member! -@registry.message(re.compile("(hi|hello|hey)")) +@app.message(re.compile("(hi|hello|hey)")) def sample_message_callback(context: BoltContext, say: Say, logger: Logger): try: greeting = context["matches"][0] diff --git a/listeners/shortcuts/sample_shortcut.py b/listeners/shortcuts/sample_shortcut.py index e4419fe..879a1b2 100644 --- a/listeners/shortcuts/sample_shortcut.py +++ b/listeners/shortcuts/sample_shortcut.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import registry +from listeners import app -@registry.shortcut("sample_shortcut_id") +@app.shortcut("sample_shortcut_id") def sample_shortcut_callback(body: dict, ack: Ack, client: WebClient, logger: Logger): try: ack() diff --git a/listeners/views/sample_view.py b/listeners/views/sample_view.py index 9bc028f..0a5cd01 100644 --- a/listeners/views/sample_view.py +++ b/listeners/views/sample_view.py @@ -3,10 +3,10 @@ from slack_bolt import Ack from slack_sdk import WebClient -from listeners import registry +from listeners import app -@registry.view("sample_view_id") +@app.view("sample_view_id") def sample_view_callback(view, ack: Ack, body: dict, client: WebClient, logger: Logger): try: ack() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..100b4df --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +from unittest.mock import patch + +from slack_bolt import App +from slack_sdk import WebClient + +from listeners import load_listeners + +with patch.object(WebClient, "auth_test"): + load_listeners(App(token="test-bot-token")) diff --git a/tests/listeners/test_load_listeners.py b/tests/listeners/test_load_listeners.py index 7682e2a..e8a0bbc 100644 --- a/tests/listeners/test_load_listeners.py +++ b/tests/listeners/test_load_listeners.py @@ -1,38 +1,13 @@ import sys from pkgutil import walk_packages -from unittest.mock import patch - -from slack_bolt import App -from slack_sdk import WebClient import listeners -from listeners import load_listeners - - -def make_app() -> App: - with patch.object(WebClient, "auth_test"): - return App(token="test-bot-token") class TestLoadListeners: def test_imports_every_listener_module(self): - load_listeners(make_app()) - for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."): assert module_info.name in sys.modules def test_registers_every_listener_on_the_app(self): - app = make_app() - - load_listeners(app) - - assert len(app._listeners) == 6 - - def test_registers_every_listener_on_each_app(self): - first_app, second_app = make_app(), make_app() - - load_listeners(first_app) - load_listeners(second_app) - - assert len(first_app._listeners) == 6 - assert len(second_app._listeners) == 6 + assert len(listeners.app._listeners) == 6 diff --git a/tests/listeners/test_registry.py b/tests/listeners/test_registry.py deleted file mode 100644 index 8af1bf8..0000000 --- a/tests/listeners/test_registry.py +++ /dev/null @@ -1,36 +0,0 @@ -from unittest.mock import patch - -import pytest -from slack_bolt import App -from slack_sdk import WebClient - -from listeners import Registry - - -def make_app() -> App: - with patch.object(WebClient, "auth_test"): - return App(token="test-bot-token") - - -def app_mention_callback(say): - say("Hi there!") - - -class TestRegistry: - def test_decorator_returns_the_original_function(self): - registry = Registry() - - assert registry.event("app_mention")(app_mention_callback) is app_mention_callback - - def test_apply_registers_the_listener_on_the_app(self): - registry = Registry() - registry.event("app_mention")(app_mention_callback) - app = make_app() - - registry.apply(app) - - assert [listener.ack_function for listener in app._listeners] == [app_mention_callback] - - def test_unknown_app_method_raises_attribute_error(self): - with pytest.raises(AttributeError): - Registry().evnt("app_mention") From affa67ac14470920d89b41f8c0ebc128d1175689 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 21 Oct 2026 13:36:56 -0400 Subject: [PATCH 9/9] docs: leave the readme unchanged The listener and OAuth sections on main stay accurate with decorator listeners, and app_oauth.py still runs on its own. Co-Authored-By: Claude --- README.md | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/README.md b/README.md index 8372c8d..95e4430 100644 --- a/README.md +++ b/README.md @@ -128,26 +128,9 @@ pytest . Every incoming request is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://docs.slack.dev/interactivity/implementing-shortcuts/) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -Each listener registers itself with a decorator. `app.py` calls `load_listeners(app)`, which shares the app as `listeners.app` and imports every module in this directory. To add a listener, create a new file in the matching directory: - -```python -from listeners import app - - -@app.event("app_mention") -def app_mention_callback(say): - say("Hi there!") -``` - ## App Distribution / OAuth -Only implement OAuth if you plan to distribute your application across multiple workspaces. `app_oauth.py` holds the OAuth settings. To start the app over HTTP with OAuth, delete `app.py`, rename `app_oauth.py` to `app.py`, and set these environment variables from your app's **Basic Information** page: - -```zsh -export SLACK_CLIENT_ID= -export SLACK_CLIENT_SECRET= -export SLACK_SIGNING_SECRET= -``` +Only implement OAuth if you plan to distribute your application across multiple workspaces. A separate `app_oauth.py` file can be found with relevant OAuth settings. When using OAuth, Slack requires a public URL where it can send requests. In this template app, we've used [`ngrok`](https://ngrok.com/download). Checkout [this guide](https://ngrok.com/docs#getting-started-expose) for setting it up.