Skip to content

Commit

Permalink
feat: implement basic hook logic (#4)
Browse files Browse the repository at this point in the history
* Add logic and tests

---------

Co-authored-by: Kazuhiro Sera <[email protected]>
Co-authored-by: Fil Maj <[email protected]>
  • Loading branch information
3 people authored Dec 13, 2023
1 parent ab6dcec commit 8e64ddb
Show file tree
Hide file tree
Showing 42 changed files with 1,100 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
156 changes: 154 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,154 @@
# python-slack-hooks
Helper library implementing the contract between the Slack CLI and Bolt for Python
<h1 align="center">Python Slack Hooks</h1>

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: `[email protected]`

## 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
3 changes: 1 addition & 2 deletions requirements/format.txt
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions requirements/testing.txt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions slack_cli_hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions slack_cli_hooks/error/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class CliError(Exception):
"""General class for cli error"""
2 changes: 2 additions & 0 deletions slack_cli_hooks/hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Slack CLI contract implementation for Bolt.
"""
23 changes: 23 additions & 0 deletions slack_cli_hooks/hooks/get_hooks.py
Original file line number Diff line number Diff line change
@@ -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))
50 changes: 50 additions & 0 deletions slack_cli_hooks/hooks/get_manifest.py
Original file line number Diff line number Diff line change
@@ -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()))
64 changes: 64 additions & 0 deletions slack_cli_hooks/hooks/start.py
Original file line number Diff line number Diff line change
@@ -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())
5 changes: 5 additions & 0 deletions slack_cli_hooks/hooks/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .managed_os_env_vars import ManagedOSEnvVars

__all__ = [
"ManagedOSEnvVars",
]
22 changes: 22 additions & 0 deletions slack_cli_hooks/hooks/utils/managed_os_env_vars.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions slack_cli_hooks/protocol/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 8e64ddb

Please sign in to comment.