meetbot.py 7.6 KB

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