Skip to content

Instantly share code, notes, and snippets.

@seratch
Last active November 29, 2022 22:20
Show Gist options
  • Save seratch/d81a445ef4467b16f047156bf859cda8 to your computer and use it in GitHub Desktop.
Save seratch/d81a445ef4467b16f047156bf859cda8 to your computer and use it in GitHub Desktop.
Slack OAuth App Example (Google Cloud Run + Datastore)

Prerequisites

Google Cloud

To run this app, you need to enable the following components in Google Cloud Platform.

  • Cloud Run
  • Datastore (Firestore)

Slack App

  • Create a new app - https://api.slack.com/apps?new_app=1
  • Enable OAuth
    • Redirect URL: https://{your-domain}.run.app/slack/oauth_redirect (set this URL after the first deployment)
  • Add the following bot token scopes
    • app_mentions:read
    • chat:write
  • Enable Event Subscriptions
    • Set the Request URL to https://{your-domain}.run.app/slack/events (set this URL after the first deployment)
  • Subscribe to the following bot events
    • app_mention
    • tokens_revoked
    • app_uninstalled

How to deploy this app

# Check Basic Information page
export SLACK_CLIENT_ID=111.111
export SLACK_CLIENT_SECRET=xxx
export SLACK_SIGNING_SECRET=xxx

export PROJECT_ID=`gcloud config get-value project`
gcloud builds submit --tag gcr.io/$PROJECT_ID/slack-oauth-app
gcloud run deploy slack-oauth-app --image gcr.io/$PROJECT_ID/slack-oauth-app --platform managed --update-env-vars SLACK_SIGNING_SECRET=$SLACK_SIGNING_SECRET,SLACK_CLIENT_ID=$SLACK_CLIENT_ID,SLACK_CLIENT_SECRET=$SLACK_CLIENT_SECRET
import logging
from logging import Logger
from typing import Optional
from uuid import uuid4
from google.cloud import datastore
from google.cloud.datastore import Client, Entity, Query
from slack_sdk.oauth import OAuthStateStore, InstallationStore
from slack_sdk.oauth.installation_store import Installation, Bot
class GoogleDatastoreInstallationStore(InstallationStore):
datastore_client: Client
def __init__(
self,
*,
datastore_client: Client,
logger: Logger,
):
self.datastore_client = datastore_client
self._logger = logger
@property
def logger(self) -> Logger:
if self._logger is None:
self._logger = logging.getLogger(__name__)
return self._logger
def installation_key(
self,
*,
enterprise_id: Optional[str],
team_id: Optional[str],
user_id: Optional[str],
suffix: Optional[str] = None,
is_enterprise_install: Optional[bool] = None,
):
enterprise_id = enterprise_id or "none"
team_id = "none" if is_enterprise_install else team_id or "none"
name = (
f"{enterprise_id}-{team_id}-{user_id}"
if user_id
else f"{enterprise_id}-{team_id}"
)
if suffix is not None:
name += "-" + suffix
return self.datastore_client.key("installations", name)
def bot_key(
self,
*,
enterprise_id: Optional[str],
team_id: Optional[str],
suffix: Optional[str] = None,
is_enterprise_install: Optional[bool] = None,
):
enterprise_id = enterprise_id or "none"
team_id = "none" if is_enterprise_install else team_id or "none"
name = f"{enterprise_id}-{team_id}"
if suffix is not None:
name += "-" + suffix
return self.datastore_client.key("bots", name)
def save(self, i: Installation):
# the latest installation in the workspace
installation_entity: Entity = datastore.Entity(
key=self.installation_key(
enterprise_id=i.enterprise_id,
team_id=i.team_id,
user_id=None, # user_id is removed
is_enterprise_install=i.is_enterprise_install,
)
)
installation_entity.update(**i.to_dict())
self.datastore_client.put(installation_entity)
# the latest installation associated with a user
user_entity: Entity = datastore.Entity(
key=self.installation_key(
enterprise_id=i.enterprise_id,
team_id=i.team_id,
user_id=i.user_id,
is_enterprise_install=i.is_enterprise_install,
)
)
user_entity.update(**i.to_dict())
self.datastore_client.put(user_entity)
# history data
user_entity.key = self.installation_key(
enterprise_id=i.enterprise_id,
team_id=i.team_id,
user_id=i.user_id,
is_enterprise_install=i.is_enterprise_install,
suffix=str(i.installed_at),
)
self.datastore_client.put(user_entity)
# the latest bot authorization in the workspace
bot = i.to_bot()
bot_entity: Entity = datastore.Entity(
key=self.bot_key(
enterprise_id=i.enterprise_id,
team_id=i.team_id,
is_enterprise_install=i.is_enterprise_install,
)
)
bot_entity.update(**bot.to_dict())
self.datastore_client.put(bot_entity)
# history data
bot_entity.key = self.bot_key(
enterprise_id=i.enterprise_id,
team_id=i.team_id,
is_enterprise_install=i.is_enterprise_install,
suffix=str(i.installed_at),
)
self.datastore_client.put(bot_entity)
def find_bot(
self,
*,
enterprise_id: Optional[str],
team_id: Optional[str],
is_enterprise_install: Optional[bool] = False,
) -> Optional[Bot]:
entity: Entity = self.datastore_client.get(
self.bot_key(
enterprise_id=enterprise_id,
team_id=team_id,
is_enterprise_install=is_enterprise_install,
)
)
if entity is not None:
entity["installed_at"] = entity["installed_at"].timestamp()
return Bot(**entity)
return None
def find_installation(
self,
*,
enterprise_id: Optional[str],
team_id: Optional[str],
user_id: Optional[str] = None,
is_enterprise_install: Optional[bool] = False,
) -> Optional[Installation]:
entity: Entity = self.datastore_client.get(
self.installation_key(
enterprise_id=enterprise_id,
team_id=team_id,
user_id=user_id,
is_enterprise_install=is_enterprise_install,
)
)
if entity is not None:
entity["installed_at"] = entity["installed_at"].timestamp()
return Installation(**entity)
return None
def delete_installation(
self,
enterprise_id: Optional[str],
team_id: Optional[str],
user_id: Optional[str],
) -> None:
installation_key = self.installation_key(
enterprise_id=enterprise_id,
team_id=team_id,
user_id=user_id,
)
q: Query = self.datastore_client.query()
q.key_filter(installation_key, ">=")
for entity in q.fetch():
if entity.key.name.startswith(installation_key.name):
self.datastore_client.delete(entity.key)
else:
break
def delete_bot(
self,
enterprise_id: Optional[str],
team_id: Optional[str],
) -> None:
bot_key = self.bot_key(
enterprise_id=enterprise_id,
team_id=team_id,
)
q: Query = self.datastore_client.query()
q.key_filter(bot_key, ">=")
for entity in q.fetch():
if entity.key.name.startswith(bot_key.name):
self.datastore_client.delete(entity.key)
else:
break
def delete_all(
self,
enterprise_id: Optional[str],
team_id: Optional[str],
):
self.delete_bot(enterprise_id=enterprise_id, team_id=team_id)
self.delete_installation(
enterprise_id=enterprise_id, team_id=team_id, user_id=None
)
class GoogleDatastoreOAuthStateStore(OAuthStateStore):
logger: Logger
datastore_client: Client
collection_id: str
def __init__(
self,
*,
datastore_client: Client,
logger: Logger,
):
self.datastore_client = datastore_client
self._logger = logger
self.collection_id = "oauth_state_values"
@property
def logger(self) -> Logger:
if self._logger is None:
self._logger = logging.getLogger(__name__)
return self._logger
def consume(self, state: str) -> bool:
key = self.datastore_client.key(self.collection_id, state)
entity = self.datastore_client.get(key)
if entity is not None:
self.datastore_client.delete(key)
return True
return False
def issue(self, *args, **kwargs) -> str:
state_value = str(uuid4())
entity: Entity = datastore.Entity(
key=self.datastore_client.key(self.collection_id, state_value)
)
entity.update(value=state_value)
self.datastore_client.put(entity)
return state_value
# https://hub.docker.com/_/python
FROM python:3.9.2-slim-buster
# Allow statements and log messages to immediately appear in the Knative logs
ENV PYTHONUNBUFFERED True
# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./
# Install production dependencies.
RUN pip install -U pip && pip install -r requirements.txt
# Run the web service on container startup.
ENTRYPOINT gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:flask_app
import logging
import os
from flask import Flask, request, make_response
from google.cloud import datastore
from google.cloud.datastore import Client
from slack_bolt import App, BoltContext
from slack_bolt.adapter.flask import SlackRequestHandler
from slack_bolt.oauth.oauth_settings import OAuthSettings
from datastore import GoogleDatastoreInstallationStore, GoogleDatastoreOAuthStateStore
datastore_client: Client = datastore.Client()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.DEBUG)
installation_store = GoogleDatastoreInstallationStore(
datastore_client=datastore_client,
logger=logger,
)
app = App(
signing_secret=os.environ["SLACK_SIGNING_SECRET"],
oauth_settings=OAuthSettings(
client_id=os.environ["SLACK_CLIENT_ID"],
client_secret=os.environ["SLACK_CLIENT_SECRET"],
scopes=["app_mentions:read", "chat:write"],
installation_store=installation_store,
state_store=GoogleDatastoreOAuthStateStore(
datastore_client=datastore_client,
logger=logger,
),
install_path="/slack/install",
redirect_uri_path="/slack/oauth_redirect",
),
)
#
# Slack App Event Listeners
#
@app.event("app_mention")
def handle_app_mentions(logger, event, say):
logger.info(event)
say(f"Hi there <@{event['user']}>!")
@app.event("tokens_revoked")
def handle_token_revocations(event: dict, context: BoltContext):
user_ids = event["tokens"].get("oauth")
if user_ids is not None and len(user_ids) > 0:
for user_id in user_ids:
installation_store.delete_installation(
context.enterprise_id, context.team_id, user_id
)
bot_user_ids = event["tokens"].get("bot")
if bot_user_ids is not None and len(bot_user_ids) > 0:
installation_store.delete_bot(context.enterprise_id, context.team_id)
@app.event("app_uninstalled")
def handle_uninstallations(context: BoltContext):
installation_store.delete_all(context.enterprise_id, context.team_id)
#
# Web endpoints
#
flask_app = Flask(__name__)
handler = SlackRequestHandler(app)
@flask_app.route("/", methods=["GET"])
def root():
return make_response("Hello World!", 200)
@flask_app.route("/slack/install", methods=["GET"])
def install():
return handler.handle(request)
@flask_app.route("/slack/oauth_redirect", methods=["GET"])
def oauth_redirect():
return handler.handle(request)
@flask_app.route("/slack/events", methods=["POST"])
def events():
return handler.handle(request)
if __name__ == "__main__":
# for local development
flask_app.run(
host="0.0.0.0",
port=int(os.environ["PORT"]),
use_debugger=True,
debug=True,
use_reloader=True,
)
slack_bolt>=1.4.3,<2
Flask>=1.1,<2
gunicorn>=20
google-cloud-datastore>=2.1.0,<3
@milen-yordanov
Copy link

Thanks for the initial implementation. It will be great if the Slack Bolt SDK includes Datastore support.

Here is my refactored implementation, if anybody is interested.

https://gist.github.com/milen-yordanov/966a81790b183e48a2831aeec323f550

The changes are:

  • It creates just one DB record per installation instead of 3.
  • It does not concatenate IDs for the record Key {enterprise_id}-{team_id}-{user_id}
  • Passes the unit tests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment