meetbot.py 11 KB

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