meetbot.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. #!/usr/bin/env python3
  2. import asyncio
  3. import logging
  4. import pickle
  5. from base58 import b58decode
  6. from nacl.public import PrivateKey, Box
  7. from meetbot_cfg import config
  8. # Initialized channels from the configuration
  9. CHANS = {}
  10. # Pickle DB
  11. PICKLE_DB = "meetbot.pickle"
  12. # TODO: while this is nice to support, it would perhaps be better to do it
  13. # all over the same connection rather than opening a socket for each channel.
  14. async def channel_listen(host, port, nick, chan):
  15. try:
  16. logging.info("%s: Connecting to %s:%s", chan, host, port)
  17. reader, writer = await asyncio.open_connection(host, port)
  18. logging.debug("%s: Send CAP msg", chan)
  19. msg = "CAP REQ : no-history\r\n"
  20. writer.write(msg.encode("utf-8"))
  21. logging.debug("%s: Send NICK msg", chan)
  22. msg = f"NICK {nick}\r\n"
  23. writer.write(msg.encode("utf-8"))
  24. logging.debug("%s: Send CAP END msg", chan)
  25. msg = "CAP END\r\n"
  26. writer.write(msg.encode("utf-8"))
  27. logging.debug("%s: Send JOIN msg", chan)
  28. msg = f"JOIN {chan}\r\n"
  29. writer.write(msg.encode("utf-8"))
  30. logging.info("%s: Listening to channel", chan)
  31. while True:
  32. msg = await reader.readline()
  33. msg = msg.decode("utf8")
  34. if not msg:
  35. continue
  36. split_msg = msg.split(" ")
  37. command = split_msg[1]
  38. nick_c = split_msg[0][1:].rsplit("!", 1)[0]
  39. logging.debug("%s: Recv: %s", chan, msg.rstrip())
  40. if command == "PRIVMSG":
  41. msg_title = msg.split(" ")[3][1:].rstrip()
  42. if not msg_title:
  43. logging.info("%s: Recv empty PRIVMSG, ignoring", chan)
  44. continue
  45. if msg_title == "!start":
  46. logging.info("%s: Got !start", chan)
  47. topics = CHANS[chan]["topics"]
  48. reply = f"PRIVMSG {chan} :Meeting started"
  49. logging.info("%s: Send: %s", chan, reply)
  50. writer.write((reply + "\r\n").encode("utf-8"))
  51. await writer.drain()
  52. if len(topics) == 0:
  53. reply = f"PRIVMSG {chan} :No topics"
  54. logging.info("%s: Send: %s", chan, reply)
  55. writer.write((reply + "\r\n").encode("utf-8"))
  56. await writer.drain()
  57. continue
  58. reply = f"PRIVMSG {chan} :Topics:"
  59. logging.info("%s: Send: %s", chan, reply)
  60. writer.write((reply + "\r\n").encode("utf-8"))
  61. await writer.drain()
  62. for i, topic in enumerate(topics):
  63. reply = f"PRIVMSG {chan} :{i+1}. {topic}"
  64. logging.info("%s: Send: %s", chan, reply)
  65. writer.write((reply + "\r\n").encode("utf-8"))
  66. await writer.drain()
  67. cur_topic = topics.pop(0)
  68. reply = f"PRIVMSG {chan} :Current topic: {cur_topic}\r\n"
  69. CHANS[chan]["topics"] = topics
  70. writer.write(reply.encode("utf-8"))
  71. await writer.drain()
  72. continue
  73. if msg_title == "!end":
  74. logging.info("%s: Got !end", chan)
  75. reply = f"PRIVMSG {chan} :Meeting ended"
  76. logging.info("%s: Send: %s", chan, reply)
  77. writer.write((reply + "\r\n").encode("utf-8"))
  78. await writer.drain()
  79. continue
  80. if msg_title == "!topic":
  81. logging.info("%s: Got !topic", chan)
  82. topic = msg.split(" ", 4)
  83. if len(topic) != 5:
  84. logging.debug("%s: Topic msg len not 5, skipping",
  85. chan)
  86. continue
  87. topic = topic[4].rstrip() + f" (by {nick_c})"
  88. if topic == "":
  89. logging.debug("%s: Topic message empty, skipping",
  90. chan)
  91. continue
  92. topics = CHANS[chan]["topics"]
  93. if topic not in topics:
  94. topics.append(topic)
  95. CHANS[chan]["topics"] = topics
  96. logging.debug("%s: Appended topic to channel topics",
  97. chan)
  98. reply = f"PRIVMSG {chan} :Added topic: {topic}"
  99. logging.info("%s: Send: %s", chan, reply)
  100. else:
  101. logging.debug("%s: Topic already in list of topics",
  102. chan)
  103. reply = f"PRIVMSG {chan} :Topic already in list"
  104. logging.info("%s: Send: %s", chan, reply)
  105. writer.write((reply + "\r\n").encode("utf-8"))
  106. await writer.drain()
  107. continue
  108. if msg_title == "!list":
  109. logging.info("%s: Got !list", chan)
  110. topics = CHANS[chan]["topics"]
  111. if len(topics) == 0:
  112. reply = f"PRIVMSG {chan} :No topics"
  113. else:
  114. reply = f"PRIVMSG {chan} :Topics:"
  115. logging.info("%s: Send: %s", chan, reply)
  116. writer.write((reply + "\r\n").encode("utf-8"))
  117. await writer.drain()
  118. for i, topic in enumerate(topics):
  119. reply = f"PRIVMSG {chan} :{i+1}. {topic}"
  120. logging.info("%s: Send: %s", chan, reply)
  121. writer.write((reply + "\r\n").encode("utf-8"))
  122. await writer.drain()
  123. continue
  124. if msg_title == "!next":
  125. logging.info("%s: Got !next", chan)
  126. topics = CHANS[chan]["topics"]
  127. if len(topics) == 0:
  128. reply = f"PRIVMSG {chan} :No further topics"
  129. else:
  130. cur_topic = topics.pop(0)
  131. CHANS[chan]["topics"] = topics
  132. reply = f"PRIVMSG {chan} :Current topic: {cur_topic}"
  133. logging.info("%s: Send: %s", chan, reply)
  134. writer.write((reply + "\r\n").encode("utf-8"))
  135. await writer.drain()
  136. continue
  137. except KeyboardInterrupt:
  138. pass
  139. except ConnectionRefusedError:
  140. logging.warning("%s: Connection refused, trying again in 3s...", chan)
  141. await asyncio.sleep(3)
  142. await channel_listen(host, port, nick, chan)
  143. except Exception as e:
  144. logging.error("EXCEPTION: %s", e)
  145. logging.warn("%s: Connection interrupted. Reconnecting in 3s...", chan)
  146. await asyncio.sleep(3)
  147. await channel_listen(host, port, nick, chan)
  148. async def main(debug=False):
  149. global CHANS
  150. loglevel = logging.DEBUG if debug else logging.INFO
  151. logfmt = "%(asctime)s [%(levelname)s]\t%(message)s"
  152. logging.basicConfig(format=logfmt,
  153. level=loglevel,
  154. datefmt="%Y-%m-%d %H:%M:%S")
  155. try:
  156. with open(PICKLE_DB, "rb") as pickle_fd:
  157. CHANS = pickle.load(pickle_fd)
  158. logging.info("Loaded pickle database")
  159. except:
  160. logging.info("Did not find pickle database")
  161. for i in config["channels"]:
  162. name = i["name"]
  163. logging.info("Found config for channel %s", name)
  164. # TODO: This will be useful when ircd has a CAP that tells it to
  165. # give **all** messages to the connected client, no matter if ircd
  166. # itself has a configured secret or not.
  167. # This way the ircd itself doesn't have to keep channel secrets, but
  168. # they can rather only be held by this bot. In turn this means the bot
  169. # can be deployed with any ircd.
  170. if i["secret"]:
  171. logging.info("Instantiating NaCl box for %s", name)
  172. secret = b58decode(i["secret"].encode("utf-8"))
  173. secret = PrivateKey(secret)
  174. public = secret.public_key
  175. box = Box(secret, public)
  176. else:
  177. box = None
  178. if not CHANS.get(name):
  179. CHANS[name] = {}
  180. if not CHANS[name].get("topics"):
  181. CHANS[name]["topics"] = []
  182. CHANS[name]["box"] = box
  183. coroutines = []
  184. for i in CHANS.keys():
  185. logging.debug("Creating async task for %s", i)
  186. task = asyncio.create_task(
  187. channel_listen(config["host"], config["port"], config["nick"], i))
  188. coroutines.append(task)
  189. await asyncio.gather(*coroutines)
  190. if __name__ == "__main__":
  191. from sys import argv
  192. DBG = bool(len(argv) == 2 and argv[1] == "-v")
  193. try:
  194. asyncio.run(main(debug=DBG))
  195. except KeyboardInterrupt:
  196. print("\rCaught ^C, saving pickle and exiting")
  197. with open(PICKLE_DB, "wb") as fdesc:
  198. pickle.dump(CHANS, fdesc, protocol=pickle.HIGHEST_PROTOCOL)