home · contact · privacy
7224b29e2ccff2a90504a113676edd625278253e
[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)
343     except (requests.exceptions.TooManyRedirects,
344             requests.exceptions.ConnectionError,
345             requests.exceptions.InvalidURL,
346             UnicodeError,
347             requests.exceptions.InvalidSchema) as error:
348         notice("TROUBLE FOLLOWING URL: " + str(error))
349         return
350     if mobile_twitter_hack(url):
351         return
352     title = bs4.BeautifulSoup(r.text, "html5lib").title
353     if title and title.string:
354         prefix = "PAGE TITLE: "
355         if show_url:
356             prefix = "PAGE TITLE FOR <" + url + ">: "
357         notice(prefix + title.string.strip())
358     else:
359         notice("PAGE HAS NO TITLE TAG")
360
361
362 class Session:
363
364     def __init__(self, io, username, nickname, channel, twtfile, dbdir, rmlogs):
365         self.io = io
366         self.nickname = nickname
367         self.username = username
368         self.channel = channel
369         self.users_in_chan = []
370         self.twtfile = twtfile
371         self.dbdir = dbdir
372         self.rmlogs = rmlogs
373         self.io.send_line("NICK " + self.nickname)
374         self.io.send_line("USER " + self.username + " 0 * : ")
375         self.io.send_line("JOIN " + self.channel)
376         hash_channel = hashlib.md5(self.channel.encode("utf-8")).hexdigest()
377         self.chandir = self.dbdir + "/" + hash_channel + "/"
378         self.rawlogdir = self.chandir + "raw_logs/"
379         self.logdir = self.chandir + "logs/"
380         if not os.path.exists(self.logdir):
381             os.makedirs(self.logdir)
382         if not os.path.exists(self.rawlogdir):
383             os.makedirs(self.rawlogdir)
384         self.markovfile = self.chandir + "markovfeed"
385         self.quotesfile = self.chandir + "quotes"
386
387     def loop(self):
388
389         def log(line):
390             if type(line) == str:
391                 line = Line(":" + self.nickname + "!~" + self.username +
392                             "@localhost" + " " + line)
393             now = datetime.datetime.utcnow()
394             form = "%Y-%m-%d %H:%M:%S UTC\t"
395             write_to_file(self.rawlogdir + now.strftime("%Y-%m-%d") + ".txt",
396                           "a", now.strftime(form) + " " + line.line + "\n")
397             to_log = irclog.format_logline(line, self.channel)
398             if to_log != None:
399                 write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt",
400                               "a", now.strftime(form) + " " + to_log + "\n")
401
402         def handle_privmsg(line):
403
404             def notice(msg):
405                 line = "NOTICE " + target + " :" + msg
406                 self.io.send_line(line)
407                 log(line)
408
409             target = line.sender
410             if line.receiver != self.nickname:
411                 target = line.receiver
412             msg = str.join(" ", line.tokens[3:])[1:]
413             matches = re.findall("(https?://[^\s>]+)", msg)
414             for i in range(len(matches)):
415                 handle_url(matches[i], notice)
416             if "!" == msg[0]:
417                 tokens = msg[1:].split()
418                 argument = str.join(" ", tokens[1:])
419                 handle_command(tokens[0], argument, notice, target, self)
420                 return
421             write_to_file(self.markovfile, "a", msg + "\n")
422
423         now = datetime.datetime.utcnow()
424         write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt", "a",
425                       "-----------------------\n")
426         while True:
427             if self.rmlogs > 0:
428                 for f in os.listdir(self.logdir):
429                     f = os.path.join(self.logdir, f)
430                     if os.path.isfile(f) and \
431                             os.stat(f).st_mtime < time.time() - self.rmlogs:
432                         os.remove(f)
433             line = self.io.recv_line()
434             if not line:
435                 continue
436             line = Line(line)
437             log(line)
438             if len(line.tokens) > 1:
439                 if line.tokens[0] == "PING":
440                     self.io.send_line("PONG " + line.tokens[1])
441                 elif line.tokens[1] == "PRIVMSG":
442                     handle_privmsg(line)
443                 elif line.tokens[1] == "353":
444                     names = line.tokens[5:]
445                     names[0] = names[0][1:]
446                     for i in range(len(names)):
447                         names[i] = names[i].replace("@", "").replace("+", "")
448                     self.users_in_chan += names
449                 elif line.tokens[1] == "JOIN" and line.sender != self.nickname:
450                     self.users_in_chan += [line.sender]
451                 elif line.tokens[1] == "PART":
452                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
453                 elif line.tokens[1] == "NICK":
454                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
455                     self.users_in_chan += [line.receiver]
456
457
458 def parse_command_line_arguments():
459     parser = argparse.ArgumentParser()
460     parser.add_argument("-s, --server", action="store", dest="server",
461                         default=SERVER,
462                         help="server or server net to connect to (default: "
463                         + SERVER + ")")
464     parser.add_argument("-p, --port", action="store", dest="port", type=int,
465                         default=PORT, help="port to connect to (default : "
466                         + str(PORT) + ")")
467     parser.add_argument("-w, --wait", action="store", dest="timeout",
468                         type=int, default=TIMEOUT,
469                         help="timeout in seconds after which to attempt "
470                         "reconnect (default: " + str(TIMEOUT) + ")")
471     parser.add_argument("-u, --username", action="store", dest="username",
472                         default=USERNAME, help="username to use (default: "
473                         + USERNAME + ")")
474     parser.add_argument("-n, --nickname", action="store", dest="nickname",
475                         default=NICKNAME, help="nickname to use (default: "
476                         + NICKNAME + ")")
477     parser.add_argument("-t, --twtxtfile", action="store", dest="twtfile",
478                         default=TWTFILE, help="twtxt file to use (default: "
479                         + TWTFILE + ")")
480     parser.add_argument("-d, --dbdir", action="store", dest="dbdir",
481                         default=DBDIR, help="directory to store DB files in")
482     parser.add_argument("-r, --rmlogs", action="store", dest="rmlogs",
483                         type=int, default=0,
484                         help="maximum age in seconds for logfiles in logs/ "
485                         "(0 means: never delete, and is default)")
486     parser.add_argument("CHANNEL", action="store", help="channel to join")
487     opts, unknown = parser.parse_known_args()
488     return opts
489
490
491 opts = parse_command_line_arguments()
492 while True:
493     try:
494         io = IO(opts.server, opts.port, opts.timeout)
495         hash_server = hashlib.md5(opts.server.encode("utf-8")).hexdigest()
496         dbdir = opts.dbdir + "/" + hash_server 
497         session = Session(io, opts.username, opts.nickname, opts.CHANNEL,
498             opts.twtfile, dbdir, opts.rmlogs)
499         session.loop()
500     except ExceptionForRestart:
501         io.socket.close()
502         continue