archivebot.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import argparse
  2. import logging
  3. import os
  4. import traceback
  5. from slack_bolt import App
  6. from utils import db_connect, migrate_db
  7. parser = argparse.ArgumentParser()
  8. parser.add_argument(
  9. "-d",
  10. "--database-path",
  11. default="slack.sqlite",
  12. help=("path to the SQLite database. (default = ./slack.sqlite)"),
  13. )
  14. parser.add_argument(
  15. "-l",
  16. "--log-level",
  17. default="debug",
  18. help=("CRITICAL, ERROR, WARNING, INFO or DEBUG (default = DEBUG)"),
  19. )
  20. parser.add_argument(
  21. "-p", "--port", default=3333, help="Port to serve on. (default = 3333)"
  22. )
  23. cmd_args, unknown = parser.parse_known_args()
  24. # Check the environment too
  25. log_level = os.environ.get("ARCHIVE_BOT_LOG_LEVEL", cmd_args.log_level)
  26. database_path = os.environ.get("ARCHIVE_BOT_DATABASE_PATH", cmd_args.database_path)
  27. port = os.environ.get("ARCHIVE_BOT_PORT", cmd_args.port)
  28. # Setup logging
  29. log_level = log_level.upper()
  30. assert log_level in ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
  31. logging.basicConfig(level=getattr(logging, log_level))
  32. logger = logging.getLogger(__name__)
  33. app = App(
  34. token=os.environ.get("SLACK_BOT_TOKEN"),
  35. signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
  36. logger=logger,
  37. )
  38. # Save the bot user's user ID
  39. app._bot_user_id = app.client.auth_test()["user_id"]
  40. # Uses slack API to get most recent user list
  41. # Necessary for User ID correlation
  42. def update_users(conn, cursor):
  43. logger.info("Updating users")
  44. info = app.client.users_list()
  45. args = []
  46. for m in info["members"]:
  47. args.append(
  48. (
  49. m["profile"]["display_name"],
  50. m["id"],
  51. m["profile"].get(
  52. "image_72",
  53. "http://fst.slack-edge.com/66f9/img/avatars/ava_0024-32.png",
  54. ),
  55. )
  56. )
  57. cursor.executemany("INSERT INTO users(name, id, avatar) VALUES(?,?,?)", args)
  58. conn.commit()
  59. def get_channel_info(channel_id):
  60. channel = app.client.conversations_info(channel=channel_id)["channel"]
  61. # Get a list of members for the channel. This will be used when querying private channels.
  62. response = app.client.conversations_members(channel=channel["id"])
  63. members = response["members"]
  64. while response["response_metadata"]["next_cursor"]:
  65. response = app.client.conversations_members(
  66. channel=channel["id"], cursor=response["response_metadata"]["next_cursor"]
  67. )
  68. members += response["members"]
  69. return (
  70. channel["id"],
  71. channel["name"],
  72. channel["is_private"],
  73. [(channel["id"], m) for m in members],
  74. )
  75. def update_channels(conn, cursor):
  76. logger.info("Updating channels")
  77. channels = app.client.conversations_list(types="public_channel,private_channel")[
  78. "channels"
  79. ]
  80. channel_args = []
  81. member_args = []
  82. for channel in channels:
  83. if channel["is_member"]:
  84. channel_id, channel_name, channel_is_private, members = get_channel_info(
  85. channel["id"]
  86. )
  87. channel_args.append((channel_name, channel_id, channel_is_private))
  88. member_args += members
  89. cursor.executemany(
  90. "INSERT INTO channels(name, id, is_private) VALUES(?,?,?)", channel_args
  91. )
  92. cursor.executemany("INSERT INTO members(channel, user) VALUES(?,?)", member_args)
  93. conn.commit()
  94. def handle_query(event, cursor, say):
  95. """
  96. Handles a DM to the bot that is requesting a search of the archives.
  97. Usage:
  98. <query> from:<user> in:<channel> sort:asc|desc limit:<number>
  99. query: The text to search for.
  100. user: If you want to limit the search to one user, the username.
  101. channel: If you want to limit the search to one channel, the channel name.
  102. sort: Either asc if you want to search starting with the oldest messages,
  103. or desc if you want to start from the newest. Default asc.
  104. limit: The number of responses to return. Default 10.
  105. """
  106. try:
  107. text = []
  108. user_name = None
  109. channel_name = None
  110. sort = None
  111. limit = 10
  112. params = event["text"].lower().split()
  113. for p in params:
  114. # Handle emoji
  115. # usual format is " :smiley_face: "
  116. if len(p) > 2 and p[0] == ":" and p[-1] == ":":
  117. text.append(p)
  118. continue
  119. p = p.split(":")
  120. if len(p) == 1:
  121. text.append(p[0])
  122. if len(p) == 2:
  123. if p[0] == "from":
  124. user_name = p[1]
  125. if p[0] == "in":
  126. channel_name = p[1].replace("#", "").strip()
  127. if p[0] == "sort":
  128. if p[1] in ["asc", "desc"]:
  129. sort = p[1]
  130. else:
  131. raise ValueError("Invalid sort order %s" % p[1])
  132. if p[0] == "limit":
  133. try:
  134. limit = int(p[1])
  135. except:
  136. raise ValueError("%s not a valid number" % p[1])
  137. query = f"""
  138. SELECT DISTINCT
  139. messages.message, messages.user, messages.timestamp, messages.channel
  140. FROM messages
  141. INNER JOIN users ON messages.user = users.id
  142. -- Only query channel that archive bot is a part of
  143. INNER JOIN (
  144. SELECT * FROM channels
  145. INNER JOIN members ON
  146. channels.id = members.channel AND
  147. members.user = (?)
  148. ) as channels ON messages.channel = channels.id
  149. INNER JOIN members ON channels.id = members.channel
  150. WHERE
  151. -- Only return messages that are in public channels or the user is a member of
  152. (channels.is_private <> 1 OR members.user = (?)) AND
  153. messages.message LIKE (?)
  154. """
  155. query_args = [app._bot_user_id, event["user"], "%" + " ".join(text) + "%"]
  156. if user_name:
  157. query += " AND users.name = (?)"
  158. query_args.append(user_name)
  159. if channel_name:
  160. query += " AND channels.name = (?)"
  161. query_args.append(channel_name)
  162. if sort:
  163. query += " ORDER BY messages.timestamp %s" % sort
  164. logger.debug(query)
  165. logger.debug(query_args)
  166. cursor.execute(query, query_args)
  167. res = cursor.fetchmany(limit)
  168. res_message = None
  169. if res:
  170. logger.debug(res)
  171. res_message = "\n".join(
  172. [
  173. "*<@%s>* _<!date^%s^{date_pretty} {time}|A while ago>_ _<#%s>_\n%s\n\n"
  174. % (i[1], int(float(i[2])), i[3], i[0])
  175. for i in res
  176. ]
  177. )
  178. if res_message:
  179. say(res_message)
  180. else:
  181. say("No results found")
  182. except ValueError as e:
  183. logger.error(traceback.format_exc())
  184. say(str(e))
  185. @app.event("member_joined_channel")
  186. def handle_join(event):
  187. conn, cursor = db_connect(database_path)
  188. # If the user added is archive bot, then add the channel too
  189. if event["user"] == app._bot_user_id:
  190. channel_id, channel_name, channel_is_private, members = get_channel_info(
  191. event["channel"]
  192. )
  193. cursor.execute(
  194. "INSERT INTO channels(name, id, is_private) VALUES(?,?,?)",
  195. (channel_id, channel_name, channel_is_private),
  196. )
  197. cursor.executemany("INSERT INTO members(channel, user) VALUES(?,?)", members)
  198. else:
  199. cursor.execute(
  200. "INSERT INTO members(channel, user) VALUES(?,?)",
  201. (event["channel"], event["user"]),
  202. )
  203. conn.commit()
  204. @app.event("member_left_channel")
  205. def handle_left(event):
  206. conn, cursor = db_connect(database_path)
  207. cursor.execute(
  208. "DELETE FROM members WHERE channel = ? AND user = ?",
  209. (event["channel"], event["user"]),
  210. )
  211. conn.commit()
  212. def handle_rename(event):
  213. channel = event["channel"]
  214. conn, cursor = db_connect(database_path)
  215. cursor.execute(
  216. "UPDATE channels SET name = ? WHERE id = ?", (channel["name"], channel["id"])
  217. )
  218. conn.commit()
  219. @app.event("channel_rename")
  220. def handle_channel_rename(event):
  221. handle_rename(event)
  222. @app.event("group_rename")
  223. def handle_group_rename(event):
  224. handle_rename(event)
  225. # For some reason slack fires off both *_rename and *_name events, so create handlers for them
  226. # but don't do anything in the *_name events.
  227. @app.event({"type": "message", "subtype": "group_name"})
  228. def handle_group_name():
  229. pass
  230. @app.event({"type": "message", "subtype": "channel_name"})
  231. def handle_channel_name():
  232. pass
  233. @app.event("user_change")
  234. def handle_user_change(event):
  235. user_id = event["user"]["id"]
  236. new_username = event["user"]["profile"]["display_name"]
  237. conn, cursor = db_connect(database_path)
  238. cursor.execute("UPDATE users SET name = ? WHERE id = ?", (new_username, user_id))
  239. conn.commit()
  240. def handle_message(message, say):
  241. logger.debug(message)
  242. if "text" not in message or message["user"] == "USLACKBOT":
  243. return
  244. conn, cursor = db_connect(database_path)
  245. # If it's a DM, treat it as a search query
  246. if message["channel_type"] == "im":
  247. handle_query(message, cursor, say)
  248. elif "user" not in message:
  249. logger.warning("No valid user. Previous event not saved")
  250. else: # Otherwise save the message to the archive.
  251. cursor.execute(
  252. "INSERT INTO messages VALUES(?, ?, ?, ?)",
  253. (message["text"], message["user"], message["channel"], message["ts"]),
  254. )
  255. conn.commit()
  256. # Ensure that the user exists in the DB
  257. cursor.execute("SELECT * FROM users WHERE id = ?", (message["user"],))
  258. row = cursor.fetchone()
  259. if row is None:
  260. update_users(conn, cursor)
  261. logger.debug("--------------------------")
  262. @app.message("")
  263. def handle_message_default(message, say):
  264. handle_message(message, say)
  265. @app.event({"type": "message", "subtype": "thread_broadcast"})
  266. def handle_message_thread_broadcast(event, say):
  267. handle_message(event, say)
  268. @app.event({"type": "message", "subtype": "message_changed"})
  269. def handle_message_changed(event):
  270. message = event["message"]
  271. conn, cursor = db_connect(database_path)
  272. cursor.execute(
  273. "UPDATE messages SET message = ? WHERE user = ? AND channel = ? AND timestamp = ?",
  274. (message["text"], message["user"], event["channel"], message["ts"]),
  275. )
  276. conn.commit()
  277. def init():
  278. # Initialize the DB if it doesn't exist
  279. conn, cursor = db_connect(database_path)
  280. migrate_db(conn, cursor)
  281. # Update the users and channels in the DB and in the local memory mapping
  282. update_users(conn, cursor)
  283. update_channels(conn, cursor)
  284. def main():
  285. init()
  286. # Start the development server
  287. app.start(port=port)
  288. if __name__ == "__main__":
  289. main()