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