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")
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)
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)
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))