Skip to content

Commit 6080f1e

Browse files
committed
Add more validation rules for view construction (thanks to feedback in #519)
1 parent 61b62fb commit 6080f1e

5 files changed

Lines changed: 290 additions & 6 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# ------------------
2+
# Only for running this script here
3+
import json
4+
import logging
5+
import sys
6+
from os.path import dirname
7+
8+
sys.path.insert(1, f"{dirname(__file__)}/../../..")
9+
logging.basicConfig(level=logging.DEBUG)
10+
11+
# ---------------------
12+
# Slack WebClient
13+
# ---------------------
14+
15+
import os
16+
17+
from slack import WebClient
18+
from slack.errors import SlackApiError
19+
from slack.signature import SignatureVerifier
20+
from slack.web.classes.blocks import InputBlock
21+
from slack.web.classes.elements import PlainTextInputElement
22+
from slack.web.classes.objects import PlainTextObject
23+
from slack.web.classes.views import View
24+
25+
client = WebClient(token=os.environ["SLACK_API_TOKEN"])
26+
signature_verifier = SignatureVerifier(os.environ["SLACK_SIGNING_SECRET"])
27+
28+
# ---------------------
29+
# Flask App
30+
# ---------------------
31+
32+
# pip3 install flask
33+
from flask import Flask, request, make_response
34+
35+
app = Flask(__name__)
36+
37+
38+
@app.route("/slack/events", methods=["POST"])
39+
def slack_app():
40+
if not signature_verifier.is_valid_request(request.get_data(), request.headers):
41+
return make_response("invalid request", 403)
42+
43+
if "command" in request.form \
44+
and request.form["command"] == "/open-modal":
45+
trigger_id = request.form["trigger_id"]
46+
try:
47+
view = View(
48+
type="modal",
49+
callback_id="modal-id",
50+
title=PlainTextObject(text="Awesome Modal"),
51+
submit=PlainTextObject(text="Submit"),
52+
close=PlainTextObject(text="Cancel"),
53+
blocks=[
54+
InputBlock(
55+
block_id="b-id",
56+
label=PlainTextObject(text="Input label"),
57+
element=PlainTextInputElement(action_id="a-id")
58+
)
59+
]
60+
)
61+
response = client.views_open(
62+
trigger_id=trigger_id,
63+
view=view
64+
)
65+
return make_response("", 200)
66+
except SlackApiError as e:
67+
code = e.response["error"]
68+
return make_response(f"Failed to open a modal due to {code}", 200)
69+
70+
elif "payload" in request.form:
71+
payload = json.loads(request.form["payload"])
72+
if payload["type"] == "view_submission" \
73+
and payload["view"]["callback_id"] == "modal-id":
74+
submitted_data = payload["view"]["state"]["values"]
75+
print(submitted_data) # {'b-id': {'a-id': {'type': 'plain_text_input', 'value': 'your input'}}}
76+
return make_response("", 200)
77+
78+
return make_response("", 404)
79+
80+
81+
if __name__ == "__main__":
82+
# export SLACK_SIGNING_SECRET=***
83+
# export SLACK_API_TOKEN=xoxb-***
84+
# export FLASK_ENV=development
85+
# python3 integration_tests/samples/basic_usage/views_2.py
86+
app.run("localhost", 3000)
87+
88+
# ngrok http 3000

slack/web/classes/views.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class View(JsonObject):
1313
https://api.slack.com/reference/surfaces/views
1414
"""
1515

16+
types = ["modal", "home"]
17+
1618
attributes = {
1719
"type",
1820
"id",
@@ -36,7 +38,7 @@ class View(JsonObject):
3638

3739
def __init__(
3840
self,
39-
type: str = None, # "modal", "home"
41+
type: str, # "modal", "home"
4042
id: Optional[str] = None,
4143
callback_id: Optional[str] = None,
4244
external_id: Optional[str] = None,
@@ -83,14 +85,31 @@ def __init__(
8385
private_metadata_max_length = 3000
8486
callback_id_max_length: int = 255
8587

88+
@JsonValidator('type must be either "modal" or "home"')
89+
def _validate_type(self):
90+
return self.type is not None and self.type in self.types
91+
8692
@JsonValidator(f"title must be between 1 and {title_max_length} characters")
8793
def _validate_title_length(self):
8894
return self.title is None or 1 <= len(self.title.text) <= self.title_max_length
8995

90-
@JsonValidator(f"modals must contain between 1 and {blocks_max_length} blocks")
96+
@JsonValidator(f"views must contain between 1 and {blocks_max_length} blocks")
9197
def _validate_blocks_length(self):
9298
return self.blocks is None or 0 < len(self.blocks) <= self.blocks_max_length
9399

100+
@JsonValidator("home view cannot have input blocks")
101+
def _validate_input_blocks(self):
102+
return self.type == "modal" or (
103+
self.type == "home"
104+
and len([b for b in self.blocks if b.type == "input"]) == 0
105+
)
106+
107+
@JsonValidator("home view cannot have submit and close")
108+
def _validate_home_tab_structure(self):
109+
return self.type != "home" or (
110+
self.type == "home" and self.close is None and self.submit is None
111+
)
112+
94113
@JsonValidator(f"close cannot exceed {close_max_length} characters")
95114
def _validate_close_length(self):
96115
return self.close is None or len(self.close.text) <= self.close_max_length

slack/web/client.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import slack.errors as e
99
from slack.web.base_client import BaseClient, SlackResponse
10+
from slack.web.classes.views import View
1011

1112

1213
class WebClient(BaseClient):
@@ -1855,7 +1856,7 @@ def users_profile_set(self, **kwargs) -> Union[Future, SlackResponse]:
18551856
return self.api_call("users.profile.set", json=kwargs)
18561857

18571858
def views_open(
1858-
self, *, trigger_id: str, view: dict, **kwargs
1859+
self, *, trigger_id: str, view: Union[dict, View], **kwargs
18591860
) -> Union[Future, SlackResponse]:
18601861
"""Open a view for a user.
18611862
@@ -1868,9 +1869,13 @@ def views_open(
18681869
Args:
18691870
trigger_id (str): Exchange a trigger to post to the user.
18701871
e.g. '12345.98765.abcd2358fdea'
1871-
view (dict): The view payload.
1872+
view (dict or View): The view payload.
18721873
"""
1873-
kwargs.update({"trigger_id": trigger_id, "view": view})
1874+
kwargs.update({"trigger_id": trigger_id})
1875+
if isinstance(view, View):
1876+
kwargs.update({"view": view.to_dict()})
1877+
else:
1878+
kwargs.update({"view": view})
18741879
return self.api_call("views.open", json=kwargs)
18751880

18761881
def views_push(

tests/web/classes/test_views.py

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import logging
33
import unittest
44

5-
from slack.web.classes.objects import PlainTextObject, Option
5+
from slack.errors import SlackObjectFormationError
6+
from slack.web.classes.blocks import InputBlock, SectionBlock, DividerBlock, ActionsBlock, ContextBlock
7+
from slack.web.classes.elements import PlainTextInputElement, RadioButtonsElement, CheckboxesElement, ButtonElement, \
8+
ImageElement
9+
from slack.web.classes.objects import PlainTextObject, Option, MarkdownTextObject
610
from slack.web.classes.views import View, ViewState, ViewStateValue
711

812

@@ -21,6 +25,85 @@ def verify_loaded_view_object(self, file):
2125
# Modals
2226
# --------------------------------
2327

28+
def test_valid_construction(self):
29+
modal_view = View(
30+
type="modal",
31+
callback_id="modal-id",
32+
title=PlainTextObject(text="Awesome Modal"),
33+
submit=PlainTextObject(text="Submit"),
34+
close=PlainTextObject(text="Cancel"),
35+
blocks=[
36+
InputBlock(
37+
block_id="b-id",
38+
label=PlainTextObject(text="Input label"),
39+
element=PlainTextInputElement(action_id="a-id")
40+
),
41+
InputBlock(
42+
block_id="cb-id",
43+
label=PlainTextObject(text="Label"),
44+
element=CheckboxesElement(
45+
action_id="a-cb-id",
46+
options=[
47+
Option(text=PlainTextObject(text="*this is plain_text text*"), value="v1"),
48+
Option(text=MarkdownTextObject(text="*this is mrkdwn text*"), value="v2"),
49+
],
50+
),
51+
),
52+
SectionBlock(
53+
block_id="sb-id",
54+
text=MarkdownTextObject(text="This is a mrkdwn text section block."),
55+
fields=[
56+
PlainTextObject(text="*this is plain_text text*", emoji=True),
57+
MarkdownTextObject(text="*this is mrkdwn text*"),
58+
PlainTextObject(text="*this is plain_text text*", emoji=True),
59+
]
60+
),
61+
DividerBlock(),
62+
SectionBlock(
63+
block_id="rb-id",
64+
text=MarkdownTextObject(text="This is a section block with radio button accessory"),
65+
accessory=RadioButtonsElement(
66+
initial_option=Option(
67+
text=PlainTextObject(text="Option 1"),
68+
value="option 1",
69+
description=PlainTextObject(text="Description for option 1"),
70+
),
71+
options=[
72+
Option(
73+
text=PlainTextObject(text="Option 1"),
74+
value="option 1",
75+
description=PlainTextObject(text="Description for option 1"),
76+
),
77+
Option(
78+
text=PlainTextObject(text="Option 2"),
79+
value="option 2",
80+
description=PlainTextObject(text="Description for option 2"),
81+
),
82+
]
83+
)
84+
)
85+
]
86+
)
87+
modal_view.validate_json()
88+
89+
def test_invalid_type_value(self):
90+
modal_view = View(
91+
type="modallll",
92+
callback_id="modal-id",
93+
title=PlainTextObject(text="Awesome Modal"),
94+
submit=PlainTextObject(text="Submit"),
95+
close=PlainTextObject(text="Cancel"),
96+
blocks=[
97+
InputBlock(
98+
block_id="b-id",
99+
label=PlainTextObject(text="Input label"),
100+
element=PlainTextInputElement(action_id="a-id")
101+
),
102+
]
103+
)
104+
with self.assertRaises(SlackObjectFormationError):
105+
modal_view.validate_json()
106+
24107
def test_simple_state_values(self):
25108
expected = {
26109
"values": {
@@ -270,6 +353,89 @@ def test_load_modal_view_010(self):
270353
# Home Tabs
271354
# --------------------------------
272355

356+
def test_home_tab_construction(self):
357+
home_tab_view = View(
358+
type="home",
359+
blocks=[
360+
SectionBlock(
361+
text=MarkdownTextObject(text="*Here's what you can do with Project Tracker:*"),
362+
),
363+
ActionsBlock(
364+
elements=[
365+
ButtonElement(
366+
text=PlainTextObject(text="Create New Task", emoji=True),
367+
style="primary",
368+
value="create_task",
369+
),
370+
ButtonElement(
371+
text=PlainTextObject(text="Create New Project", emoji=True),
372+
value="create_project",
373+
),
374+
ButtonElement(
375+
text=PlainTextObject(text="Help", emoji=True),
376+
value="help",
377+
),
378+
],
379+
),
380+
ContextBlock(
381+
elements=[
382+
ImageElement(
383+
image_url="https://api.slack.com/img/blocks/bkb_template_images/placeholder.png",
384+
alt_text="placeholder",
385+
),
386+
],
387+
),
388+
SectionBlock(
389+
text=MarkdownTextObject(text="*Your Configurations*"),
390+
),
391+
DividerBlock(),
392+
SectionBlock(
393+
text=MarkdownTextObject(
394+
text="*#public-relations*\n<fakelink.toUrl.com|PR Strategy 2019> posts new tasks, comments, and project updates to <fakelink.toChannel.com|#public-relations>"),
395+
accessory=ButtonElement(
396+
text=PlainTextObject(text="Edit", emoji=True),
397+
value="public-relations",
398+
),
399+
)
400+
],
401+
)
402+
home_tab_view.validate_json()
403+
404+
def test_input_blocks_in_home_tab(self):
405+
modal_view = View(
406+
type="home",
407+
callback_id="home-tab-id",
408+
blocks=[
409+
InputBlock(
410+
block_id="b-id",
411+
label=PlainTextObject(text="Input label"),
412+
element=PlainTextInputElement(action_id="a-id")
413+
),
414+
]
415+
)
416+
with self.assertRaises(SlackObjectFormationError):
417+
modal_view.validate_json()
418+
419+
def test_submit_in_home_tab(self):
420+
modal_view = View(
421+
type="home",
422+
callback_id="home-tab-id",
423+
submit=PlainTextObject(text="Submit"),
424+
blocks=[DividerBlock()]
425+
)
426+
with self.assertRaises(SlackObjectFormationError):
427+
modal_view.validate_json()
428+
429+
def test_close_in_home_tab(self):
430+
modal_view = View(
431+
type="home",
432+
callback_id="home-tab-id",
433+
close=PlainTextObject(text="Cancel"),
434+
blocks=[DividerBlock()]
435+
)
436+
with self.assertRaises(SlackObjectFormationError):
437+
modal_view.validate_json()
438+
273439
def test_load_home_tab_view_001(self):
274440
with open("tests/data/view_home_001.json") as file:
275441
self.verify_loaded_view_object(file)

tests/web/test_web_client_coverage.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import unittest
33

44
import slack
5+
from slack.web.classes.blocks import DividerBlock
6+
from slack.web.classes.views import View
57
from tests.web.mock_web_api_server import setup_mock_web_api_server, cleanup_mock_web_api_server
68

79

@@ -273,6 +275,10 @@ def test_coverage(self):
273275
self.api_methods_to_call.remove(method(presence="away")["method"])
274276
elif method_name == "views_open":
275277
self.api_methods_to_call.remove(method(trigger_id="123123", view={})["method"])
278+
method(
279+
trigger_id="123123",
280+
view=View(type="modal", blocks=[DividerBlock()])
281+
)
276282
elif method_name == "views_publish":
277283
self.api_methods_to_call.remove(method(user_id="U123", view={})["method"])
278284
elif method_name == "views_push":

0 commit comments

Comments
 (0)