From 717e11f653de9814acb609ee467eaa302f1b75bc Mon Sep 17 00:00:00 2001 From: Oklahomer Date: Thu, 3 Sep 2015 23:05:44 +0900 Subject: [PATCH 1/2] Enable rich formatted message for slack --- sarah/bot/base.py | 6 +- sarah/bot/plugins/bmw_quotes.py | 89 +++++++++++++++++--------- sarah/bot/slack.py | 107 ++++++++++++++++++++++++++++++-- sarah/bot/values/__init__.py | 4 ++ sarah/value_object.py | 3 + tests/test_hipchat_plugin.py | 9 +-- tests/test_slack_plugin.py | 6 +- 7 files changed, 175 insertions(+), 49 deletions(-) diff --git a/sarah/bot/base.py b/sarah/bot/base.py index 2a672ec..3c286d4 100644 --- a/sarah/bot/base.py +++ b/sarah/bot/base.py @@ -11,7 +11,7 @@ from apscheduler.schedulers.background import BackgroundScheduler from typing import Sequence, Optional, Callable, Union from sarah.bot.types import PluginConfig, AnyFunction, CommandFunction -from sarah.bot.values import Command, CommandMessage, UserContext +from sarah.bot.values import Command, CommandMessage, UserContext, RichMessage from sarah.thread import ThreadExecutor @@ -122,7 +122,7 @@ def load_plugin(module_name: str) -> None: else: logging.info('Loaded plugin. %s' % module_name) - def respond(self, user_key, user_input) -> Optional[str]: + def respond(self, user_key, user_input) -> Union[RichMessage, str]: user_context = self.user_context_map.get(user_key, None) ret = None @@ -189,7 +189,7 @@ def respond(self, user_key, user_input) -> Optional[str]: return ret.message else: - # String + # String or RichMessage return ret def find_command(self, text: str) -> Optional[Command]: diff --git a/sarah/bot/plugins/bmw_quotes.py b/sarah/bot/plugins/bmw_quotes.py index e0c67a5..1c74d0d 100644 --- a/sarah/bot/plugins/bmw_quotes.py +++ b/sarah/bot/plugins/bmw_quotes.py @@ -5,53 +5,84 @@ from typing import Dict from sarah.bot.values import CommandMessage from sarah.bot.hipchat import HipChat -from sarah.bot.slack import Slack +from sarah.bot.slack import Slack, SlackMessage, MessageAttachment # http://www.imdb.com/title/tt0105958/quotes -quotes = (("Eric: So I said to myself, 'Kyle,'\n" - "Alan: Kyle?\n" - "Eric: That's what I call myself."), - ("Cory: It's hard to imagine you as a boy. \n" - "Did your parents call you Mr. Feeny?"), - ("[Jack and Eric are dressed up as girls to avoid bullies]\n" - "Mr. George Feeny: Hmm, double d's, just like your grades."), - ("Morgan Matthews: Mommy, if my dolly's cold, " - "can I put her in the toaster oven?\n" - "Amy Matthews: No, honey. That would be a mistake.\n" - "Morgan Matthews: Mommy?\n" - "Amy Matthews: Yes?\n" - "Morgan Matthews: I made a mistake."), - ("Amy Matthews: Apparently, Cory would rather listen to the game " - "then try and understand the emotional content of Romeo & Juliet.\n" - "Cory: Mom, I'm a kid. I don't understand the emotional content " - "of Full House.\n" - "Morgan Matthews: I do."), - ("Topanga: Cory, the worst thing that ever happened " - "when we were kids was that your Pop-Tart fell on the ground.\n" - "Cory: Yeah, and *you* convinced me to eat it. You said, " - "\"God made dirt, dirt won't hurt.\"")) +quotes = ([('Eric', "So i said to myself, 'Kyle'"), + ('Alan', "Kyle?"), + ('Eric', "That's what I call myself.")], + [('Cory', "It's hard to imagine you as a boy.\n" + "Did your parents call you Mr. Feeny?")], + ["[Jack and Eric are dressed up as girls to avoid bullies]", + ('Feeny', "Hmm, double d's, just like your grades.")], + [('Morgan', "Mommy, if my dolly's cold, " + "Can I put her in the toaster oven?"), + ('Amy', "No, honey. That would be a mistake."), + ('Morgan', "Mommy?"), + ('Amy', "Yes?"), + ('Morgan', "I made a mistake.")], + [('Amy', "Apparently, Cory would rather listen to the " + "game than try and understand the emotional " + "content of Romeo & Juliet."), + ('Cory', "Mom, I'm a kid. I don't understand the emotional content" + "of Full House."), + ('Morgan', "I do.")], + [('Topanga', "Cory, the worst thing that ever happened when we were " + "kids was that your Pop-Tart fell on the ground."), + ('Cory', "Yeah, and *you* convinced me to eat it. You said, " + "\"God made dirt, dirt won't hurt.\"")]) + + +def _hipchat_message(): + return "\n".join([q if isinstance(q, str) else + "%s: %s" % (q[0], q[1]) for q in random.choice(quotes)]) # noinspection PyUnusedLocal @HipChat.command('.bmw') def hipchat_quote(msg: CommandMessage, config: Dict) -> str: - return random.choice(quotes) + return _hipchat_message() # noinspection PyUnusedLocal @HipChat.schedule('bmw_quotes') def hipchat_scheduled_quote(config: Dict) -> str: - return random.choice(quotes) + return _hipchat_message() + + +def _slack_message(): + quote = random.choice(quotes) + if isinstance(quote[0], str): + title = quote.pop(0) + else: + title = None + + color_map = {'Eric': "danger", + 'Amy': "warning", + 'Alan': "green", + 'Cory': "green", + 'Topanga': "warning", + 'Morgan': "danger", + 'Feeny': "danger"} + return SlackMessage( + text=title, + attachments=[ + MessageAttachment( + fallback="%s : %s" % (q[0], q[1]), + pretext=q[0], + title=q[1], + color=color_map.get(q[0], "green") + ) for q in quote]) # noinspection PyUnusedLocal @Slack.command('.bmw') -def slack_quote(msg: CommandMessage, config: Dict) -> str: - return random.choice(quotes) +def slack_quote(msg: CommandMessage, config: Dict) -> SlackMessage: + return _slack_message() # noinspection PyUnusedLocal @Slack.schedule('bmw_quotes') -def slack_scheduled_quote(config: Dict) -> str: - return random.choice(quotes) +def slack_scheduled_quote(config: Dict) -> SlackMessage: + return _slack_message() diff --git a/sarah/bot/slack.py b/sarah/bot/slack.py index eb499f5..aa5c826 100644 --- a/sarah/bot/slack.py +++ b/sarah/bot/slack.py @@ -7,10 +7,11 @@ from typing import Optional, Dict, Sequence import requests from websocket import WebSocketApp +from sarah import ValueObject from sarah.exceptions import SarahException from sarah.bot import Base, concurrent -from sarah.bot.values import Command +from sarah.bot.values import Command, RichMessage from sarah.bot.types import PluginConfig @@ -59,6 +60,88 @@ def request(self, return json.loads(response.content.decode()) +class AttachmentField(ValueObject): + def __init__(self, title: str, value: str, short: bool=None): + pass + + def to_dict(self): + # Exclude empty fields + params = dict() + for param in self.keys(): + if self[param] is None: + continue + + params[param] = self[param] + + return params + + +# https://api.slack.com/docs/attachments +class MessageAttachment(ValueObject): + def __init__(self, + fallback: str, + title: str, + title_link: str=None, + author_name: str=None, + author_link: str=None, + author_icon: str=None, + fields: Sequence[AttachmentField]=None, + image_url: str=None, + thumb_url: str=None, + pretext: str=None, + color: str=None): + pass + + def to_dict(self): + # Exclude empty fields + params = dict() + for param in self.keys(): + if self[param] is None: + continue + + params[param] = self[param] + + if 'fields' in params: + params['fields'] = [f.to_dict() for f in params['fields']] + + return params + + +class SlackMessage(RichMessage): + def __init__(self, + text: str=None, + as_user: bool=True, + username: str=None, + parse: str="full", + link_names: int=1, + unfurl_links: bool=True, + unfurl_media: bool=False, + icon_url: str=None, + icon_emoji: str=None, + attachments: Sequence[MessageAttachment]=None): + pass + + def to_dict(self): + # Exclude empty fields + params = dict() + for param in self.keys(): + if self[param] is None: + continue + + params[param] = self[param] + + return params + + def to_request_params(self): + params = self.to_dict() + + if 'attachments' in params: + params['attachments'] = json.dumps( + [a.to_dict() for a in params['attachments']]) + + return params + + class Slack(Base): def __init__(self, token: str='', @@ -102,10 +185,17 @@ def add_schedule_job(self, command: Command) -> None: def job_function() -> None: ret = command.execute() - for channel in command.config['channels']: - self.enqueue_sending_message(self.send_message, - channel, - str(ret)) + if isinstance(ret, SlackMessage): + for channel in command.config['channels']: + # TODO Error handling + data = dict({'channel': channel}) + data.update(ret.to_request_params()) + self.client.post('chat.postMessage', data=data) + else: + for channel in command.config['channels']: + self.enqueue_sending_message(self.send_message, + channel, + str(ret)) job_id = '%s.%s' % (command.module_name, command.name) logging.info("Add schedule %s" % id) @@ -189,7 +279,12 @@ def handle_message(self, content: Dict) -> Optional[Future]: return ret = self.respond(content['user'], content['text']) - if ret: + if isinstance(ret, SlackMessage): + # TODO Error handling + data = dict({'channel': content["channel"]}) + data.update(ret.to_request_params()) + self.client.post('chat.postMessage', data=data) + elif isinstance(ret, str): return self.enqueue_sending_message(self.send_message, content['channel'], ret) diff --git a/sarah/bot/values/__init__.py b/sarah/bot/values/__init__.py index a68aa73..38cdb03 100644 --- a/sarah/bot/values/__init__.py +++ b/sarah/bot/values/__init__.py @@ -5,6 +5,10 @@ from sarah.bot.types import CommandFunction, CommandConfig +class RichMessage(ValueObject): + pass + + class InputOption(ValueObject): def __init__(self, pattern: Union[Pattern, AnyStr], diff --git a/sarah/value_object.py b/sarah/value_object.py index d2c96b4..e147ce0 100644 --- a/sarah/value_object.py +++ b/sarah/value_object.py @@ -67,3 +67,6 @@ def __eq__(self, other: Any) -> bool: def __ne__(self, other) -> bool: return not self.__eq__(other) + + def keys(self): + return self.__stash.keys() diff --git a/tests/test_hipchat_plugin.py b/tests/test_hipchat_plugin.py index eb9c473..213d2e1 100644 --- a/tests/test_hipchat_plugin.py +++ b/tests/test_hipchat_plugin.py @@ -7,7 +7,6 @@ from sarah.bot.plugins.simple_counter import hipchat_count, \ hipchat_reset_count, reset_count from sarah.bot.plugins.bmw_quotes import hipchat_quote, hipchat_scheduled_quote -import sarah.bot.plugins.bmw_quotes class TestEcho(object): @@ -87,14 +86,10 @@ def test_valid(self): msg = CommandMessage(original_text='.bmw', text='', sender='123_homer@localhost/Oklahomer') - assert_that(sarah.bot.plugins.bmw_quotes.quotes) \ - .described_as("Returned text is part of stored sample") \ - .contains(hipchat_quote(msg, {})) + assert_that(hipchat_quote(msg, {})).is_type_of(str) def test_schedule(self): - assert_that(sarah.bot.plugins.bmw_quotes.quotes) \ - .described_as("Scheduled message is part of stored sample") \ - .contains(hipchat_scheduled_quote({})) + assert_that(hipchat_scheduled_quote({})).is_type_of(str) class TestHello(object): diff --git a/tests/test_slack_plugin.py b/tests/test_slack_plugin.py index 2a45382..a666ffc 100644 --- a/tests/test_slack_plugin.py +++ b/tests/test_slack_plugin.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- from assertpy import assert_that +from sarah.bot.slack import SlackMessage from sarah.bot.values import CommandMessage from sarah.bot.plugins.bmw_quotes import slack_quote from sarah.bot.plugins.echo import slack_echo from sarah.bot.plugins.simple_counter import reset_count, slack_count, \ slack_reset_count -import sarah.bot.plugins.bmw_quotes class TestEcho(object): @@ -84,6 +84,4 @@ def test_valid(self): msg = CommandMessage(original_text='.bmw', text='', sender='U06TXXXXX') - assert_that(sarah.bot.plugins.bmw_quotes.quotes) \ - .described_as("Returned text is part of stored sample") \ - .contains(slack_quote(msg, {})) + assert_that(slack_quote(msg, {})).is_type_of(SlackMessage) From 3f330387a2a6dd4da8995f65d812b40431410579 Mon Sep 17 00:00:00 2001 From: Oklahomer Date: Fri, 4 Sep 2015 21:51:04 +0900 Subject: [PATCH 2/2] UserContext may have rich formatted message --- sarah/bot/slack.py | 3 +++ sarah/bot/values/__init__.py | 13 ++++++++----- tests/test_slack_plugin.py | 3 ++- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/sarah/bot/slack.py b/sarah/bot/slack.py index aa5c826..616ea7f 100644 --- a/sarah/bot/slack.py +++ b/sarah/bot/slack.py @@ -121,6 +121,9 @@ def __init__(self, attachments: Sequence[MessageAttachment]=None): pass + def __str__(self) -> str: + return self['text'] + def to_dict(self): # Exclude empty fields params = dict() diff --git a/sarah/bot/values/__init__.py b/sarah/bot/values/__init__.py index 38cdb03..4272cf2 100644 --- a/sarah/bot/values/__init__.py +++ b/sarah/bot/values/__init__.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- +import abc import re from typing import Union, Pattern, AnyStr, Callable, Sequence from sarah import ValueObject from sarah.bot.types import CommandFunction, CommandConfig -class RichMessage(ValueObject): - pass +class RichMessage(ValueObject, metaclass=abc.ABCMeta): + @abc.abstractmethod + def __str__(self) -> str: + pass class InputOption(ValueObject): @@ -31,16 +34,16 @@ def match(self, msg: str) -> bool: class UserContext(ValueObject): def __init__(self, - message: str, + message: Union[str, RichMessage], help_message: str, input_options: Sequence[InputOption]) -> None: pass def __str__(self): - return self.message + return str(self.message) @property - def message(self) -> str: + def message(self) -> Union[str, RichMessage]: return self['message'] @property diff --git a/tests/test_slack_plugin.py b/tests/test_slack_plugin.py index a666ffc..2e175ba 100644 --- a/tests/test_slack_plugin.py +++ b/tests/test_slack_plugin.py @@ -84,4 +84,5 @@ def test_valid(self): msg = CommandMessage(original_text='.bmw', text='', sender='U06TXXXXX') - assert_that(slack_quote(msg, {})).is_type_of(SlackMessage) + # assert_that(slack_quote(msg, {})).is_instance_of(SlackMessage) + assert_that(isinstance(slack_quote(msg, {}), SlackMessage)).is_true()