qdb.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. from event import Event
  2. import re
  3. import difflib
  4. try:
  5. import imgurpython
  6. except (ImportError, SystemError):
  7. print("Warning: QDB module requires imgurpython.")
  8. imgurpython = object
  9. try:
  10. import requests
  11. except (ImportError, SystemError):
  12. print("Warning: QDB module requires requests.")
  13. requests = object
  14. class QDB:
  15. def __init__(self, events=None, printer_handle=None, bot=None, say=None):
  16. self.events = events
  17. self.printer = printer_handle
  18. self.interests = ['__.qdb__', '1__all_lines__'] # should be first event in the listing.. so lines being added is a priority
  19. self.bot = bot
  20. self.say = say
  21. try:
  22. from imgur_credentials import ImgurCredentials as ic
  23. except (ImportError, SystemError):
  24. print("Warning: imgur module requires credentials in modules/imgur_credentials.py")
  25. class PhonyIc:
  26. imgur_client_id = "None"
  27. imgur_client_secret = "None"
  28. ic = PhonyIc()
  29. self.imgur_client_id = ic.imgur_client_id
  30. self.imgur_client_secret = ic.imgur_client_secret
  31. # prevent unecessarily clearing our mem_store['qdb'] dict
  32. if not "qdb" in self.bot.mem_store:
  33. self.bot.mem_store['qdb'] = {}
  34. #define a key for _recent since that will not be a potential channel name
  35. self.bot.mem_store['qdb']['_recent'] = []
  36. for event in events:
  37. if event._type in self.interests:
  38. event.subscribe(self)
  39. self.help = ".qdb <search string of first line> | <search string of last line>"
  40. self.MAX_BUFFER_SIZE = 500
  41. self.MAX_HISTORY_SIZE = 10
  42. def _imgurify(self, url):
  43. client = imgurpython.ImgurClient(self.imgur_client_id, self.imgur_client_secret)
  44. replacement_values = list()
  45. if type(url) is list:
  46. for u in url:
  47. resp = client.upload_from_url(u)
  48. replacement_values.append(resp)
  49. else:
  50. try:
  51. resp = client.upload_from_url(url)
  52. replacement_values.append(resp)
  53. except imgurpython.helpers.error.ImgurClientError as e:
  54. self.bot.debug_print("ImgurClientError: ")
  55. self.bot.debug_print(str(e))
  56. except UnboundLocalError as e:
  57. self.bot.debug_print("UnboundLocalError: ")
  58. self.bot.debug_print(str(e))
  59. except requests.ConnectionError as e:
  60. self.bot.debug_print("ConnectionError: ")
  61. self.bot.debug_print(str(e))
  62. return replacement_values
  63. def _detect_url(self, quote):
  64. """
  65. for tsd printouts and imgflip (.meme)
  66. follows this format:
  67. http://irc.teamschoolyd.org/printouts/8xnK5DmfMz
  68. http://i.imgflip.com/zs1e6.jpg
  69. """
  70. tsd_regex = "(?P<url>http://irc\.teamschoolyd\.org/printouts/\w+)"
  71. if_regex = "(?P<url>http://i\.imgflip\.com/\w+.jpg)"
  72. # to allow us to check both variables at the end
  73. tsd_url, if_url = None, None
  74. try:
  75. tsd_url = re.search(tsd_regex, quote).group("url")
  76. except AttributeError:
  77. pass
  78. try:
  79. if_url = re.search(if_regex, quote).group("url")
  80. except AttributeError:
  81. pass
  82. if not tsd_url and not if_url:
  83. return quote
  84. if tsd_url:
  85. url = tsd_url
  86. if if_url:
  87. url = if_url
  88. repl = self._imgurify(url)
  89. if tsd_url:
  90. new_quote = re.sub(tsd_regex, repl[0]['link'], quote)
  91. if if_url:
  92. new_quote = re.sub(if_regex, repl[0]['link'], quote)
  93. return new_quote
  94. def strip_formatting(self, msg):
  95. """Uses regex to replace any special formatting in IRC (bold, colors) with nothing"""
  96. return re.sub('([\x02\x1D\x1F\x16\x0F]|\x03([0-9]{2})?)', '', msg)
  97. def add_buffer(self, event=None, debug=False):
  98. """Takes a channel name and line passed to it and stores them in the bot's mem_store dict
  99. for future access. The dict will have channel as key. The value to that key will be a list
  100. of formatted lines of activity.
  101. If the buffer size is not yet exceeded, lines are just added. If the buffer
  102. is maxed out, the oldest line is removed and newest one inserted at the beginning.
  103. """
  104. if debug:
  105. print("Line: " + event.line)
  106. print("Verb: " + event.verb)
  107. print("Channel: " + event.channel)
  108. print("")
  109. if not event:
  110. return
  111. #there are certain things we want to record in history, like nick changes and quits
  112. #these often add to the humor of a quote. however, these are not specific to a channel
  113. #in IRC and our bot does not maintain a userlist per channel. Therefore, when nick
  114. #changes and quits occur, we will add them to every buffer. This is not technically
  115. #correct behavior and could very well lead to quits/nick changes that are not visible
  116. #showing up in a quote, but it's the best we can do at the moment
  117. if not event.channel:
  118. #discard events with unwanted verbs
  119. if event.verb not in ["QUIT", "NICK"]:
  120. return
  121. try:
  122. for chan in list(self.bot.mem_store['qdb'].keys()):
  123. if chan != '_recent':
  124. if len(self.bot.mem_store['qdb'][chan]) >= self.MAX_BUFFER_SIZE:
  125. self.bot.mem_store['qdb'][chan].pop()
  126. line = self.format_line(event)
  127. if line:
  128. self.bot.mem_store['qdb'][chan].insert(0, line)
  129. except (KeyError, IndexError):
  130. print("QDB add_buffer() error when no event channel")
  131. #now we continue with normal, per channel line addition
  132. #create a dictionary associating the channel with an empty list if it doesn't exist yet
  133. else:
  134. if event.channel not in self.bot.mem_store['qdb']:
  135. self.bot.mem_store['qdb'][event.channel] = []
  136. try:
  137. #check for the length of the buffer. if it's too long, pop the last item
  138. if len(self.bot.mem_store['qdb'][event.channel]) >= self.MAX_BUFFER_SIZE:
  139. self.bot.mem_store['qdb'][event.channel].pop()
  140. #get a line by passing event to format_line
  141. #insert the line into the first position in the list
  142. line = self.format_line(event)
  143. if line:
  144. self.bot.mem_store['qdb'][event.channel].insert(0, line)
  145. except IndexError:
  146. print("QDB add_buffer() error. Couldn't access the list index.")
  147. def format_line(self, event):
  148. """Takes an event and formats a string appropriate for quotation from it"""
  149. # first strip out printout urls and replace them with imgur mirrors
  150. # commenting out for now to avoid uploading to imgur so often
  151. #event.msg = self._detect_url(event.msg)
  152. #format all strings based on the verb
  153. if event.verb == "":
  154. return ''
  155. elif event.verb == "PRIVMSG":
  156. #special formatting for ACTION strings
  157. if event.msg.startswith('\001ACTION'):
  158. #strip out the word ACTION from the msg
  159. return ' * %s %s\n' % (event.user, event.msg[7:])
  160. else:
  161. return '<%s> %s\n' % (event.user, self.strip_formatting(event.msg))
  162. elif event.verb == "JOIN":
  163. return ' --> %s has joined channel %s\n' % (event.user, event.channel)
  164. elif event.verb == "PART":
  165. return ' <-- %s has left channel %s\n' % (event.user, event.channel)
  166. elif event.verb == "NICK":
  167. return ' -- %s has changed their nick to %s\n' % (event.user, event.msg)
  168. elif event.verb == "TOPIC":
  169. return ' -- %s has changed the topic for %s to "%s"\n' % (event.user, event.channel, event.msg)
  170. elif event.verb == "QUIT":
  171. return ' <-- %s has quit (%s)\n' % (event.user, event.msg)
  172. elif event.verb == "KICK":
  173. #this little bit of code finds the kick target by getting the last
  174. #thing before the event message begins
  175. target = event.line.split(":", 2)[1].split()[-1]
  176. return ' <--- %s has kicked %s from %s (%s)\n' % (event.user, target, event.channel, event.msg)
  177. elif event.verb == "NOTICE":
  178. return ' --NOTICE from %s: %s\n' % (event.user, event.msg)
  179. else:
  180. #no matching verbs found. just ignore the line
  181. return ''
  182. def get_qdb_submission(self, channel=None, start_msg='', end_msg='', strict=False):
  183. """Given two strings, start_msg and end_msg, this function will assemble a submission for the QDB.
  184. start_msg is a substring to search for and identify a starting line. end_msg similarly is used
  185. to search for the last desired line in the submission. This function returns a string ready
  186. for submission to the QDB if it finds the desired selection. If not, it returns None.
  187. """
  188. if not channel:
  189. return None
  190. #must have at least one msg to search for and channel to look it up in
  191. if len(start_msg) == 0 or not channel:
  192. return None
  193. #first, check to see if we are doing a single string submission.
  194. if end_msg == '':
  195. for line in self.bot.mem_store['qdb'][channel]:
  196. if start_msg.lower() in line.lower():
  197. return self._detect_url(line) #removing temporary printout urls and replacing with imgur
  198. #making sure we get out of the function if no matching strings were found
  199. #don't want to search for a nonexistent second string later
  200. return None
  201. #search for a matching start and end string and get the buffer index for the start and end message
  202. start_index = -1
  203. end_index = -1
  204. """Finds matching string for beginning line. Buffer is traversed in reverse-chronological order
  205. .qdb -> strict = False -> earliest occurence
  206. .qdbs -> strict = True -> latest occurence
  207. """
  208. for index, line in enumerate(self.bot.mem_store['qdb'][channel]):
  209. #print "evaluating line for beginning: {}".format(line)
  210. if start_msg.encode('utf-8','ignore').lower() in line.encode('utf-8','ignore').lower():
  211. #print "found match, start_index={}".format(index)
  212. start_index = index
  213. if strict:
  214. break
  215. #finds newest matching string for ending line
  216. for index, line in enumerate(self.bot.mem_store['qdb'][channel]):
  217. #print "evaluating line for end: {}".format(line)
  218. if end_msg.lower() in line.lower():
  219. #print "found match, end_index={}".format(index)
  220. end_index = index
  221. break
  222. #check to see if index values are positive. if not, string was not found and we're done
  223. if start_index == -1 or end_index == -1 or start_index < end_index:
  224. return None
  225. #now we generate the string to be returned for submission
  226. submission = ''
  227. try:
  228. for i in reversed(list(range(end_index, start_index + 1))):
  229. #print 'Index number is ' + str(i) + ' and current submission is ' + submission
  230. submission += self._detect_url(self.bot.mem_store['qdb'][channel][i]) #detect temporary printout urls and replace with imgur
  231. except IndexError:
  232. print("QDB get_qdb_submission() error when accessing list index")
  233. return submission
  234. def submit(self, qdb_submission, debug=False):
  235. """Given a string, qdb_submission, this function will upload the string to hlmtre's qdb
  236. server. Returns a string with status of submission. If it worked, includes a link to new quote.
  237. """
  238. if debug:
  239. print("Submission is:")
  240. print(qdb_submission)
  241. print("Current buffer is:")
  242. print(self.bot.mem_store['qdb'])
  243. print("")
  244. return ''
  245. #accessing hlmtre's qdb api
  246. url = 'https://qdb.zero9f9.com/api.php'
  247. payload = {'q':'new', 'quote': qdb_submission.rstrip('\n')}
  248. try:
  249. qdb = requests.post(url, payload)
  250. except ConnectionError as e:
  251. self.bot.debug_print("ConnectionError: ")
  252. self.bot.debug_print(str(e))
  253. #check for any HTTP errors and return False if there were any
  254. try:
  255. qdb.raise_for_status()
  256. except requests.exceptions.HTTPError as e:
  257. self.bot.debug_print('HTTPError: ')
  258. self.bot.debug_print(str(e))
  259. self.bot.debug_print("Perhaps informative:")
  260. self.bot.debug_print(url)
  261. self.bot.debug_print(str(payload))
  262. return "HTTPError encountered when submitting to QDB"
  263. try:
  264. q_url = qdb.json()
  265. self.add_recently_submitted(q_url['id'], qdb_submission)
  266. return "QDB submission successful! https://qdb.zero9f9.com/quote.php?id=" + str(q_url['id'])
  267. except (KeyError, UnicodeDecodeError):
  268. return "Error getting status of quote submission."
  269. return "That was probably successful since no errors came up, but no status available."
  270. def delete(self, user, post_id='', passcode=''):
  271. """A special function that allows certain users to delete posts"""
  272. #accessing hlmtre's qdb api
  273. url = 'http://qdb.zero9f9.com/api.php'
  274. payload = {'q':'delete', 'user':user, 'id':post_id, 'code':passcode}
  275. deletion = requests.get(url, params=payload)
  276. #check for any HTTP errors and return False if there were any
  277. try:
  278. deletion.raise_for_status()
  279. except requests.exceptions.HTTPError as e:
  280. self.bot.debug_print('HTTPError: ')
  281. self.bot.debug_print(str(e))
  282. return "HTTPError encountered when accessing QDB"
  283. try:
  284. del_status = deletion.json()
  285. if del_status['success'] == "true":
  286. for quote in self.bot.mem_store['qdb']['_recent']: # they're a list of dicts
  287. if int(post_id) in quote:
  288. self.bot.mem_store['qdb']['_recent'].remove(quote)
  289. return "QDB deletion succeeded."
  290. return "QDB deletion failed."
  291. except (KeyError, UnicodeDecodeError):
  292. return "Error getting status of quote deletion."
  293. def recently_submitted(self, submission):
  294. """Checks to see if the given submission is string is at least 75% similar to the strings
  295. in the list of recently submitted quotes.
  296. Returns the id of the quote if it was recently submitted. If not, returns -1.
  297. """
  298. #set up a difflib SequenceMatcher with the first string to test
  299. comparer = difflib.SequenceMatcher()
  300. comparer.set_seq1(submission)
  301. #if we find that it has 75% similarity or greater to a recent submission, return True
  302. try:
  303. for recent_quote in self.bot.mem_store['qdb']['_recent']:
  304. comparer.set_seq2(list(recent_quote.values())[0])
  305. if comparer.ratio() >= .75:
  306. return list(recent_quote.keys())[0]
  307. except TypeError:
  308. return -1
  309. except KeyError:
  310. return -1
  311. except IndexError:
  312. return -1
  313. return -1
  314. def add_recently_submitted(self, q_id, submission):
  315. """Takes a string, submission, and adds it to the list of recent submissions.
  316. Also we do length checking, only keep record of the previous MAX_HISTORY_SIZE quotes.
  317. """
  318. #first, see if we have reached the maximum history size. if so, remove last item
  319. if len(self.bot.mem_store['qdb']['_recent']) >= self.MAX_HISTORY_SIZE:
  320. self.bot.mem_store['qdb']['_recent'].pop()
  321. #inserting a dict with the qdb id of the submission and the submission content
  322. self.bot.mem_store['qdb']['_recent'].insert(0, {q_id:submission})
  323. def handle(self, event):
  324. #first check to see if there is a special deletion going on
  325. if event.msg.startswith(".qdbdelete") and event.is_pm:
  326. deletion = event.msg.split(' ', 2)
  327. try:
  328. #requires the format ".qdbdelete <post_id> <password>"
  329. self.say(event.user, self.delete(event.user, deletion[1], deletion[2]))
  330. except IndexError:
  331. self.say(event.user, "Not enough parameters provided for deletion.")
  332. return
  333. """
  334. See if we're going to generate a qdb submission, or just add the line to the buffer.
  335. .qdb is the standard, generous implementation selected after hours of testing and ideal for a significant number of situations where lines are repeated. Use specific search strings. the start_index of the submission will be the EARLIEST occurrence of the substring in the buffer.
  336. .qdbs is the strict implementation. The start_index will be the LATEST occurrence of the substring.
  337. """
  338. if event.msg.startswith(".qdb ") or event.msg.startswith(".qdbs "):
  339. #split the msg with '.qdb[s] ' stripped off beginning and divide into 1 or 2 search strings
  340. #e.g. ".qdb string1|string2" -> [".qdb", "string1|string2"]
  341. cmd_parts = event.msg.split(None,1)
  342. if len(cmd_parts) < 2:
  343. #do something here to handle '.qdb[s]'
  344. return
  345. #determine if using strict mode
  346. strict_mode = cmd_parts[0] == ".qdbs"
  347. #split the search parameter(s)
  348. #e.g. "string1|string2" -> ["string1", "string2"]
  349. string_token = cmd_parts[1].split('|', 1)
  350. start_msg = string_token[0].rstrip()
  351. #see if we only have a one line submission
  352. if len(string_token) == 1:
  353. #s is the string to submit
  354. s = self.get_qdb_submission(event.channel, start_msg)
  355. recent = self.recently_submitted(s)
  356. if recent > 0:
  357. q_url = "http://qdb.zero9f9.com/quote.php?id=" + str(recent)
  358. self.printer("PRIVMSG " + event.channel + " :QDB Error: A quote of >75% similarity has already been posted here: " + q_url + "\n")
  359. return
  360. if not s:
  361. self.printer("PRIVMSG " + event.channel + ' :QDB Error: Could not find requested string.\n')
  362. return
  363. #Print the link to the newly submitted quote
  364. self.printer("PRIVMSG " + event.channel + ' :' + self.submit(s) + '\n')
  365. return
  366. #We should only get here if there are two items in string_token
  367. end_msg = string_token[1].lstrip()
  368. s = self.get_qdb_submission(event.channel, start_msg, end_msg, strict_mode)
  369. recent = self.recently_submitted(s)
  370. if recent > 0:
  371. q_url = "http://qdb.zero9f9.com/quote.php?id=" + str(recent)
  372. self.printer("PRIVMSG " + event.channel + " :QDB Error: A quote of >75% similarity has already been posted here: " + q_url + "\n")
  373. return
  374. #if there's nothing found for the submission, then we alert the channel and gtfo
  375. if not s:
  376. self.printer("PRIVMSG " + event.channel + ' :QDB Error: Could not find requested quotes or parameters were not specific enough.\n')
  377. return
  378. #print the link to the new submission
  379. self.printer("PRIVMSG " + event.channel + ' :' + self.submit(s) + '\n')
  380. return
  381. self.add_buffer(event)