Ejemplo n.º 1
0
class ChatbotEngineTestCase(unittest.TestCase):
    def setUp(self):

        self.errorlogger = testhandler.emit = Mock()
        self.ch = ChatbotEngine()
        self.scripts_dir = tempfile.mkdtemp()

        self.py_imports = b"""
from __future__ import unicode_literals
from chatbot_reply import Script, rule
"""
        self.py_encoding = b"# coding=utf-8\n"

    def tearDown(self):
        shutil.rmtree(self.scripts_dir)

    def test_Load_RaisesOSError_OnInvalidDirectory(self):
        self.assertRaises(OSError,
                          self.ch.load_script_directory,
                          os.path.join(self.scripts_dir, "not_there"))

    def test_Load_RaisesNoRulesFoundError_OnNoFiles(self):
        self.assertRaises(NoRulesFoundError,
                          self.ch.load_script_directory,
                          self.scripts_dir)

    def test_Load_RaisesSyntaxError_OnBrokenScript(self):
        self.write_py(b"if True\n\tprint 'syntax error'\n")
        self.assertRaises(SyntaxError,
                          self.ch.load_script_directory,
                          self.scripts_dir)

    def test_Load_RaisesNoRulesFoundError_On_NoRules(self):
        py = self.py_imports + b"""
class TestScript(Script):
    pass
"""
        self.write_py(py)
        self.assertRaises(NoRulesFoundError, self.ch.load_script_directory,
                          self.scripts_dir)

    def test_Load_Warning_On_DuplicateRules(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py, filename="foo.py")
        self.write_py(py, filename="bar.py")
        testhandler.setLevel(logging.WARNING)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertEqual(self.errorlogger.call_count, 1)
        
    def test_Load_RaisesPatternError_On_MalformedPattern(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("(_)")
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaisesCheckMessage(PatternError, "test.TestScript.rule_foo",
                          self.ch.load_script_directory,
                          self.scripts_dir)
        
    def test_Load_Raises_On_UndecoratedMethod(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaisesCheckMessage(TypeError, "test.TestScript.rule_foo",
                          self.ch.load_script_directory,
                          self.scripts_dir)

    def test_LoadClearLoad_WorksWithoutComplaint(self):
        # this creates a file not closed warning on python 3, but
        # I think it's python's bug not mine
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py, filename="loadclearload.py")
        self.ch.load_script_directory(self.scripts_dir)
        self.ch.clear_rules()
        self.ch.load_script_directory(self.scripts_dir)        
        self.assertFalse(self.errorlogger.called)

    def test_Load_Raises_OnMalformedAlternates(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def setup(self):
        self.alternates = "(hello|world)"
    @rule("hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaisesCheckMessage(AttributeError,
                                      "alternates of test.TestScript",
                                      self.ch.load_script_directory,
                                      self.scripts_dir)

    def test_Load_RaisesPatternError_OnBadPatternInAlternates(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def setup(self):
        self.alternates = {"foo": "(hello|world]"}
    @rule("hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaisesCheckMessage(PatternError,
                                      'alternates["foo"] of test.TestScript',
                                      self.ch.load_script_directory,
                                      self.scripts_dir)
        

    def test_Load_RaisesNoRulesFoundError_OnTopicNone(self):
        py = self.py_imports + b"""
class TestScript(Script):
    topic = None
    @rule("hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaises(NoRulesFoundError,
                          self.ch.load_script_directory,
                          self.scripts_dir)

    def test_Load_Raises_WhenRulePassedByteStr(self):
        py = b"""
from chatbot_reply import Script, rule
class TestScript(Script):
    @rule(b"hello")
    def rule_foo(self):
        pass
"""
        self.write_py(py)
        self.assertRaisesCheckMessage(TypeError, "test.Testscript.rule_foo",
                          self.ch.load_script_directory,
                          self.scripts_dir)
        

    def test_Reply_Passes_MatchedText(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("the number is _# and the word is _@")
    def rule_and_word(self):
        return "results: {match0} {match1}"
    @rule("*", previous_reply="results _*1 _*1")
    def rule_after_results(self):
        return "again: {reply_match0} {reply_match1}"
    @rule("my name is _@1 _@1")
    def rule_my_name_is(self):
        return "I'll put you down as {raw_match1}, {raw_match0}."
    @rule("play it again sam", previous_reply="_* as _*")
    def rule_play_it_again(self):
        return ("The first part was '{raw_reply_match0}' "
                "and the second part was '{raw_reply_match1}'.")
"""
        conversation = [("local", u"The number is 5 and the word is spam",
                         u"results: 5 spam"),
                        ("local", u"test", u"again: 5 spam"),
                        ("local", u"My name is Fred Flintstone",
                         u"I'll put you down as Flintstone, Fred."),
                        ("local", u"Play it again, Sam!",
                         u"The first part was 'I'll put you down' and the "
                         u"second part was 'Flintstone, Fred.'.")
                        ]

        self.have_conversation(py, conversation)

    def test_Reply_Matches_RuleWithPrevious(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("_*")
    def rule_star(self):
        return "echo {match0}"
    @rule("*", previous_reply="echo [*]")
    def rule_after_results(self):
        return "echo"
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertEqual(self.ch.reply("local", {}, u"who"), u"echo who")
        for i in range(100):
            self.assertEqual(self.ch.reply("local", {}, u"who"), u"echo")
        self.assertFalse(self.errorlogger.called)

    def test_Reply_Raises_WithBadRuleReturnString(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("do you like _*")
    def rule_foo(self):
        return "Yes, I love {1}."
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertRaisesCheckMessage(IndexError, "test.TestScript.rule_foo",
                                      self.ch.reply,"local", {}, u"do you like spam")

    def test_Reply_Raises_WithBadSubstitutionsReturnValue(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def substitute(self, text, things):
        things.append(text)
        return things
    @rule("*")
    def rule_star(self):
        return "anything"
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertRaisesCheckMessage(TypeError, "test.TestScript.rule_foo",
                                      self.ch.reply,"local", {}, u"do you like spam")
        
        

    def test_Reply_Matches_RuleWithAlternate(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def __init__(self):
        self.alternates = {"numbers" : "(1|3|5|7|9)"}
    @rule("the number is %a:numbers")
    def rule_number(self):
        return "pass"
    @rule("*")
    def rule_star(self):
        return "fail"
"""
        self.have_conversation(py, [("local", u"The number is 5", "pass")])
        
    def test_Reply_Matches_RuleWithVariableExpansion(self):
        py = self.py_imports + b"""
class TestScript(Script):
    def setup(self):
        self.botvars["numbers"] = "(1|3|5|7|9)"
        self.alternates = {"colors": "(red|green|blue)"}
    def setup_user(self, user):
        self.uservars["letters"] = "(x|y|z)"
    @rule("the number is %b:numbers")
    def rule_number(self):
        return "pass1"
    @rule("the letter is %u:letters")
    def rule_letter(self):
        return "pass2"
    @rule("the mistake is %b:undefined")
    def rule_mistake(self):
        return "fail"
    @rule("i need %b:numbers %a:colors %u:letters")
    def rule_need(self):
        return "pass3"
    @rule("say it")
    def rule_say_it(self):
        return "number 5 color blue letter x"
    @rule("check it", 
          previous_reply="number %b:numbers color %a:colors letter %u:letters")
    def rule_check_it(self):
        return "pass4"
    @rule("*")
    def rule_star(self):
        return "star"
"""
        conversation = [("local", u"The number is 9", u"pass1"),
                        ("local", u"The letter is x", u"pass2"),
                        ("local", u"The mistake is", u"star"),
                        ("local", u"I need 1 green z", u"pass3"),
                        ("local", u"Say it", u"number 5 color blue letter x"),
                        ("local", u"Check it", u"pass4"),
                        ("local", u"Check it", u"star")]
        
        self.have_conversation(py, conversation)
        
    def test_Reply_RecursivelyExpandsRuleReplies(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("count")
    def rule_foo(self):
        return "one <count2> <count3>"
    @rule("count2")
    def rule_two(self):
        return "two"
    @rule("count3")
    def rule_three(self):
        return "three"    
"""
        self.have_conversation(py, [("local", u"count", u"one two three")])
        
    def test_Reply_Raises_OnRuntimeErrorInRule(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("*")
    def rule_foo(self):
        x = y
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertRaises(NameError, self.ch.reply, "local", {}, u"test")
        
    def test_Reply_Chooses_HigherScoringRule(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("hello *")
    def rule_test1(self):
        return "fail"
    @rule("* world")
    def rule_test2(self):
        return "fail"
    @rule("* *")
    def rule_test3(self):
        return "fail"
    @rule("*")
    def rule_test4(self):
        return "fail"
    @rule("*~2")
    def rule_test5(self):
        return "fail"
    @rule("_* hello")
    def rule_test6(self):
        return "fail"    
    @rule("hello world")
    def rule_test7(self):
        return "pass" 
    @rule("*2")
    def rule_test8(self):
        return "fail"
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        # this would have a 1 in 8 chance of working anyway due to the
        # lack of hash order, but I tried commenting out sorted() and it
        # triggered the assert.
        # could programatically write 10000 methods with wildcards...
        self.assertEqual(self.ch.reply("local", {}, u"hello world"), "pass")
        self.assertFalse(self.errorlogger.called)

    def test_Reply_Error_OnInfiniteRecursion(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("one")
    def rule_one(self):
        return "<two>"
    @rule("two")
    def rule_two(self):
        return "<one>"
"""
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        self.assertRaisesCheckMessage(RecursionTooDeepError, u"one",
                                      self.ch.reply, "local", {}, u"one")

    def test_Reply_RespondsCorrectly_ToTwoUsers(self):
        py = self.py_imports + b"""
class TestScript(Script):
    @rule("my name is _@~3")
    def rule_name(self):
        self.uservars["name"] = self.match["raw_match0"]
        return "Nice to meet you!"
    @rule("what did you just say", previous_reply="_*")
    def rule_what(self):
        return 'I just said "{raw_reply_match0}"'
    @rule("what is my name")
    def rule_what_name(self):
        if "name" in self.uservars:
            return "Your name is {0}.".format(self.uservars["name"])
        else:
            return "You haven't told me."
"""
        conversation = [
            (u"one", u"My name is Test One", u"Nice to meet you!"),
            (u"two", u"What is my name?", u"You haven't told me."),
            (u"one", u"What did you just say?",
                     u'I just said "Nice to meet you!"'),
            (u"two", u"My name is Test Two", u"Nice to meet you!"),
            (u"one", u"What is my name?", u"Your name is Test One.")]
        self.have_conversation(py, conversation)

    def test_Reply_LimitsRuleSelectionByTopic(self):
        py = self.py_imports + b"""
class TestScriptMain(Script):
    @rule("change topic")
    def rule_change_topic(self):
        self.current_topic = "test"
        return "changed to test"
    @rule("topic")
    def rule_topic(self):
        return "all star"

class TestScriptTest(Script):
    topic = "test"
    @rule("change topic")
    def rule_change_topic(self):
        self.current_topic = "all"
        return "changed to all"
    @rule("topic")
    def rule_topic(self):
        return "test star"
"""
        conversation = [(0, u"topic", u"all star"),
                        (0, u"change topic", u"changed to test"),
                        (0, u"topic", u"test star"),
                        (0, u"change topic", u"changed to all"),
                        (0, u"topic", u"all star")]
        self.have_conversation(py, conversation)

    def test_Reply_CallsSubstitutionsByTopic(self):
        py = self.py_imports + b"""
class TestScriptMain(Script):
    def substitute(self, text, wordlists):
        sub = {"3":"three"}
        return [[sub.get(w, w) for w in wl] for wl in wordlists]
    @rule("change topic")
    def rule_change_topic(self):
        self.current_topic = "test"
        return "changed to test"
    @rule("1 2 three")
    def rule_topic(self):
        return "pass all"

class TestScriptTest(Script):
    topic = "test"
    def substitute(self, text, wordlists):
        sub = {"1":"one"}
        return [[sub.get(w, w) for w in wl] for wl in wordlists]
    @rule("change topic")
    def rule_change_topic(self):
        self.current_topic = "all"
        return "changed to all"
    @rule("one 2 3")
    def rule_topic(self):
        return "pass test"
"""
        conversation = [(100, u"1 2 3", u"pass all"),
                        (100, u"change topic", u"changed to test"),
                        (100, u"1 2 3", u"pass test"),
                        (100, u"change topic", u"changed to all"),
                        (100, u"1 2 3", u"pass all")]
        self.have_conversation(py, conversation)

    def assertRaisesCheckMessage(self, expected_error, expected_message,
                                 func, *args, **kwargs):
        """ assert that an error is raised, and that something useful is in 
        e.args[0]
        """
        try:
            func(*args, **kwargs)
        except expected_error as e:
            self.assertNotEqual(e.args[0].find(expected_message), "")

    def have_conversation(self, py, conversation):
        self.write_py(py)
        self.ch.load_script_directory(self.scripts_dir)
        for user, msg, rep in conversation:
            self.assertEqual(self.ch.reply(user, {}, msg), rep)
        self.assertFalse(self.errorlogger.called)
        
    def write_py(self, py, filename="test.py"):
        filename = os.path.join(self.scripts_dir, filename)
        with open(filename, "wb") as f:
            f.write(py + b"\n")
Ejemplo n.º 2
0
class Plugin(indigo.PluginBase):
    """Chatbot plugin class for IndigoServer"""

    # ----- plugin framework ----- #

    def __init__(self, plugin_id, display_name, version, prefs):
        indigo.PluginBase.__init__(self, plugin_id, display_name,
                                   version, prefs)
        self.debug = prefs.get("showDebugInfo", False)
        self.debug_engine = prefs.get("showEngineDebugInfo", False)
        self.configure_logging()

        if (StrictVersion(prefs.get("configVersion", "0.0")) <
                StrictVersion(version)):
            log.debug("Updating config version to " + version)
            prefs["configVersion"] = version
        self.device_info = {}

    def startup(self):
        log.debug("Startup called")
        self.bot = ChatbotEngine()

        scripts_directory = self.pluginPrefs.get("scriptsPath", "")
        if scripts_directory:
            self.load_scripts(scripts_directory)
        else:
            log.debug("Chatbot plugin is not configured.")

    def shutdown(self):
        log.debug("Shutdown called")
        pass

    def update(self):
        pass

    def runConcurrentThread(self):
        try:
            while True:
                self.update()
                self.sleep(3600)  # seconds
        except self.StopThread:
            pass

    # ----- Logging Configuration ----- #

    def configure_logging(self):
        """ Set up the logging for this module and chatbot_reply. """
        self.configure_logger(log)
        self.configure_logger(logging.getLogger("chatbot_reply"),
                              prefix="Engine")
        self.configure_logger(logging.getLogger("termapp_server"),
                              prefix="Console")
        self.set_chatbot_logging()

    def configure_logger(self, logger, level=logging.DEBUG, prefix="",
                         propagate=False):
        """ Create a Handler subclass for the logging module that uses the
        logging methods supplied to the plugin by Indigo. Use it for both
        the plugin and the chatbot_reply module, and add a little formatting
        to chatbot_reply's logger to distinguish the two in the log.
        """
        def make_handler(debugLog, errorLog, prefix=""):
            class NewHandler(logging.Handler):
                def emit(self, record):
                    try:
                        msg = self.format(record)
                        if record.levelno < logging.WARNING:
                            debugLog(msg)
                        else:
                            errorLog(msg)
                    except Exception:
                        self.handleError(record)
            handler = NewHandler()
            if prefix:
                prefix = "[" + prefix + "]"
            handler.setFormatter(logging.Formatter(prefix + "%(message)s"))
            return handler

        logger.addHandler(make_handler(self.debugLog, self.errorLog,
                                       prefix))
        logger.setLevel(level)
        if propagate is not None:
            logger.propagate = propagate

    def set_chatbot_logging(self):
        """ Set the logging level for the chatbot_reply module's logger """
        chatbot_logger = logging.getLogger("chatbot_reply")
        if self.debug_engine:
            chatbot_logger.setLevel(logging.DEBUG)
        else:
            chatbot_logger.setLevel(logging.WARNING)

    # ----- Preferences UI ----- #

    def validatePrefsConfigUi(self, values):
        """ called by the Indigo UI to validate the values dictionary for
        the Plugin Preferences user interface dialog
        """
        errors = indigo.Dict()
        log.debug("Preferences Validation called")
        debug = values.get("showDebugInfo", False)
        if self.debug:
            if not debug:
                log.debug("Turning off debug logging")
        self.debug = debug
        log.debug("Debug logging is on")  # won't print if not self.debug

        self.debug_engine = values.get("showEngineDebugInfo", False)
        self.set_chatbot_logging()

        scripts_directory = values.get("scriptsPath", "")
        if not scripts_directory:
            errors["scriptsPath"] = "Directory of script files is required."
        elif scripts_directory != self.pluginPrefs.get("scriptsPath", ""):
            self.load_scripts(scripts_directory, errors, "scriptsPath")
        if errors:
            return (False, values, errors)
        else:
            return(True, values)

    def load_scripts(self, scripts_directory, errors=None, key=None):
        """ Call the chatbot engine to load scripts, catch all exceptions
        and send them to the error log or error dictionary if provided.
        """
        message = ""
        try:
            self.bot.clear_rules()
            self.bot.load_script_directory(scripts_directory)
        except OSError as e:
            log.error("", exc_info=True)
            message = ("Unable to read script files from directory "
                       "'{0}'".format(scripts_directory))
        except NoRulesFoundError as e:
            message = unicode(e)
        except Exception:
            log.error("", exc_info=True)
            message = "Error reading script files"

        if message:
            log.error(message)
        if errors is not None:
            if message:
                errors[key] = message

    # ----- Action Configuration UI ----- #

    def validateActionConfigUi(self, values, type_id, device_id):
        """ called by the Indigo UI to validate the values dictionary
        for the Action user interface dialog
        """
        log.debug("Action Validation called for %s" % type_id)
        errors = indigo.Dict()

        if StrictVersion(values["actionVersion"]) < StrictVersion(_VERSION):
            values["actionVersion"] = _VERSION

        if not values["message"]:
            errors["message"] = "Can't reply to an empty message."

        if not values["name"]:
            errors["name"] = "Name of the message sender is required."

        fields = ["message", "name"]
        fields.extend(_SENDER_INFO_FIELDS)
        self.validate_substitution(values, errors, fields)

        if errors:
            return (False, values, errors)
        else:
            return (True, values)

    def validate_substitution(self, values, errors, keys):
        """ Run the variable and device substitution syntax check on
        values[key]. If an error is returned, put that in errors[key].
        """
        for key in keys:
            tup = self.substitute(values[key], validateOnly=True)
            valid = tup[0]
            if not valid:
                errors[key] = tup[1]

    # ----- Menu Items ----- #

    def toggleDebugging(self):
        """ Called by the Indigo UI for the Toggle Debugging menu item. """
        if self.debug:
            log.debug("Turning off debug logging")
        self.debug = not self.debug
        log.debug("Turning on debug logging")  # won't print if !self.debug
        self.pluginPrefs["showDebugInfo"] = self.debug

    def toggleEngineDebugging(self):
        """ Called by the Indigo UI for the Toggle Chatbot Engine
        Debugging menu item.
        """
        if self.debug_engine:
            log.debug("Turning off chatbot engine debug logging")
        else:
            log.debug("Turning on chatbot engine debug logging")
        self.debug_engine = not self.debug_engine
        self.pluginPrefs["showEngineDebugInfo"] = self.debug_engine
        self.set_chatbot_logging()

    def reloadScripts(self):
        """ Called by the Indigo UI for the Reload Script Files menu item. """
        scripts_directory = self.pluginPrefs.get("scriptsPath", "")
        if scripts_directory:
            self.load_scripts(scripts_directory)
        else:
            log.error("Can't load script files because the scripts "
                      "directory has not been set. See the Chatbot "
                      "Configure dialog.")

    def startInteractiveInterpreter(self):
        """ Called by the Indigo UI for the Start Interactive Interpreter
        menu item.
        """
        log.debug("startInteractiveInterpreter called")
        namespace = globals().copy()
        namespace.update(locals())
        start_shell_thread(namespace, "", "Chatbot Plugin")

    def startInteractiveChat(self):
        """ Called by the Indigo UI for the Start Interactive Chat
        menu item.
        """
        log.debug("startInteractiveChat called")
        start_interaction_thread(Chatter(self.bot).push,
                                 Chatter.helpmessage, "Chat")

    # ----- Device Start and Stop methods  ----- #

    def deviceStartComm(self, device):
        """Called by Indigo Server to tell a device to start working.
        Initialize the device's message backlog to empty.

        """
        props = device.pluginProps
        if "deviceVersion" not in props:
            props["deviceVersion"] = _VERSION
            device.replacePluginPropsOnServer(props)

        log.debug("Starting device {0}".format(device.id))
        self.device_info[device.id] = []
        self.clear_device_state(device)

    def clear_device_state(self, device):
        device.updateStateOnServer("message", "")
        device.updateStateOnServer("response", "")
        device.updateStateOnServer("name", "")
        for k in _SENDER_INFO_FIELDS:
            device.updateStateOnServer(k, "")
        device.updateStateOnServer("status", "Idle")

    def deviceStopComm(self, device):
        """ Called by Indigo Server to tell us it's done with a device.
        """
        log.debug("Stopping device: {0}".format(device.id))
        self.device_info.pop(device.id, None)

    # ----- Action Callbacks ----- #

    def getChatbotResponse(self, action):
        """ Called by the Indigo Server to implement the respondToMessage
        action defined in Actions.xml.

        If the device is busy, which means there is an uncleared response,
        put the message and sender info in the backlog.

        Otherwise, run the chat script and find a response to a message and
        set the device state to Ready with all sender info.

        The following should be defined in action.props:
        message: the message to respond to
        name: name of the sender
        info1, info2, info3: some information about the
            sender that the user wants to save

        Indigo's device and variable substitution will be used on the
        message before sending it to the chatbot reply engine, and on the
        sender_info values before saving them.
        """
        message = self.substitute(action.props.get("message", ""),
                                  validateOnly=False)
        name = self.substitute(action.props.get("name", ""),
                               validateOnly=False)
        if not message:
            log.error("Can't respond to an empty message")
            return
        if ("actionVersion" in action.props and
                StrictVersion(action.props["actionVersion"]) <
                StrictVersion("0.2.0")):
            log.error("Action was configured by a previous version of this "
                      "plugin. Please check and save the Action Settings.")
            return

        sender_info = dict([(k, self.substitute(action.props.get(k, "")))
                            for k in _SENDER_INFO_FIELDS])
        sender_info["device_id"] = action.deviceId
        sender_info["name"] = name

        device = indigo.devices[action.deviceId]
        if device.states["status"] == "Idle":
            self.device_respond(device, message, sender_info)
        else:
            self.device_info[action.deviceId].append((message, sender_info))

    def device_respond(self, device, message, sender_info):
        """ Ask the chatbot for a response to a message. If it returns a
        non-empty reply, set the device to Ready with the response and
        sender info. If it returns an empty reply, clear the device state.
        If it raises an error, also clear the device and clear the device's
        backlog too, and then re-raise.
        """
        device.updateStateOnServer("message", message)
        device.updateStateOnServer("response", "")
        device.updateStateOnServer("name", sender_info["name"])
        for k in _SENDER_INFO_FIELDS:
            device.updateStateOnServer(k, sender_info[k])
        device.updateStateOnServer("status", "Processing")

        try:
            log.debug("Processing message: '{0}' From user {1}".format(
                message, sender_info["name"]))
            reply = self.bot.reply(sender_info["name"], sender_info, message)
            log.debug("Chatbot response: '{0}'".format(reply))
        except Exception:
            # kill any backlog, so as not to add to the confusion
            del self.device_info[device.id][:]
            self.clear_device_state(device)
            raise

        if reply:
            device.updateStateOnServer("response", reply)
            device.updateStateOnServer("status", "Ready")
        else:
            self.clear_device_state(device)

    def clearResponse(self, action):
        """Called by Indigo Server to implement clearResponse action in
        Actions.xml. Only uses action.deviceId, not props. If there are
        waiting messages to process, get responses for them from the chatbot.
        Empty replies are ignored.

        """
        device = indigo.devices[action.deviceId]

        status = device.states["status"]
        if status != "Idle":
            device.updateStateOnServer("status", "Idle")

        backlog = self.device_info.get(action.deviceId, None)
        while backlog:
            message, sender_info = backlog.pop(0)
            self.device_respond(device, message, sender_info)
            device.refreshFromServer()
            if device.states["status"] == "Ready":
                return
        self.clear_device_state(device)
Ejemplo n.º 3
0
class Plugin(indigo.PluginBase):
    """Chatbot plugin class for IndigoServer"""

    # ----- plugin framework ----- #

    def __init__(self, plugin_id, display_name, version, prefs):
        indigo.PluginBase.__init__(self, plugin_id, display_name, version,
                                   prefs)
        self.debug = prefs.get("showDebugInfo", False)
        self.debug_engine = prefs.get("showEngineDebugInfo", False)
        self.configure_logging()

        if (StrictVersion(prefs.get("configVersion", "0.0")) <
                StrictVersion(version)):
            log.debug("Updating config version to " + version)
            prefs["configVersion"] = version
        self.device_info = {}

    def startup(self):
        log.debug("Startup called")
        self.bot = ChatbotEngine()

        scripts_directory = self.pluginPrefs.get("scriptsPath", "")
        if scripts_directory:
            self.load_scripts(scripts_directory)
        else:
            log.debug("Chatbot plugin is not configured.")

    def shutdown(self):
        log.debug("Shutdown called")
        pass

    def update(self):
        pass

    def runConcurrentThread(self):
        try:
            while True:
                self.update()
                self.sleep(3600)  # seconds
        except self.StopThread:
            pass

    # ----- Logging Configuration ----- #

    def configure_logging(self):
        """ Set up the logging for this module and chatbot_reply. """
        self.configure_logger(log)
        self.configure_logger(logging.getLogger("chatbot_reply"),
                              prefix="Engine")
        self.configure_logger(logging.getLogger("termapp_server"),
                              prefix="Console")
        self.set_chatbot_logging()

    def configure_logger(self,
                         logger,
                         level=logging.DEBUG,
                         prefix="",
                         propagate=False):
        """ Create a Handler subclass for the logging module that uses the
        logging methods supplied to the plugin by Indigo. Use it for both
        the plugin and the chatbot_reply module, and add a little formatting
        to chatbot_reply's logger to distinguish the two in the log.
        """
        def make_handler(debugLog, errorLog, prefix=""):
            class NewHandler(logging.Handler):
                def emit(self, record):
                    try:
                        msg = self.format(record)
                        if record.levelno < logging.WARNING:
                            debugLog(msg)
                        else:
                            errorLog(msg)
                    except Exception:
                        self.handleError(record)

            handler = NewHandler()
            if prefix:
                prefix = "[" + prefix + "]"
            handler.setFormatter(logging.Formatter(prefix + "%(message)s"))
            return handler

        logger.addHandler(make_handler(self.debugLog, self.errorLog, prefix))
        logger.setLevel(level)
        if propagate is not None:
            logger.propagate = propagate

    def set_chatbot_logging(self):
        """ Set the logging level for the chatbot_reply module's logger """
        chatbot_logger = logging.getLogger("chatbot_reply")
        if self.debug_engine:
            chatbot_logger.setLevel(logging.DEBUG)
        else:
            chatbot_logger.setLevel(logging.WARNING)

    # ----- Preferences UI ----- #

    def validatePrefsConfigUi(self, values):
        """ called by the Indigo UI to validate the values dictionary for
        the Plugin Preferences user interface dialog
        """
        errors = indigo.Dict()
        log.debug("Preferences Validation called")
        debug = values.get("showDebugInfo", False)
        if self.debug:
            if not debug:
                log.debug("Turning off debug logging")
        self.debug = debug
        log.debug("Debug logging is on")  # won't print if not self.debug

        self.debug_engine = values.get("showEngineDebugInfo", False)
        self.set_chatbot_logging()

        scripts_directory = values.get("scriptsPath", "")
        if not scripts_directory:
            errors["scriptsPath"] = "Directory of script files is required."
        elif scripts_directory != self.pluginPrefs.get("scriptsPath", ""):
            self.load_scripts(scripts_directory, errors, "scriptsPath")
        if errors:
            return (False, values, errors)
        else:
            return (True, values)

    def load_scripts(self, scripts_directory, errors=None, key=None):
        """ Call the chatbot engine to load scripts, catch all exceptions
        and send them to the error log or error dictionary if provided.
        """
        message = ""
        try:
            self.bot.clear_rules()
            self.bot.load_script_directory(scripts_directory)
        except OSError as e:
            log.error("", exc_info=True)
            message = ("Unable to read script files from directory "
                       "'{0}'".format(scripts_directory))
        except NoRulesFoundError as e:
            message = unicode(e)
        except Exception:
            log.error("", exc_info=True)
            message = "Error reading script files"

        if message:
            log.error(message)
        if errors is not None:
            if message:
                errors[key] = message

    # ----- Action Configuration UI ----- #

    def validateActionConfigUi(self, values, type_id, device_id):
        """ called by the Indigo UI to validate the values dictionary
        for the Action user interface dialog
        """
        log.debug("Action Validation called for %s" % type_id)
        errors = indigo.Dict()

        if StrictVersion(values["actionVersion"]) < StrictVersion(_VERSION):
            values["actionVersion"] = _VERSION

        if not values["message"]:
            errors["message"] = "Can't reply to an empty message."

        if not values["name"]:
            errors["name"] = "Name of the message sender is required."

        fields = ["message", "name"]
        fields.extend(_SENDER_INFO_FIELDS)
        self.validate_substitution(values, errors, fields)

        if errors:
            return (False, values, errors)
        else:
            return (True, values)

    def validate_substitution(self, values, errors, keys):
        """ Run the variable and device substitution syntax check on
        values[key]. If an error is returned, put that in errors[key].
        """
        for key in keys:
            tup = self.substitute(values[key], validateOnly=True)
            valid = tup[0]
            if not valid:
                errors[key] = tup[1]

    # ----- Menu Items ----- #

    def toggleDebugging(self):
        """ Called by the Indigo UI for the Toggle Debugging menu item. """
        if self.debug:
            log.debug("Turning off debug logging")
        self.debug = not self.debug
        log.debug("Turning on debug logging")  # won't print if !self.debug
        self.pluginPrefs["showDebugInfo"] = self.debug

    def toggleEngineDebugging(self):
        """ Called by the Indigo UI for the Toggle Chatbot Engine
        Debugging menu item.
        """
        if self.debug_engine:
            log.debug("Turning off chatbot engine debug logging")
        else:
            log.debug("Turning on chatbot engine debug logging")
        self.debug_engine = not self.debug_engine
        self.pluginPrefs["showEngineDebugInfo"] = self.debug_engine
        self.set_chatbot_logging()

    def reloadScripts(self):
        """ Called by the Indigo UI for the Reload Script Files menu item. """
        scripts_directory = self.pluginPrefs.get("scriptsPath", "")
        if scripts_directory:
            self.load_scripts(scripts_directory)
        else:
            log.error("Can't load script files because the scripts "
                      "directory has not been set. See the Chatbot "
                      "Configure dialog.")

    def startInteractiveInterpreter(self):
        """ Called by the Indigo UI for the Start Interactive Interpreter
        menu item.
        """
        log.debug("startInteractiveInterpreter called")
        namespace = globals().copy()
        namespace.update(locals())
        start_shell_thread(namespace, "", "Chatbot Plugin")

    def startInteractiveChat(self):
        """ Called by the Indigo UI for the Start Interactive Chat
        menu item.
        """
        log.debug("startInteractiveChat called")
        start_interaction_thread(
            Chatter(self.bot).push, Chatter.helpmessage, "Chat")

    # ----- Device Start and Stop methods  ----- #

    def deviceStartComm(self, device):
        """Called by Indigo Server to tell a device to start working.
        Initialize the device's message backlog to empty.

        """
        props = device.pluginProps
        if "deviceVersion" not in props:
            props["deviceVersion"] = _VERSION
            device.replacePluginPropsOnServer(props)

        log.debug("Starting device {0}".format(device.id))
        self.device_info[device.id] = []
        self.clear_device_state(device)

    def clear_device_state(self, device):
        device.updateStateOnServer("message", "")
        device.updateStateOnServer("response", "")
        device.updateStateOnServer("name", "")
        for k in _SENDER_INFO_FIELDS:
            device.updateStateOnServer(k, "")
        device.updateStateOnServer("status", "Idle")

    def deviceStopComm(self, device):
        """ Called by Indigo Server to tell us it's done with a device.
        """
        log.debug("Stopping device: {0}".format(device.id))
        self.device_info.pop(device.id, None)

    # ----- Action Callbacks ----- #

    def getChatbotResponse(self, action):
        """ Called by the Indigo Server to implement the respondToMessage
        action defined in Actions.xml.

        If the device is busy, which means there is an uncleared response,
        put the message and sender info in the backlog.

        Otherwise, run the chat script and find a response to a message and
        set the device state to Ready with all sender info.

        The following should be defined in action.props:
        message: the message to respond to
        name: name of the sender
        info1, info2, info3: some information about the
            sender that the user wants to save

        Indigo's device and variable substitution will be used on the
        message before sending it to the chatbot reply engine, and on the
        sender_info values before saving them.
        """
        message = self.substitute(action.props.get("message", ""),
                                  validateOnly=False)
        name = self.substitute(action.props.get("name", ""),
                               validateOnly=False)
        if not message:
            log.error("Can't respond to an empty message")
            return
        if ("actionVersion" in action.props and StrictVersion(
                action.props["actionVersion"]) < StrictVersion("0.2.0")):
            log.error("Action was configured by a previous version of this "
                      "plugin. Please check and save the Action Settings.")
            return

        sender_info = dict([(k, self.substitute(action.props.get(k, "")))
                            for k in _SENDER_INFO_FIELDS])
        sender_info["device_id"] = action.deviceId
        sender_info["name"] = name

        device = indigo.devices[action.deviceId]
        if device.states["status"] == "Idle":
            self.device_respond(device, message, sender_info)
        else:
            self.device_info[action.deviceId].append((message, sender_info))

    def device_respond(self, device, message, sender_info):
        """ Ask the chatbot for a response to a message. If it returns a
        non-empty reply, set the device to Ready with the response and
        sender info. If it returns an empty reply, clear the device state.
        If it raises an error, also clear the device and clear the device's
        backlog too, and then re-raise.
        """
        device.updateStateOnServer("message", message)
        device.updateStateOnServer("response", "")
        device.updateStateOnServer("name", sender_info["name"])
        for k in _SENDER_INFO_FIELDS:
            device.updateStateOnServer(k, sender_info[k])
        device.updateStateOnServer("status", "Processing")

        try:
            log.debug("Processing message: '{0}' From user {1}".format(
                message, sender_info["name"]))
            reply = self.bot.reply(sender_info["name"], sender_info, message)
            log.debug("Chatbot response: '{0}'".format(reply))
        except Exception:
            # kill any backlog, so as not to add to the confusion
            del self.device_info[device.id][:]
            self.clear_device_state(device)
            raise

        if reply:
            device.updateStateOnServer("response", reply)
            device.updateStateOnServer("status", "Ready")
        else:
            self.clear_device_state(device)

    def clearResponse(self, action):
        """Called by Indigo Server to implement clearResponse action in
        Actions.xml. Only uses action.deviceId, not props. If there are
        waiting messages to process, get responses for them from the chatbot.
        Empty replies are ignored.

        """
        device = indigo.devices[action.deviceId]

        status = device.states["status"]
        if status != "Idle":
            device.updateStateOnServer("status", "Idle")

        backlog = self.device_info.get(action.deviceId, None)
        while backlog:
            message, sender_info = backlog.pop(0)
            self.device_respond(device, message, sender_info)
            device.refreshFromServer()
            if device.states["status"] == "Ready":
                return
        self.clear_device_state(device)
Ejemplo n.º 4
0
    ch = ChatbotEngine()
    ch.load_script_directory("scripts")
    print ("Type /quit to quit, "
           "/botvars or /uservars to see values of variables, "
           "/reload to reload the scripts directory,"
           "/log plus debug, info, warning or error to set logging level.")
    while True:
        msg = text_type(input("You> "))
        if msg == "/quit":
            break
        elif msg == "/botvars":
            print(text_type(ch._botvars))
        elif msg == "/uservars":
            if "local" in ch._users:
                print(text_type(ch._users["local"].vars))
            else:
                print("No user variables have been defined.")
        elif msg == "/reload":
            ch.clear_rules()
            ch.load_script_directory("scripts")
        elif msg == "/log debug":
            log.setLevel(logging.DEBUG)
        elif msg == "/log info":
            log.setLevel(logging.INFO)
        elif msg == "/log warning":
            log.setLevel(logging.WARNING)
        elif msg == "/log error":
            log.setLevel(logging.ERROR)
        else:
            print("Bot> " + ch.reply("local", {}, msg))