home · contact · privacy
Handle too-many-URLs-in-message.
[plomlombot-irc.git] / plomlombot.py
1 #!/usr/bin/python3
2
3 import argparse
4 import socket
5 import datetime
6 import select
7 import time
8 import re
9 import requests
10 import bs4
11 import random
12 import hashlib
13 import os
14 import plomsearch
15 import irclog
16
17 # Defaults, may be overwritten by command line arguments.
18 SERVER = "irc.freenode.net"
19 PORT = 6667
20 TIMEOUT = 240
21 USERNAME = "plomlombot"
22 NICKNAME = USERNAME
23 TWTFILE = ""
24 DBDIR = os.path.expanduser("~/plomlombot_db")
25
26
27 def write_to_file(path, mode, text):
28     f = open(path, mode)
29     f.write(text)
30     f.close()
31
32
33 class ExceptionForRestart(Exception):
34     pass
35
36
37 class Line:
38
39     def __init__(self, line):
40         self.line = line
41         self.tokens = line.split(" ")
42         self.sender = ""
43         if self.tokens[0][0] == ":":
44             for rune in self.tokens[0][1:]:
45                 if rune in {"!", "@"}:
46                     break
47                 self.sender += rune
48         self.receiver = ""
49         if len(self.tokens) > 2:
50             for rune in self.tokens[2]:
51                 if rune in {"!", "@"}:
52                     break
53                 if rune != ":":
54                     self.receiver += rune
55
56
57 class IO:
58
59     def __init__(self, server, port, timeout):
60         self.timeout = timeout
61         self.socket = socket.socket()
62         try:
63             self.socket.connect((server, port))
64         except TimeoutError:
65             raise ExceptionForRestart
66         self.socket.setblocking(0)
67         self.line_buffer = []
68         self.rune_buffer = ""
69         self.last_pong = time.time()
70         self.servername = self.recv_line(send_ping=False).split(" ")[0][1:]
71
72     def _pingtest(self, send_ping=True):
73         if self.last_pong + self.timeout < time.time():
74             print("SERVER NOT ANSWERING")
75             raise ExceptionForRestart
76         if send_ping:
77             self.send_line("PING " + self.servername)
78
79     def send_line(self, msg):
80         msg = msg.replace("\r", " ")
81         msg = msg.replace("\n", " ")
82         if len(msg.encode("utf-8")) > 510:
83             print("NOT SENT LINE TO SERVER (too long): " + msg)
84         print("LINE TO SERVER: "
85               + str(datetime.datetime.now()) + ": " + msg)
86         msg = msg + "\r\n"
87         msg_len = len(msg)
88         total_sent_len = 0
89         while total_sent_len < msg_len:
90             sent_len = self.socket.send(bytes(msg[total_sent_len:], "UTF-8"))
91             if sent_len == 0:
92                 print("SOCKET CONNECTION BROKEN")
93                 raise ExceptionForRestart
94             total_sent_len += sent_len
95
96     def _recv_line_wrapped(self, send_ping=True):
97         if len(self.line_buffer) > 0:
98             return self.line_buffer.pop(0)
99         while True:
100             ready = select.select([self.socket], [], [], int(self.timeout / 2))
101             if not ready[0]:
102                 self._pingtest(send_ping)
103                 return None
104             self.last_pong = time.time()
105             received_bytes = self.socket.recv(1024)
106             try:
107                 received_runes = received_bytes.decode("UTF-8")
108             except UnicodeDecodeError:
109                 received_runes = received_bytes.decode("latin1")
110             if len(received_runes) == 0:
111                 print("SOCKET CONNECTION BROKEN")
112                 raise ExceptionForRestart
113             self.rune_buffer += received_runes
114             lines_split = str.split(self.rune_buffer, "\r\n")
115             self.line_buffer += lines_split[:-1]
116             self.rune_buffer = lines_split[-1]
117             if len(self.line_buffer) > 0:
118                 return self.line_buffer.pop(0)
119
120     def recv_line(self, send_ping=True):
121         line = self._recv_line_wrapped(send_ping)
122         if line:
123             print("LINE FROM SERVER " + str(datetime.datetime.now()) + ": " +
124                   line)
125         return line
126
127
128 def handle_command(command, argument, notice, target, session):
129
130     def addquote():
131         if not os.access(session.quotesfile, os.F_OK):
132             write_to_file(session.quotesfile, "w",
133                           "QUOTES FOR " + target + ":\n")
134         write_to_file(session.quotesfile, "a", argument + "\n")
135         quotesfile = open(session.quotesfile, "r")
136         lines = quotesfile.readlines()
137         quotesfile.close()
138         notice("ADDED QUOTE #" + str(len(lines) - 1))
139
140     def quote():
141
142         def help():
143             notice("SYNTAX: !quote [int] OR !quote search QUERY")
144             notice("QUERY may be a boolean grouping of quoted or unquoted " +
145                    "search terms, examples:")
146             notice("!quote search foo")
147             notice("!quote search foo AND (bar OR NOT baz)")
148             notice("!quote search \"foo\\\"bar\" AND ('NOT\"' AND \"'foo'\"" +
149                    " OR 'bar\\'baz')")
150
151         if "" == argument:
152             tokens = []
153         else:
154             tokens = argument.split(" ")
155         if (len(tokens) > 1 and tokens[0] != "search") or \
156             (len(tokens) == 1 and
157                 (tokens[0] == "search" or not tokens[0].isdigit())):
158             help()
159             return
160         if not os.access(session.quotesfile, os.F_OK):
161             notice("NO QUOTES AVAILABLE")
162             return
163         quotesfile = open(session.quotesfile, "r")
164         lines = quotesfile.readlines()
165         quotesfile.close()
166         lines = lines[1:]
167         if len(tokens) == 1:
168             i = int(tokens[0])
169             if i == 0 or i > len(lines):
170                 notice("THERE'S NO QUOTE OF THAT INDEX")
171                 return
172             i = i - 1
173         elif len(tokens) > 1:
174             query = str.join(" ", tokens[1:])
175             try:
176                 results = plomsearch.search(query, lines)
177             except plomsearch.LogicParserError as err:
178                 notice("FAILED QUERY PARSING: " + str(err))
179                 return
180             if len(results) == 0:
181                 notice("NO QUOTES MATCHING QUERY")
182             else:
183                 if len(results) > 3:
184                     notice("SHOWING 3 OF " + str(len(results)) + " QUOTES")
185                 for result in results[:3]:
186                     notice("QUOTE #" + str(result[0] + 1) + ": "
187                            + result[1][:-1])
188             return
189         else:
190             i = random.randrange(len(lines))
191         notice("QUOTE #" + str(i + 1) + ": " + lines[i][:-1])
192
193     def markov():
194         from random import choice, shuffle
195         select_length = 2
196         selections = []
197
198         def markov(snippet):
199             usable_selections = []
200             for i in range(select_length, 0, -1):
201                 for selection in selections:
202                     add = True
203                     for j in range(i):
204                         j += 1
205                         if snippet[-j] != selection[-(j+1)]:
206                             add = False
207                             break
208                     if add:
209                         usable_selections += [selection]
210                 if [] != usable_selections:
211                     break
212             if [] == usable_selections:
213                 usable_selections = selections
214             selection = choice(usable_selections)
215             return selection[select_length]
216
217         if not os.access(session.markovfile, os.F_OK):
218             notice("NOT ENOUGH TEXT TO MARKOV.")
219             return
220
221         # Lowercase incoming lines, ensure they end in a sentence end mark.
222         file = open(session.markovfile, "r")
223         lines = file.readlines()
224         file.close()
225         tokens = []
226         sentence_end_markers = ".!?)("
227         for line in lines:
228             line = line.lower().replace("\n", "")
229             if line[-1] not in sentence_end_markers:
230                 line += "."
231             tokens += line.split()
232         if len(tokens) <= select_length:
233             notice("NOT ENOUGH TEXT TO MARKOV.")
234             return
235
236         # Replace URLs with escape string for now, so that the Markov selector
237         # won't see them as different strings. Stash replaced URLs in urls.
238         urls = []
239         url_escape = "\nURL"
240         url_starts = ["http://", "https://", "<http://", "<https://"]
241         for i in range(len(tokens)):
242             for url_start in url_starts:
243                 if tokens[i][:len(url_start)] == url_start:
244                     length = len(tokens[i])
245                     if url_start[0] == "<":
246                         try:
247                             length = tokens[i].index(">") + 1
248                         except ValueError:
249                             pass
250                     urls += [tokens[i][:length]]
251                     tokens[i] = url_escape + tokens[i][length:]
252                     break
253
254         # For each snippet of select_length, use markov() to find continuation
255         # token from selections. Replace present users' names with malkovich.
256         # Start snippets with the beginning of a sentence, if possible.
257         for i in range(len(tokens) - select_length):
258             token_list = []
259             for j in range(select_length + 1):
260                 token_list += [tokens[i + j]]
261             selections += [token_list]
262         snippet = []
263         for i in range(select_length):
264             snippet += [""]
265         shuffle(selections)
266         for i in range(len(selections)):
267             if selections[i][0][-1] in sentence_end_markers:
268                 for i in range(select_length):
269                     snippet[i] = selections[i][i + 1]
270                 break
271         msg = ""
272         malkovich = "malkovich"
273         while 1:
274             new_end = markov(snippet)
275             for name in session.users_in_chan:
276                 if new_end[:len(name)] == name.lower():
277                     new_end = malkovich + new_end[len(name):]
278                     break
279             if len(msg) + len(new_end) > 200:
280                 break
281             msg += new_end + " "
282             for i in range(select_length - 1):
283                 snippet[i] = snippet[i + 1]
284             snippet[select_length - 1] = new_end
285
286         # Replace occurences of url escape string with random choice from urls.
287         while True:
288             index = msg.find(url_escape)
289             if index < 0:
290                 break
291             msg = msg.replace(url_escape, choice(urls), 1)
292
293         # More meaningful ways to randomly end sentences.
294         notice(msg + malkovich + ".")
295
296     def twt():
297         def try_open(mode):
298             try:
299                 twtfile = open(session.twtfile, mode)
300             except (PermissionError, FileNotFoundError) as err:
301                 notice("CAN'T ACCESS OR CREATE TWT FILE: " + str(err))
302                 return None
303             return twtfile
304
305         from datetime import datetime
306         if not os.access(session.twtfile, os.F_OK):
307             twtfile = try_open("w")
308             if None == twtfile:
309                 return
310             twtfile.close()
311         twtfile = try_open("a")
312         if None == twtfile:
313             return
314         twtfile.write(datetime.utcnow().isoformat() + "\t" + argument + "\n")
315         twtfile.close()
316         notice("WROTE TWT.")
317
318     if "addquote" == command:
319         addquote()
320     elif "quote" == command:
321         quote()
322     elif "markov" == command:
323         markov()
324     elif "twt" == command:
325         twt()
326
327
328 def handle_url(url, notice, show_url=False):
329
330     def mobile_twitter_hack(url):
331         re1 = 'https?://(mobile.twitter.com/)[^/]+(/status/)'
332         re2 = 'https?://mobile.twitter.com/([^/]+)/status/([^\?/]+)'
333         m = re.search(re1, url)
334         if m and m.group(1) == 'mobile.twitter.com/' \
335                 and m.group(2) == '/status/':
336             m = re.search(re2, url)
337             url = 'https://twitter.com/' + m.group(1) + '/status/' + m.group(2)
338             handle_url(url, notice, True)
339             return True
340
341     try:
342         r = requests.get(url, timeout=15, stream=True)
343         r.raw.decode_content = True
344         text = r.raw.read(10000000+1)
345         if len(text) > 10000000:
346             raise ValueError('Too large a response')
347     except (requests.exceptions.TooManyRedirects,
348             requests.exceptions.ConnectionError,
349             requests.exceptions.InvalidURL,
350             requests.exceptions.ReadTimeout,
351             UnicodeError,
352             ValueError,
353             requests.exceptions.InvalidSchema) as error:
354         notice("TROUBLE FOLLOWING URL: " + str(error))
355         return False
356     if mobile_twitter_hack(url):
357         return True
358     title = bs4.BeautifulSoup(text, "html5lib").title
359     if title and title.string:
360         prefix = "PAGE TITLE: "
361         if show_url:
362             prefix = "PAGE TITLE FOR <" + url + ">: "
363         notice(prefix + title.string.strip())
364     else:
365         notice("PAGE HAS NO TITLE TAG")
366     return True
367
368
369 class Session:
370
371     def __init__(self, io, username, nickname, channel, twtfile, dbdir, rmlogs):
372         self.io = io
373         self.nickname = nickname
374         self.username = username
375         self.channel = channel
376         self.users_in_chan = []
377         self.twtfile = twtfile
378         self.dbdir = dbdir
379         self.rmlogs = rmlogs
380         self.io.send_line("NICK " + self.nickname)
381         self.io.send_line("USER " + self.username + " 0 * : ")
382         self.io.send_line("JOIN " + self.channel)
383         hash_channel = hashlib.md5(self.channel.encode("utf-8")).hexdigest()
384         self.chandir = self.dbdir + "/" + hash_channel + "/"
385         self.rawlogdir = self.chandir + "raw_logs/"
386         self.logdir = self.chandir + "logs/"
387         if not os.path.exists(self.logdir):
388             os.makedirs(self.logdir)
389         if not os.path.exists(self.rawlogdir):
390             os.makedirs(self.rawlogdir)
391         self.markovfile = self.chandir + "markovfeed"
392         self.quotesfile = self.chandir + "quotes"
393
394     def loop(self):
395
396         def log(line):
397             if type(line) == str:
398                 line = Line(":" + self.nickname + "!~" + self.username +
399                             "@localhost" + " " + line)
400             now = datetime.datetime.utcnow()
401             form = "%Y-%m-%d %H:%M:%S UTC\t"
402             write_to_file(self.rawlogdir + now.strftime("%Y-%m-%d") + ".txt",
403                           "a", now.strftime(form) + " " + line.line + "\n")
404             to_log = irclog.format_logline(line, self.channel)
405             if to_log != None:
406                 write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt",
407                               "a", now.strftime(form) + " " + to_log + "\n")
408
409         def handle_privmsg(line):
410
411             def notice(msg):
412                 line = "NOTICE " + target + " :" + msg
413                 self.io.send_line(line)
414                 log(line)
415
416             target = line.sender
417             if line.receiver != self.nickname:
418                 target = line.receiver
419             msg = str.join(" ", line.tokens[3:])[1:]
420             matches = re.findall("(https?://[^\s>]+)", msg)
421             url_count = 0
422             for i in range(len(matches)):
423                 if handle_url(matches[i], notice):
424                     url_count += 1
425                     if url_count == 3:
426                         notice("MAXIMUM NUMBER OF URLs TO PARSE PER MESSAGE "
427                                "REACHED")
428                         break
429             if "!" == msg[0]:
430                 tokens = msg[1:].split()
431                 argument = str.join(" ", tokens[1:])
432                 handle_command(tokens[0], argument, notice, target, self)
433                 return
434             write_to_file(self.markovfile, "a", msg + "\n")
435
436         now = datetime.datetime.utcnow()
437         write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt", "a",
438                       "-----------------------\n")
439         while True:
440             if self.rmlogs > 0:
441                 for f in os.listdir(self.logdir):
442                     f = os.path.join(self.logdir, f)
443                     if os.path.isfile(f) and \
444                             os.stat(f).st_mtime < time.time() - self.rmlogs:
445                         os.remove(f)
446             line = self.io.recv_line()
447             if not line:
448                 continue
449             line = Line(line)
450             log(line)
451             if len(line.tokens) > 1:
452                 if line.tokens[0] == "PING":
453                     self.io.send_line("PONG " + line.tokens[1])
454                 elif line.tokens[1] == "PRIVMSG":
455                     handle_privmsg(line)
456                 elif line.tokens[1] == "353":
457                     names = line.tokens[5:]
458                     names[0] = names[0][1:]
459                     for i in range(len(names)):
460                         names[i] = names[i].replace("@", "").replace("+", "")
461                     self.users_in_chan += names
462                 elif line.tokens[1] == "JOIN" and line.sender != self.nickname:
463                     self.users_in_chan += [line.sender]
464                 elif line.tokens[1] == "PART":
465                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
466                 elif line.tokens[1] == "NICK":
467                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
468                     self.users_in_chan += [line.receiver]
469
470
471 def parse_command_line_arguments():
472     parser = argparse.ArgumentParser()
473     parser.add_argument("-s, --server", action="store", dest="server",
474                         default=SERVER,
475                         help="server or server net to connect to (default: "
476                         + SERVER + ")")
477     parser.add_argument("-p, --port", action="store", dest="port", type=int,
478                         default=PORT, help="port to connect to (default : "
479                         + str(PORT) + ")")
480     parser.add_argument("-w, --wait", action="store", dest="timeout",
481                         type=int, default=TIMEOUT,
482                         help="timeout in seconds after which to attempt "
483                         "reconnect (default: " + str(TIMEOUT) + ")")
484     parser.add_argument("-u, --username", action="store", dest="username",
485                         default=USERNAME, help="username to use (default: "
486                         + USERNAME + ")")
487     parser.add_argument("-n, --nickname", action="store", dest="nickname",
488                         default=NICKNAME, help="nickname to use (default: "
489                         + NICKNAME + ")")
490     parser.add_argument("-t, --twtxtfile", action="store", dest="twtfile",
491                         default=TWTFILE, help="twtxt file to use (default: "
492                         + TWTFILE + ")")
493     parser.add_argument("-d, --dbdir", action="store", dest="dbdir",
494                         default=DBDIR, help="directory to store DB files in")
495     parser.add_argument("-r, --rmlogs", action="store", dest="rmlogs",
496                         type=int, default=0,
497                         help="maximum age in seconds for logfiles in logs/ "
498                         "(0 means: never delete, and is default)")
499     parser.add_argument("CHANNEL", action="store", help="channel to join")
500     opts, unknown = parser.parse_known_args()
501     return opts
502
503
504 opts = parse_command_line_arguments()
505 while True:
506     try:
507         io = IO(opts.server, opts.port, opts.timeout)
508         hash_server = hashlib.md5(opts.server.encode("utf-8")).hexdigest()
509         dbdir = opts.dbdir + "/" + hash_server 
510         session = Session(io, opts.username, opts.nickname, opts.CHANNEL,
511             opts.twtfile, dbdir, opts.rmlogs)
512         session.loop()
513     except ExceptionForRestart:
514         io.socket.close()
515         continue