Skip to content

Commit 0a2cee6

Browse files
authored
Add Google Cloud Functions adapter (ref #646) (#649)
1 parent 5d3ee55 commit 0a2cee6

File tree

11 files changed

+742
-8
lines changed

11 files changed

+742
-8
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SLACK_CLIENT_ID: '1111.222'
2+
SLACK_CLIENT_SECRET: 'xxx'
3+
SLACK_SIGNING_SECRET: 'yyy'
4+
SLACK_SCOPES: 'app_mentions:read,chat:write,commands'
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
.env.yaml
1+
.env.yaml
2+
main.py
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#
2+
# Please note that this is an example implementation.
3+
# You can reuse this implementation for your app,
4+
# but we don't have short-term plans to add this code to slack-sdk package.
5+
# Please maintain the code on your own if you copy this file.
6+
#
7+
# Also, please refer to the following gist for more discussion and better implementation:
8+
# https://gist.github.com/seratch/d81a445ef4467b16f047156bf859cda8
9+
#
10+
11+
import logging
12+
from logging import Logger
13+
from typing import Optional
14+
from uuid import uuid4
15+
16+
from google.cloud import datastore
17+
from google.cloud.datastore import Client, Entity, Query
18+
from slack_sdk.oauth import OAuthStateStore, InstallationStore
19+
from slack_sdk.oauth.installation_store import Installation, Bot
20+
21+
22+
class GoogleDatastoreInstallationStore(InstallationStore):
23+
datastore_client: Client
24+
25+
def __init__(
26+
self,
27+
*,
28+
datastore_client: Client,
29+
logger: Logger,
30+
):
31+
self.datastore_client = datastore_client
32+
self._logger = logger
33+
34+
@property
35+
def logger(self) -> Logger:
36+
if self._logger is None:
37+
self._logger = logging.getLogger(__name__)
38+
return self._logger
39+
40+
def installation_key(
41+
self,
42+
*,
43+
enterprise_id: Optional[str],
44+
team_id: Optional[str],
45+
user_id: Optional[str],
46+
suffix: Optional[str] = None,
47+
is_enterprise_install: Optional[bool] = None,
48+
):
49+
enterprise_id = enterprise_id or "none"
50+
team_id = "none" if is_enterprise_install else team_id or "none"
51+
name = (
52+
f"{enterprise_id}-{team_id}-{user_id}"
53+
if user_id
54+
else f"{enterprise_id}-{team_id}"
55+
)
56+
if suffix is not None:
57+
name += "-" + suffix
58+
return self.datastore_client.key("installations", name)
59+
60+
def bot_key(
61+
self,
62+
*,
63+
enterprise_id: Optional[str],
64+
team_id: Optional[str],
65+
suffix: Optional[str] = None,
66+
is_enterprise_install: Optional[bool] = None,
67+
):
68+
enterprise_id = enterprise_id or "none"
69+
team_id = "none" if is_enterprise_install else team_id or "none"
70+
name = f"{enterprise_id}-{team_id}"
71+
if suffix is not None:
72+
name += "-" + suffix
73+
return self.datastore_client.key("bots", name)
74+
75+
def save(self, i: Installation):
76+
# the latest installation in the workspace
77+
installation_entity: Entity = datastore.Entity(
78+
key=self.installation_key(
79+
enterprise_id=i.enterprise_id,
80+
team_id=i.team_id,
81+
user_id=None, # user_id is removed
82+
is_enterprise_install=i.is_enterprise_install,
83+
)
84+
)
85+
installation_entity.update(**i.to_dict())
86+
self.datastore_client.put(installation_entity)
87+
88+
# the latest installation associated with a user
89+
user_entity: Entity = datastore.Entity(
90+
key=self.installation_key(
91+
enterprise_id=i.enterprise_id,
92+
team_id=i.team_id,
93+
user_id=i.user_id,
94+
is_enterprise_install=i.is_enterprise_install,
95+
)
96+
)
97+
user_entity.update(**i.to_dict())
98+
self.datastore_client.put(user_entity)
99+
# history data
100+
user_entity.key = self.installation_key(
101+
enterprise_id=i.enterprise_id,
102+
team_id=i.team_id,
103+
user_id=i.user_id,
104+
is_enterprise_install=i.is_enterprise_install,
105+
suffix=str(i.installed_at),
106+
)
107+
self.datastore_client.put(user_entity)
108+
109+
# the latest bot authorization in the workspace
110+
bot = i.to_bot()
111+
bot_entity: Entity = datastore.Entity(
112+
key=self.bot_key(
113+
enterprise_id=i.enterprise_id,
114+
team_id=i.team_id,
115+
is_enterprise_install=i.is_enterprise_install,
116+
)
117+
)
118+
bot_entity.update(**bot.to_dict())
119+
self.datastore_client.put(bot_entity)
120+
# history data
121+
bot_entity.key = self.bot_key(
122+
enterprise_id=i.enterprise_id,
123+
team_id=i.team_id,
124+
is_enterprise_install=i.is_enterprise_install,
125+
suffix=str(i.installed_at),
126+
)
127+
self.datastore_client.put(bot_entity)
128+
129+
def find_bot(
130+
self,
131+
*,
132+
enterprise_id: Optional[str],
133+
team_id: Optional[str],
134+
is_enterprise_install: Optional[bool] = False,
135+
) -> Optional[Bot]:
136+
entity: Entity = self.datastore_client.get(
137+
self.bot_key(
138+
enterprise_id=enterprise_id,
139+
team_id=team_id,
140+
is_enterprise_install=is_enterprise_install,
141+
)
142+
)
143+
if entity is not None:
144+
entity["installed_at"] = entity["installed_at"].timestamp()
145+
return Bot(**entity)
146+
return None
147+
148+
def find_installation(
149+
self,
150+
*,
151+
enterprise_id: Optional[str],
152+
team_id: Optional[str],
153+
user_id: Optional[str] = None,
154+
is_enterprise_install: Optional[bool] = False,
155+
) -> Optional[Installation]:
156+
entity: Entity = self.datastore_client.get(
157+
self.installation_key(
158+
enterprise_id=enterprise_id,
159+
team_id=team_id,
160+
user_id=user_id,
161+
is_enterprise_install=is_enterprise_install,
162+
)
163+
)
164+
if entity is not None:
165+
entity["installed_at"] = entity["installed_at"].timestamp()
166+
return Installation(**entity)
167+
return None
168+
169+
def delete_installation(
170+
self,
171+
enterprise_id: Optional[str],
172+
team_id: Optional[str],
173+
user_id: Optional[str],
174+
) -> None:
175+
installation_key = self.installation_key(
176+
enterprise_id=enterprise_id,
177+
team_id=team_id,
178+
user_id=user_id,
179+
)
180+
q: Query = self.datastore_client.query()
181+
q.key_filter(installation_key, ">=")
182+
for entity in q.fetch():
183+
if entity.key.name.startswith(installation_key.name):
184+
self.datastore_client.delete(entity.key)
185+
else:
186+
break
187+
188+
def delete_bot(
189+
self,
190+
enterprise_id: Optional[str],
191+
team_id: Optional[str],
192+
) -> None:
193+
bot_key = self.bot_key(
194+
enterprise_id=enterprise_id,
195+
team_id=team_id,
196+
)
197+
q: Query = self.datastore_client.query()
198+
q.key_filter(bot_key, ">=")
199+
for entity in q.fetch():
200+
if entity.key.name.startswith(bot_key.name):
201+
self.datastore_client.delete(entity.key)
202+
else:
203+
break
204+
205+
def delete_all(
206+
self,
207+
enterprise_id: Optional[str],
208+
team_id: Optional[str],
209+
):
210+
self.delete_bot(enterprise_id=enterprise_id, team_id=team_id)
211+
self.delete_installation(
212+
enterprise_id=enterprise_id, team_id=team_id, user_id=None
213+
)
214+
215+
216+
class GoogleDatastoreOAuthStateStore(OAuthStateStore):
217+
logger: Logger
218+
datastore_client: Client
219+
collection_id: str
220+
221+
def __init__(
222+
self,
223+
*,
224+
datastore_client: Client,
225+
logger: Logger,
226+
):
227+
self.datastore_client = datastore_client
228+
self._logger = logger
229+
self.collection_id = "oauth_state_values"
230+
231+
@property
232+
def logger(self) -> Logger:
233+
if self._logger is None:
234+
self._logger = logging.getLogger(__name__)
235+
return self._logger
236+
237+
def consume(self, state: str) -> bool:
238+
key = self.datastore_client.key(self.collection_id, state)
239+
entity = self.datastore_client.get(key)
240+
if entity is not None:
241+
self.datastore_client.delete(key)
242+
return True
243+
return False
244+
245+
def issue(self, *args, **kwargs) -> str:
246+
state_value = str(uuid4())
247+
entity: Entity = datastore.Entity(
248+
key=self.datastore_client.key(self.collection_id, state_value)
249+
)
250+
entity.update(value=state_value)
251+
self.datastore_client.put(entity)
252+
return state_value
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# TODO: Once this once a new version newer than 1.13.2, delete this file
2+
3+
from typing import Callable
4+
5+
from flask import Request, Response, make_response
6+
7+
from slack_bolt.app import App
8+
from slack_bolt.error import BoltError
9+
from slack_bolt.lazy_listener import LazyListenerRunner
10+
from slack_bolt.oauth import OAuthFlow
11+
from slack_bolt.request import BoltRequest
12+
from slack_bolt.response import BoltResponse
13+
14+
15+
def to_bolt_request(req: Request) -> BoltRequest:
16+
return BoltRequest( # type: ignore
17+
body=req.get_data(as_text=True),
18+
query=req.query_string.decode("utf-8"),
19+
headers=req.headers, # type: ignore
20+
) # type: ignore
21+
22+
23+
def to_flask_response(bolt_resp: BoltResponse) -> Response:
24+
resp: Response = make_response(bolt_resp.body, bolt_resp.status)
25+
for k, values in bolt_resp.headers.items():
26+
if k.lower() == "content-type" and resp.headers.get("content-type") is not None:
27+
# Remove the one set by Flask
28+
resp.headers.pop("content-type")
29+
for v in values:
30+
resp.headers.add_header(k, v)
31+
return resp
32+
33+
34+
class NoopLazyListenerRunner(LazyListenerRunner):
35+
def start(self, function: Callable[..., None], request: BoltRequest) -> None:
36+
raise BoltError(
37+
"The google_cloud_functions adapter does not support lazy listeners. "
38+
"Please consider either having a queue to pass the request to a different function or "
39+
"rewriting your code not to use lazy listeners."
40+
)
41+
42+
43+
class SlackRequestHandler:
44+
def __init__(self, app: App): # type: ignore
45+
self.app = app
46+
# Note that lazy listener is not supported
47+
self.app.listener_runner.lazy_listener_runner = NoopLazyListenerRunner()
48+
if self.app.oauth_flow is not None:
49+
self.app.oauth_flow.settings.redirect_uri_page_renderer.install_path = "?"
50+
51+
def handle(self, req: Request) -> Response:
52+
if req.method == "GET":
53+
if self.app.oauth_flow is not None:
54+
oauth_flow: OAuthFlow = self.app.oauth_flow
55+
bolt_req = to_bolt_request(req)
56+
if "code" in req.args or "error" in req.args or "state" in req.args:
57+
bolt_resp = oauth_flow.handle_callback(bolt_req)
58+
return to_flask_response(bolt_resp)
59+
else:
60+
bolt_resp = oauth_flow.handle_installation(bolt_req)
61+
return to_flask_response(bolt_resp)
62+
elif req.method == "POST":
63+
bolt_resp: BoltResponse = self.app.dispatch(to_bolt_request(req))
64+
return to_flask_response(bolt_resp)
65+
66+
return make_response("Not Found", 404)

0 commit comments

Comments
 (0)