home · contact · privacy
66b902f672e978ad0ff5e01961c7fdd94e5e196a
[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                 for result in results:
184                     notice("QUOTE #" + str(result[0] + 1) + " : "
185                            + result[1][-1])
186             return
187         else:
188             i = random.randrange(len(lines))
189         notice("QUOTE #" + str(i + 1) + ": " + lines[i][:-1])
190
191     def markov():
192         from random import choice, shuffle
193         select_length = 2
194         selections = []
195
196         def markov(snippet):
197             usable_selections = []
198             for i in range(select_length, 0, -1):
199                 for selection in selections:
200                     add = True
201                     for j in range(i):
202                         j += 1
203                         if snippet[-j] != selection[-(j+1)]:
204                             add = False
205                             break
206                     if add:
207                         usable_selections += [selection]
208                 if [] != usable_selections:
209                     break
210             if [] == usable_selections:
211                 usable_selections = selections
212             selection = choice(usable_selections)
213             return selection[select_length]
214
215         if not os.access(session.markovfile, os.F_OK):
216             notice("NOT ENOUGH TEXT TO MARKOV.")
217             return
218
219         # Lowercase incoming lines, ensure they end in a sentence end mark.
220         file = open(session.markovfile, "r")
221         lines = file.readlines()
222         file.close()
223         tokens = []
224         sentence_end_markers = ".!?)("
225         for line in lines:
226             line = line.lower().replace("\n", "")
227             if line[-1] not in sentence_end_markers:
228                 line += "."
229             tokens += line.split()
230         if len(tokens) <= select_length:
231             notice("NOT ENOUGH TEXT TO MARKOV.")
232             return
233
234         # Replace URLs with escape string for now, so that the Markov selector
235         # won't see them as different strings. Stash replaced URLs in urls.
236         urls = []
237         url_escape = "\nURL"
238         url_starts = ["http://", "https://", "<http://", "<https://"]
239         for i in range(len(tokens)):
240             for url_start in url_starts:
241                 if tokens[i][:len(url_start)] == url_start:
242                     length = len(tokens[i])
243                     if url_start[0] == "<":
244                         try:
245                             length = tokens[i].index(">") + 1
246                         except ValueError:
247                             pass
248                     urls += [tokens[i][:length]]
249                     tokens[i] = url_escape + tokens[i][length:]
250                     break
251
252         # For each snippet of select_length, use markov() to find continuation
253         # token from selections. Replace present users' names with malkovich.
254         # Start snippets with the beginning of a sentence, if possible.
255         for i in range(len(tokens) - select_length):
256             token_list = []
257             for j in range(select_length + 1):
258                 token_list += [tokens[i + j]]
259             selections += [token_list]
260         snippet = []
261         for i in range(select_length):
262             snippet += [""]
263         shuffle(selections)
264         for i in range(len(selections)):
265             if selections[i][0][-1] in sentence_end_markers:
266                 for i in range(select_length):
267                     snippet[i] = selections[i][i + 1]
268                 break
269         msg = ""
270         malkovich = "malkovich"
271         while 1:
272             new_end = markov(snippet)
273             for name in session.users_in_chan:
274                 if new_end[:len(name)] == name.lower():
275                     new_end = malkovich + new_end[len(name):]
276                     break
277             if len(msg) + len(new_end) > 200:
278                 break
279             msg += new_end + " "
280             for i in range(select_length - 1):
281                 snippet[i] = snippet[i + 1]
282             snippet[select_length - 1] = new_end
283
284         # Replace occurences of url escape string with random choice from urls.
285         while True:
286             index = msg.find(url_escape)
287             if index < 0:
288                 break
289             msg = msg.replace(url_escape, choice(urls), 1)
290
291         # More meaningful ways to randomly end sentences.
292         notice(msg + malkovich + ".")
293
294     def twt():
295         def try_open(mode):
296             try:
297                 twtfile = open(session.twtfile, mode)
298             except (PermissionError, FileNotFoundError) as err:
299                 notice("CAN'T ACCESS OR CREATE TWT FILE: " + str(err))
300                 return None
301             return twtfile
302
303         from datetime import datetime
304         if not os.access(session.twtfile, os.F_OK):
305             twtfile = try_open("w")
306             if None == twtfile:
307                 return
308             twtfile.close()
309         twtfile = try_open("a")
310         if None == twtfile:
311             return
312         twtfile.write(datetime.utcnow().isoformat() + "\t" + argument + "\n")
313         twtfile.close()
314         notice("WROTE TWT.")
315
316     if "addquote" == command:
317         addquote()
318     elif "quote" == command:
319         quote()
320     elif "markov" == command:
321         markov()
322     elif "twt" == command:
323         twt()
324
325
326 def handle_url(url, notice, show_url=False):
327
328     def mobile_twitter_hack(url):
329         re1 = 'https?://(mobile.twitter.com/)[^/]+(/status/)'
330         re2 = 'https?://mobile.twitter.com/([^/]+)/status/([^\?/]+)'
331         m = re.search(re1, url)
332         if m and m.group(1) == 'mobile.twitter.com/' \
333                 and m.group(2) == '/status/':
334             m = re.search(re2, url)
335             url = 'https://twitter.com/' + m.group(1) + '/status/' + m.group(2)
336             handle_url(url, notice, True)
337             return True
338
339     try:
340         r = requests.get(url, timeout=15)
341     except (requests.exceptions.TooManyRedirects,
342             requests.exceptions.ConnectionError,
343             requests.exceptions.InvalidURL,
344             UnicodeError,
345             requests.exceptions.InvalidSchema) as error:
346         notice("TROUBLE FOLLOWING URL: " + str(error))
347         return
348     if mobile_twitter_hack(url):
349         return
350     title = bs4.BeautifulSoup(r.text, "html5lib").title
351     if title and title.string:
352         prefix = "PAGE TITLE: "
353         if show_url:
354             prefix = "PAGE TITLE FOR <" + url + ">: "
355         notice(prefix + title.string.strip())
356     else:
357         notice("PAGE HAS NO TITLE TAG")
358
359
360 class Session:
361
362     def __init__(self, io, username, nickname, channel, twtfile, dbdir, rmlogs):
363         self.io = io
364         self.nickname = nickname
365         self.username = username
366         self.channel = channel
367         self.users_in_chan = []
368         self.twtfile = twtfile
369         self.dbdir = dbdir
370         self.rmlogs = rmlogs
371         self.io.send_line("NICK " + self.nickname)
372         self.io.send_line("USER " + self.username + " 0 * : ")
373         self.io.send_line("JOIN " + self.channel)
374         hash_channel = hashlib.md5(self.channel.encode("utf-8")).hexdigest()
375         self.chandir = self.dbdir + "/" + hash_channel + "/"
376         self.rawlogdir = self.chandir + "raw_logs/"
377         self.logdir = self.chandir + "logs/"
378         if not os.path.exists(self.logdir):
379             os.makedirs(self.logdir)
380         if not os.path.exists(self.rawlogdir):
381             os.makedirs(self.rawlogdir)
382         self.markovfile = self.chandir + "markovfeed"
383         self.quotesfile = self.chandir + "quotes"
384
385     def loop(self):
386
387         def log(line):
388             if type(line) == str:
389                 line = Line(":" + self.nickname + "!~" + self.username +
390                             "@localhost" + " " + line)
391             now = datetime.datetime.utcnow()
392             form = "%Y-%m-%d %H:%M:%S UTC\t"
393             write_to_file(self.rawlogdir + now.strftime("%Y-%m-%d") + ".txt",
394                           "a", now.strftime(form) + " " + line.line + "\n")
395             to_log = irclog.format_logline(line, self.channel)
396             if to_log != None:
397                 write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt",
398                               "a", now.strftime(form) + " " + to_log + "\n")
399
400         def handle_privmsg(line):
401
402             def notice(msg):
403                 line = "NOTICE " + target + " :" + msg
404                 self.io.send_line(line)
405                 log(line)
406
407             target = line.sender
408             if line.receiver != self.nickname:
409                 target = line.receiver
410             msg = str.join(" ", line.tokens[3:])[1:]
411             matches = re.findall("(https?://[^\s>]+)", msg)
412             for i in range(len(matches)):
413                 handle_url(matches[i], notice)
414             if "!" == msg[0]:
415                 tokens = msg[1:].split()
416                 argument = str.join(" ", tokens[1:])
417                 handle_command(tokens[0], argument, notice, target, self)
418                 return
419             write_to_file(self.markovfile, "a", msg + "\n")
420
421         now = datetime.datetime.utcnow()
422         write_to_file(self.logdir + now.strftime("%Y-%m-%d") + ".txt", "a",
423                       "-----------------------\n")
424         while True:
425             if self.rmlogs > 0:
426                 for f in os.listdir(self.logdir):
427                     f = os.path.join(self.logdir, f)
428                     if os.path.isfile(f) and \
429                             os.stat(f).st_mtime < time.time() - self.rmlogs:
430                         os.remove(f)
431             line = self.io.recv_line()
432             if not line:
433                 continue
434             line = Line(line)
435             log(line)
436             if len(line.tokens) > 1:
437                 if line.tokens[0] == "PING":
438                     self.io.send_line("PONG " + line.tokens[1])
439                 elif line.tokens[1] == "PRIVMSG":
440                     handle_privmsg(line)
441                 elif line.tokens[1] == "353":
442                     names = line.tokens[5:]
443                     names[0] = names[0][1:]
444                     for i in range(len(names)):
445                         names[i] = names[i].replace("@", "").replace("+", "")
446                     self.users_in_chan += names
447                 elif line.tokens[1] == "JOIN" and line.sender != self.nickname:
448                     self.users_in_chan += [line.sender]
449                 elif line.tokens[1] == "PART":
450                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
451                 elif line.tokens[1] == "NICK":
452                     del(self.users_in_chan[self.users_in_chan.index(line.sender)])
453                     self.users_in_chan += [line.receiver]
454
455
456 def parse_command_line_arguments():
457     parser = argparse.ArgumentParser()
458     parser.add_argument("-s, --server", action="store", dest="server",
459                         default=SERVER,
460                         help="server or server net to connect to (default: "
461                         + SERVER + ")")
462     parser.add_argument("-p, --port", action="store", dest="port", type=int,
463                         default=PORT, help="port to connect to (default : "
464                         + str(PORT) + ")")
465     parser.add_argument("-w, --wait", action="store", dest="timeout",
466                         type=int, default=TIMEOUT,
467                         help="timeout in seconds after which to attempt "
468                         "reconnect (default: " + str(TIMEOUT) + ")")
469     parser.add_argument("-u, --username", action="store", dest="username",
470                         default=USERNAME, help="username to use (default: "
471                         + USERNAME + ")")
472     parser.add_argument("-n, --nickname", action="store", dest="nickname",
473                         default=NICKNAME, help="nickname to use (default: "
474                         + NICKNAME + ")")
475     parser.add_argument("-t, --twtxtfile", action="store", dest="twtfile",
476                         default=TWTFILE, help="twtxt file to use (default: "
477                         + TWTFILE + ")")
478     parser.add_argument("-d, --dbdir", action="store", dest="dbdir",
479                         default=DBDIR, help="directory to store DB files in")
480     parser.add_argument("-r, --rmlogs", action="store", dest="rmlogs",
481                         type=int, default=0,
482                         help="maximum age in seconds for logfiles in logs/ "
483                         "(0 means: never delete, and is default)")
484     parser.add_argument("CHANNEL", action="store", help="channel to join")
485     opts, unknown = parser.parse_known_args()
486     return opts
487
488
489 opts = parse_command_line_arguments()
490 while True:
491     try:
492         io = IO(opts.server, opts.port, opts.timeout)
493         hash_server = hashlib.md5(opts.server.encode("utf-8")).hexdigest()
494         dbdir = opts.dbdir + "/" + hash_server 
495         session = Session(io, opts.username, opts.nickname, opts.CHANNEL,
496             opts.twtfile, dbdir, opts.rmlogs)
497         session.loop()
498     except ExceptionForRestart:
499         io.socket.close()
500         continue