Skip to content

Instantly share code, notes, and snippets.

@seratch
Last active November 29, 2022 22:20
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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

GoogleDatastoreOAuthStateStore.consume() always returns True.
The code should be:

    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

@seratch
Copy link
Author

seratch commented Oct 6, 2021

@milen-yordanov Thanks for letting me know that! Fixed.

@milen-yordanov
Copy link

milen-yordanov commented Oct 6, 2021

This code looks fragile to me.
There is no explicit query.order = ['key']

See:https://cloud.google.com/datastore/docs/concepts/queries#ordering_of_query_results_is_undefined_when_no_sort_order_is_specified
Ordering of query results is undefined when no sort order is specified

        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

@milen-yordanov
Copy link

milen-yordanov commented Oct 6, 2021

Is there any specific reason why this implementation does not use Datastore composite index?
Now it simulates the composite index by concatenating multiple values in the Key.

{enterprise_id}-{team_id}-{user_id}

All other implementations (SQLite3InstallationStore, SQLAlchemyInstallationStore, DjangoInstallationStore) work with a composite index.
Also I tried to run the tests from TestSQLite3 (python-slack-sdk-main/tests/slack_sdk/oauth/installation_store/test_sqlite3.py)
against GoogleDatastoreInstallationStore and they do not pass.

I'm going to try to rework it to use Datastore composite index instead of concatenated values in the key.

@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