diff --git a/apptools/feedback/feedbackbot/README.md b/apptools/feedback/feedbackbot/README.md new file mode 100644 index 000000000..847507451 --- /dev/null +++ b/apptools/feedback/feedbackbot/README.md @@ -0,0 +1,32 @@ +Model and GUI logic for a Feedback/Bugs dialog box. The comments in this +[example app](https://github.com/enthought/apptools/tree/master/examples/feedback) +demonstrate how the dialog box can be incorporated in any TraitsUI app. + +### Requirements: +- python3 +- numpy +- PIL +- python-slackclient + +### Slack setup + +Create a Slack app and install it to the Slack workspace. Then, add a bot user +to the app (detailed instructions are provided +[here](https://api.slack.com/bot-users)). + +#### Tokens, authentication, and security + +Requests to the Slack API have to be authenticated with an authorization token. +A token for the bot-user will be created automatically when the bot is added to +the Slack app. This token must be provided to the model class when an instance +is created. + +The bearer of a bot token has a pretty broad set of permissions. For instance, +they can upload files to a channel, lookup a user with an email address, and +even get the entire conversation history of a channel (see this +[link](https://api.slack.com/bot-users#methods) for a full list of functions +accessible with a bot token). Needless to say, tokens must be secured and never +revealed publicly. The responsibility of transmitting tokens securely lies with the +developer of the app incorporating this dialog box. Refer to the [Slack API +documentation](https://api.slack.com/docs/oauth-safety) +for security best-practices. diff --git a/apptools/feedback/feedbackbot/__init__.py b/apptools/feedback/feedbackbot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py new file mode 100644 index 000000000..ca2483f54 --- /dev/null +++ b/apptools/feedback/feedbackbot/model.py @@ -0,0 +1,97 @@ +""" +This module implements a class that provides logic for a simple plugin +for sending messages to a developer team's slack channel. +""" + +import io +import logging + +import slack +from PIL import Image + +from traits.api import ( + HasRequiredTraits, Str, Property, Array, String) + +logger = logging.getLogger(__name__) + + +class FeedbackMessage(HasRequiredTraits): + """ Model for the feedback message. + + Notes + ----- + The user-developer must specify the slack channel that the message must be + sent to, as well as provide raw screenshot data. + + """ + + #: Name of the client user + name = Str(msg_meta=True) + + #: Name of the client organization. + organization = Str(msg_meta=True) + + # TODO: Slack supports some markdown in messages, provide + # some details here. + #: Main body of the feedback message. + description = Str(msg_meta=True) + + #: The target slack channel that the bot will post to, must start with # + # and must be provided by the user-developer. + channels = String(minlen=2, regex='#.*', required=True) + + #: OAuth token for the slackbot, must be provided by the user-developer. + token = Str(required=True) + + #: The final message that gets posted to Slack. + msg = Property(Str, depends_on='+msg_meta') + + #: 3D numpy array to hold three channel (RGB) screenshot pixel data. + img_data = Array(shape=(None, None, 3), dtype='uint8', required=True) + + def _get_msg(self): + + feedback_template = 'Name: {name}\n' \ + + 'Organization: {org}\nDescription: {desc}' + + return feedback_template.format( + name=self.name, + org=self.organization, + desc=self.description) + + def send(self): + """ Send feedback message and screenshot to Slack. """ + + # Set up object that talks to Slack's API. Note that the run_async + # flag is False. This ensures that each HTTP request is blocking. More + # precisely, the WebClient sets up an event loop with just a single + # HTTP request in it, and ensures that the event loop runs to + # completion before returning. + client = slack.WebClient(token=self.token, + timeout=5, + ssl=True, + run_async=False) + + logger.info("Attempting to send message: <%s> to channel: <%s>", + self.msg, self.channels) + + # Compress screenshot into PNG format using an in-memory buffer. + compressed_img_buf = io.BytesIO() + + Image.fromarray(self.img_data).save(compressed_img_buf, 'PNG') + + compressed_img_buf.seek(0) + + # Send message. + response = client.files_upload( + channels=self.channels, + initial_comment=self.msg, + filetype='png', + filename='screenshot.png', + file=compressed_img_buf) + + logger.info("Message sent." + + " Slack responded with OK : {ok_resp}".format( + ok_resp=response['ok'])) + + return response diff --git a/apptools/feedback/feedbackbot/tests/test_model.py b/apptools/feedback/feedbackbot/tests/test_model.py new file mode 100644 index 000000000..e32204316 --- /dev/null +++ b/apptools/feedback/feedbackbot/tests/test_model.py @@ -0,0 +1,109 @@ +""" +Tests for FeedbackMessage model. +""" + +import numpy as np +import unittest +from unittest.mock import patch +from PIL import Image + +from traits.api import TraitError +from traits.testing.unittest_tools import UnittestTools + +from apptools.feedback.feedbackbot.model import FeedbackMessage + + +class TestFeedbackMessage(unittest.TestCase, UnittestTools): + + def test_invalid_img_data_raises_error(self): + """ Test that setting img_data to an incorrectly shaped array + raises a TraitError. + + """ + + with self.assertRaises(TraitError): + + FeedbackMessage(img_data=np.empty((2, 2)), + token='xoxb-123456', + channels='#general') + + def test_invalid_channel_raises_error(self): + """ Test that passing a channel name that doesn't begin with + '#' raises TraitError. + + """ + + with self.assertRaises(TraitError): + + FeedbackMessage(img_data=np.empty((1, 1, 3)), + token='xoxb-123456', + channels='general') + + def test_send(self): + """ Test that the slack client call happens with the correct arguments. + + """ + + img_data = np.array([[[1, 2, 3]]], dtype=np.uint8) + + token = 'xoxb-123456' + + channels = '#general' + + msg = FeedbackMessage(img_data=img_data, + token=token, + channels=channels) + + msg.name = 'Tom Riddle' + msg.organization = 'Death Eather, Inc' + msg.description = 'No one calls me Voldy.' + + expected_msg = 'Name: {}\nOrganization: {}\nDescription: {}'.format( + msg.name, msg.organization, msg.description) + + files_upload_found = False + + with patch('apptools.feedback.feedbackbot.model.slack'): + + with patch('apptools.feedback.feedbackbot.model.slack.WebClient') \ + as mock_client: + + msg.send() + + for call_ in mock_client.mock_calls: + # Loop over all calls made to mock_client, including nested + # function calls. + + # Glean function name, provided positional and keyword + # arguments in call_ + name, args, kwargs = call_ + + if name == '().files_upload': + + files_upload_found = True + + # The following lines ensure that is + # called with the correct arguments. + + # There shouldn't be any positional arguments. + self.assertTupleEqual((), args) + + # The following lines check whether keyword arguments + # were passed correctly. + np.testing.assert_almost_equal( + img_data, np.array(Image.open(kwargs['file']))) + + self.assertEqual(channels, kwargs['channels']) + + self.assertEqual( + expected_msg, kwargs['initial_comment']) + + if not files_upload_found: + + self.fail( + "Call to Slack API method not found.") + + +if __name__ == '__main__': + + unittest.main() diff --git a/apptools/feedback/feedbackbot/tests/test_view.py b/apptools/feedback/feedbackbot/tests/test_view.py new file mode 100644 index 000000000..18e41bb0b --- /dev/null +++ b/apptools/feedback/feedbackbot/tests/test_view.py @@ -0,0 +1,170 @@ +""" +Tests for FeedbackController. +""" + +import unittest +from unittest.mock import patch, MagicMock, create_autospec + +import numpy as np +from aiohttp import ClientConnectionError, ServerTimeoutError +from slack.errors import SlackApiError + +from traits.testing.unittest_tools import UnittestTools +from traitsui.api import UIInfo + +from apptools.feedback.feedbackbot.model import FeedbackMessage +from apptools.feedback.feedbackbot.view import FeedbackController + + +class TestFeedbackController(unittest.TestCase, UnittestTools): + + def setUp(self): + + self.msg = FeedbackMessage(img_data=np.empty((2, 2, 3)), + channels='#dummy', + token='xoxb-123456') + + self.msg.name = 'dummy_name' + self.msg.organization = 'dummy_org' + self.msg.description = 'dummy_desc' + + def test__send_enabled_empty_name(self): + """ Test send button is disabled if name is empty. """ + + self.msg.name = '' + + controller_no_name = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_name._send_enabled) + + def test__send_enabled_empty_organization(self): + """ Test send button is disabled if organization is empty. """ + + self.msg.organization = '' + + controller_no_organization = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_organization._send_enabled) + + def test__send_enabled_empty_description(self): + """ Test send button is disabled if description is empty. """ + + self.msg.description = '' + + controller_no_description = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_description._send_enabled) + + def _test_error_called_once(self, controller): + """ Helper function to test if error dialog box is opened. """ + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + # Use a mocked version of the function that creates the error + # dialog. + + # Mock the UIInfo object that is passed to the send button. + mock_ui_info = create_autospec(UIInfo()) + + controller._do_send(mock_ui_info) + + mock_error.assert_called_once() + + def test_error_dialog_opened_if_slack_ratelimited_exception_occurs(self): + """ Test that an error dialog box is opened if a SlackApiError occurs + as a result of being rate-limited. + + """ + + # Patch the slack.web.slack_response.SlackResponse object. If + # a rate-limited error occurs, Slack promises it will return + # a retry-after header in the respones, so add that to the mock + # instance as well. + dummy_response = MagicMock( + data=patch.dict({'ok': False, 'error': 'ratelimited'}), + headers={'retry-after': 10}) + + # Mock the send method of the controller's model. + self.msg.__dict__['send'] = MagicMock( + side_effect=SlackApiError('dummy_err_msg', dummy_response)) + + controller = FeedbackController(model=self.msg) + + self._test_error_called_once(controller) + + def test_error_dialog_opened_if_slack_exception_occurs(self): + """ Test that an error dialog box is opened if a SlackApiError occurs, + but not as a result of being rate-limited. + + """ + + dummy_response = MagicMock( + data=patch.dict({'ok': False, 'error': 'dummy_error'})) + + self.msg.__dict__['send'] = MagicMock( + side_effect=SlackApiError('dummy_err_msg', dummy_response)) + + controller = FeedbackController(model=self.msg) + + self._test_error_called_once(controller) + + def test_error_dialog_opened_if_client_connection_exception_occurs(self): + """ Test that an error dialog box is opened if a ClientConnectionError + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock( + side_effect=ClientConnectionError) + + controller = FeedbackController(model=self.msg) + + self._test_error_called_once(controller) + + def test_error_dialog_opened_if_server_timeout_exception_occurs(self): + """ Test that an error dialog box is opened if a ServerTimeoutError + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock( + side_effect=ServerTimeoutError) + + controller = FeedbackController(model=self.msg) + + self._test_error_called_once(controller) + + def test_error_dialog_opened_if_other_exception_occurs(self): + """ Test that an error dialog box is opened if an Exception + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock(side_effect=Exception) + + controller = FeedbackController(model=self.msg) + + self._test_error_called_once(controller) + + def test_information_dialog_opened_if_no_exception_occurs(self): + """ Test that an information dialog box is opened + if no Exception occurs. + + """ + + self.msg.__dict__['send'] = MagicMock() + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.information') \ + as mock_info: + + mock_ui_info = create_autospec(UIInfo()) + + controller._do_send(mock_ui_info) + + mock_info.assert_called_once() + + +if __name__ == '__main__': + + unittest.main() diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py new file mode 100644 index 000000000..44aa67d47 --- /dev/null +++ b/apptools/feedback/feedbackbot/utils.py @@ -0,0 +1,116 @@ +""" +This module provides some helper functions for the feedback plugin. These +functions are designed to be used by the user-developer so that the feedback +plugin can be used in their application. +""" + +import logging + +from pyface.qt.QtGui import QPixmap +import numpy as np + +from .model import FeedbackMessage +from .view import FeedbackController + +logger = logging.getLogger(__name__) + + +def take_screenshot_qimage(control): + """ Take screenshot of an active GUI widget. + + Parameters + ---------- + control : `QtGui.QWidget` + GUI widget that will be grabbed. + + Returns: + -------- + qimg : `QtGui.QImage` + The screenshot image. + + """ + + logger.info('Grabbing screenshot of control' + + ' with id <%s> and title <%s>.', + str(control.winId()), control.windowTitle()) + + qpixmap = QPixmap.grabWidget(control).toImage() + + return qpixmap + + +def qimage_to_rgb_array(qimg): + """ Converts a `QImage` instance to a numeric RGB array containing pixel data. + + Parameters + ---------- + qimg : `QtGui.QImage` + Image to convert to array. + + Returns + ------- + img_array : `numpy.ndarray` + A 3D array containing pixel data in RGB format. + + Note + ---- + If an Alpha channel is present in `qimg`, it will be dropped in the + array representation. + + """ + + num_channels = qimg.depth() // 8 + + qbits = qimg.bits() + qbits.setsize(qimg.height() * qimg.width() * num_channels) + + img_bytes = qbits.asstring() + + logger.info('Converting raw screenshot bytes to RGB array.') + + img_array = np.ascontiguousarray( + np.frombuffer(img_bytes, dtype=np.uint8).reshape( + qimg.height(), qimg.width(), -1)[..., 2::-1]) + + return img_array + + +def initiate_feedback_dialog(control, token, channels): + """ Initiate the feedback dialog box. + + This function grabs a screenshot of the active GUI widget + and starts up a feedback dialog box. The dialog box displays a preview of + the screenshot, and allows the client-user to enter their name, + organization, and a message. This message is then sent to + the specified Slack channel. + + Parameters + ---------- + control : `QtGui.QWidget` + GUI widget whose screenshot will be taken. + + token : str + Slack API authentication token. + + channels : list + List of channels where the message will be sent. + + Note + ---- + The authentication `token` must bear the required scopes, + i.e., it must have permissions to send messages to the specified channels. + + """ + + logger.info('Feedback dialog requested on control' + + ' with id <%s> and title <%s>', + str(control.winId()), control.windowTitle()) + + img_data = qimage_to_rgb_array(take_screenshot_qimage(control)) + + msg = FeedbackMessage(img_data=img_data, channels=channels, token=token) + + msg_controller = FeedbackController(model=msg) + + logger.info('Launching feedback dialog box.') + msg_controller.configure_traits(kind='livemodal') diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py new file mode 100644 index 000000000..586193733 --- /dev/null +++ b/apptools/feedback/feedbackbot/view.py @@ -0,0 +1,196 @@ +""" +This module implements UI classes and logic for a plugin that enable +clients to send feedback messages to a developer team's slack channel. +""" + +import logging +import traceback + +import slack +import aiohttp + +from traits.api import Property, Instance +from traitsui.api import ( + View, Group, Item, Action, Label, Controller) +from traitsui.menu import CancelButton +from enable.api import ComponentEditor +from enable.primitives.image import Image as ImageComponent +from pyface.api import information, error + +from .model import FeedbackMessage + +logger = logging.getLogger(__name__) + +# ---------------------------------------------------------------------------- +# TraitsUI Actions +# ---------------------------------------------------------------------------- + +send_button = Action(name='Send', action='_do_send', + enabled_when='controller._send_enabled') + +# ---------------------------------------------------------------------------- +# TraitsUI Views +# ---------------------------------------------------------------------------- + +#: Primary view for the feedback message. +feedback_msg_view = View( + Label('Enter feedback here. All fields are mandatory.'), + Group( + Group( + Item('name'), + Item('organization', + tooltip='Enter the name of your organization.'), + Item('description', + style='custom', + tooltip='Enter feedback.', + height=200, + springy=True)), + Group( + Item('controller.image_component', + editor=ComponentEditor(), + show_label=False)), + orientation='horizontal'), + buttons=[CancelButton, send_button], + width=800, + resizable=True, + title='Feedback Reporter') + + +# ---------------------------------------------------------------------------- +# TraitsUI Handler +# ---------------------------------------------------------------------------- + +class FeedbackController(Controller): + """Controller for FeedbackMessage. + + The Controller allows the client user to specify the feedback and preview + the screenshot. + + """ + + #: The underlying model. + model = Instance(FeedbackMessage) + + #: Enable component to store the screenshot. + image_component = Instance(ImageComponent) + + #: Property that decides whether the state of the message is valid + # for sending. + _send_enabled = Property(depends_on='+msg_meta') + + # Default view for this controller. + trait_view = feedback_msg_view + + def _image_component_default(self): + """ Default image to display, this is simply the screenshot.""" + + return ImageComponent(data=self.model.img_data) + + def _get__send_enabled(self): + """ Logic to check if message is valid for sending. """ + + return self.model.name \ + and self.model.organization and self.model.description + + def _do_send(self, ui_info): + """ Actions to perform when the send button is clicked. """ + + logger.info('Send button clicked in feedback dialog box.') + + # Boolean that specifies whether the client-user can try again or not. + # If False, then the feedback dialog box is automatically closed. + # If True, the feedback dialog is kept alive. A possible use case could + # arise when the request to the Slack API takes too long (in which case + # an aiohttp.ServerTimeoutError is raised). In that case, notify the + # user of the error, but keep the dialog box alive. This way, the data + # that the client-user enters persists, allowing them to try sending it + # again without more typing. The other possible use case is when Slack + # rate-limits the bot. + retry = False + + try: + + self.model.send() + + except slack.errors.SlackApiError as exc: + + if exc.response["error"] == "ratelimited": + # Slack has rate-limited the bot. + # The rate limit for this API call is around 20 requests per + # workspace per minute. It is unlikely that this will happen, + # but no harm in handling it. + + # Slack promises to return a retry-after value in seconds in + # the response headers. + retry_time = exc.response.headers["retry-after"] + + err_msg = "Server received too many requests." \ + + " Please try again after {} seconds.".format(retry_time) + + # Allow the user the opportunity to retry the send operation. + retry = True + + else: + # All other Slack API errors (invalid_auth, invalid_channel, + # etc.) + + err_msg = 'Message sent successfully, but received an error' \ + + ' response from Slack.' + + # Construct the detail message explicitly instead of simply calling + # str(exc), which in some cases can reveal the OAuth token. + detail = 'Slack response: '.format( + ok=exc.response['ok'], err=exc.response['error']) + + error(ui_info.ui.control, err_msg, detail=detail) + + # For the same reason (str(exc) can reveal the OAuth token) + # use logger.error instead of logger.exception + logger.error(err_msg + ' ' + detail) + + except aiohttp.ServerTimeoutError as exc: + + err_msg = 'Server took too long to respond.' \ + + ' Please try again later.' + + error(ui_info.ui.control, err_msg, detail=str(exc)) + + retry = True + + logger.exception(err_msg) + + except aiohttp.ClientConnectionError as exc: + # Handle all client-related connection errors raised by aiohttp + # here. + + err_msg = 'An error occured while connecting to the server.' + + error(ui_info.ui.control, err_msg, detail=str(exc)) + + logger.exception(err_msg) + + except Exception as exc: + # Handle all other exceptions here. + + err_msg = 'Unexpected error: {}'.format(str(exc)) + + detail = ' '.join(traceback.format_tb(exc.__traceback__)) + + error(ui_info.ui.control, err_msg, detail=detail) + + logger.exception(err_msg) + + else: + + success_msg = 'Message sent successfully.' + + information(ui_info.ui.control, success_msg) + + finally: + + # Kill the GUI if the user will not retry. + if not retry: + + ui_info.ui.dispose() + + logger.info('Feedback dialog closed automatically.') diff --git a/examples/feedback/example.py b/examples/feedback/example.py new file mode 100644 index 000000000..3b4925ed7 --- /dev/null +++ b/examples/feedback/example.py @@ -0,0 +1,96 @@ +""" +Feedback Dialog Example +========================= + +This example shows how the feedback dialog can be included in an application. +The client-user interface is described in the +FeedbackExampleApp.client_user_explanation attribute. All other comments are +aimed at the developer interested in including the feedback dialog in their +app (see especially `feedback_example_view` and the `FeedbackExampleHandler` +class). + +""" + +import os + +from traits.api import HasTraits, Str +from traitsui.api import ( + Item, Menu, MenuBar, OKCancelButtons, View, Action, Handler +) + +from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog + + +class FeedbackExampleApp(HasTraits): + """ A simple model to demonstrate the feedback dialog.""" + + #: This attribute explains the client-user interface. + client_user_explanation = Str + + def _client_user_explanation_default(self): + + return """ + This app demonstrates how to use the feedback dialog. + + To begin, click on Feedback/Bugs in the Help menu. This will + automatically take a screenshot of this app, and launch the feedback + dialog box. You should be able to see a preview of the + screenshot in the dialog box. + + Next, enter your details, and a description of the problem. All fields + are mandatory, and you can't send the message till you type something + in each field. When you're done, click on the Send button. The dialog + is pre-configured by our developers to ensure it reaches + the right team. + + The dialog will notify you of successful delivery of the message, or if + any problems occured.""" + + +# View for the example app. The feedbackbot module provides a helper function +# `initiate_feedback_dialog` that launches the feedback dialog box. To include +# the feedback dialog box in the app, simply call this function from an +# appropriate place. In this example, we call it from the Feedback/Bugs menu +# item in the Help menu. +feedback_example_view = View( + Item('client_user_explanation', style='readonly', show_label=False), + menubar=MenuBar( + Menu( + Action(name='Feedback/Bugs', action='initiate_feedback_dialog'), + name='Help'), + ), + buttons=OKCancelButtons, + width=480, + height=320, + title='Example App', + resizable=True, +) + + +class FeedbackExampleHandler(Handler): + """ Simple handler for the FeedbackExampleApp. """ + + def initiate_feedback_dialog(self, ui_info): + """ Initiates the feedback dialog. """ + + # As mentioned earlier, the feedback dialog can be initiated by + # invoking the `initiate_feedback_dialog_` function. The first argument + # to this function is the control object whose screenshot will be + # grabbed. The second argument is the OAuth token for the bot (see + # the feedbackbot README for an explanation). In practice, you (the + # user-developer) will have to decide on an appropriate way to + # pass around the token (again, see the README for a discussion on what + # could go wrong if the token gets leaked.). The third argument is the + # channel where you'd like messages from this app to go. The value for + # this argument must start with '#'. + initiate_feedback_dialog(ui_info.ui.control, + os.environ['FEEDBACKBOT_OAUTH_TOKEN'], + '#general') + + +if __name__ == '__main__': + + app = FeedbackExampleApp() + + app.configure_traits(view=feedback_example_view, + handler=FeedbackExampleHandler())