bot.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import logging
  2. import time
  3. from contextlib import contextmanager
  4. from .messaging import RollbotResponse, RollbotFailure
  5. from .plugins import as_plugin
  6. def lift_response(call, response):
  7. @as_plugin(call)
  8. def response_func(db, msg):
  9. return RollbotResponse(msg, txt=response)
  10. return response_func
  11. def get_session_manager_factory(session_factory):
  12. @contextmanager
  13. def session_manager_factory():
  14. """Provide a transactional scope around a series of operations."""
  15. session = session_factory()
  16. try:
  17. yield session
  18. session.commit()
  19. except:
  20. # TODO there is some worry that this would rollback things in other threads...
  21. # we should probably find a more correct solution for managing the threaded
  22. # db access, but the risk is fairly low at this point.
  23. session.rollback()
  24. raise
  25. finally:
  26. session.close()
  27. return session_manager_factory
  28. class Rollbot:
  29. def __init__(self, logger=logging.getLogger(__name__), plugin_classes={}, aliases={}, responses={}, sleep_time=0.0, session_factory=None, callback=None):
  30. self.logger = logger
  31. if session_factory is not None:
  32. self.session_manager_factory = get_session_manager_factory(session_factory)
  33. else:
  34. self.session_manager_factory = lambda: None
  35. self.post_callback = callback or (lambda txt, gid: self.logger.info(f"Responding to {gid} with {txt}"))
  36. self.commands = {}
  37. self.to_start = set()
  38. self.to_stop = set()
  39. self.logger.info("Loading command plugins")
  40. for plugin_class in plugin_classes:
  41. plugin_instance = plugin_class(self, logger=logger)
  42. if plugin_instance.command in self.commands:
  43. self.logger.error(f"Duplicate command word '{plugin_instance.command}'")
  44. raise ValueError(f"Duplicate command word '{plugin_instance.command}'")
  45. self.commands[plugin_instance.command] = plugin_instance
  46. if "on_start" in plugin_class.__dict__:
  47. self.to_start.add(plugin_instance)
  48. if "on_shutdown" in plugin_class.__dict__:
  49. self.to_stop.add(plugin_instance)
  50. self.logger.info(f"Finished loading plugins, {len(self.commands)} commands found")
  51. self.logger.info("Loading simple responses")
  52. for cmd, response in responses.items():
  53. if cmd in self.commands:
  54. self.logger.error(f"Duplicate command word '{cmd}'")
  55. raise ValueError(f"Duplicate command word '{cmd}'")
  56. self.commands[cmd] = lift_response(cmd, response)(self, logger=logger)
  57. self.logger.info(f"Finished loading simple responses, {len(self.commands)} total commands available")
  58. self.logger.info("Loading aliases")
  59. for alias, cmd in aliases.items():
  60. if cmd not in self.commands:
  61. self.logger.error(f"Missing aliased command word '{cmd}'")
  62. raise ValueError(f"Missing aliased command word '{cmd}'")
  63. if alias in self.commands:
  64. self.logger.error(f"Duplicate command word '{alias}'")
  65. raise ValueError(f"Duplicate command word '{alias}'")
  66. self.commands[alias] = self.commands[cmd]
  67. self.logger.info(f"Finished loading aliases, {len(self.commands)} total commands + aliases available")
  68. self.sleep_time = sleep_time
  69. def start_plugins(self):
  70. self.logger.info("Starting plugins")
  71. with self.session_manager_factory() as session:
  72. for cmd in self.to_start:
  73. cmd.on_start(session)
  74. self.logger.info("Finished starting plugins")
  75. def shutdown_plugins(self):
  76. self.logger.info("Shutting down plugins")
  77. with self.session_manager_factory() as session:
  78. for cmd in self.to_stop:
  79. cmd.on_shutdown(session)
  80. self.logger.info("Finished shutting down plugins")
  81. def run_command(self, message):
  82. if not message.is_command:
  83. self.logger.warn(f"Tried to run non-command message {message.message_id}")
  84. return RollbotResponse(message, failure=RollbotFailure.INTERNAL_ERROR)
  85. if message.command == "help":
  86. topic = next(message.args())
  87. targeted = self.commands.get(topic, None)
  88. if targeted is None:
  89. return RollbotResponse(message, failure=RollbotFailure.INVALID_ARGUMENTS, debugging={"explain": f"Could not find command {topic}"})
  90. return RollbotResponse(message, txt=targeted.help_msg())
  91. plugin = self.commands.get(message.command, None)
  92. if plugin is None:
  93. self.logger.warn(f"Message {message.message_id} had a command {message.command} that could not be run.")
  94. return RollbotResponse(message, failure=RollbotFailure.INVALID_COMMAND)
  95. with self.session_manager_factory() as session:
  96. response = plugin.on_command(session, message)
  97. if not response.is_success:
  98. self.logger.warn(f"Message {message.message_id} caused failure")
  99. self.logger.warn(response.info)
  100. return response
  101. def handle_command(self, message):
  102. if not message.is_command:
  103. self.logger.debug("Ignoring non-command message")
  104. return
  105. self.logger.info(f"Handling message {message.message_id}")
  106. t = time.time()
  107. try:
  108. response = self.run_command(message)
  109. except Exception as e:
  110. self.logger.exception(f"Exception during command execution for message {message.message_id}")
  111. response = RollbotResponse(message, failure=RollbotFailure.INTERNAL_ERROR)
  112. if not response.respond:
  113. self.logger.info(f"Skipping response to message {message.message_id}")
  114. return
  115. self.logger.info(f"Responding to message {message.message_id}")
  116. sleep = self.sleep_time - time.time() + t
  117. if sleep > 0:
  118. self.logger.info(f"Sleeping for {sleep:.3f}s before responding")
  119. time.sleep(sleep)
  120. if response.is_success:
  121. if response.txt is not None:
  122. self.post_callback(response.txt, message.group_id)
  123. if response.img is not None:
  124. self.post_callback(response.img, message.group_id)
  125. else:
  126. self.post_callback(response.failure_msg, message.group_id)
  127. self.logger.warning(f"Failed command response: {response}")
  128. t = time.time() - t
  129. self.logger.info(f"Exiting command thread for {message.message_id} after {t:.3f}s")
  130. def manually_post_message(self, message_text, group_id):
  131. self.post_callback(message_text, group_id)