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