-
+ A168B17F05E71BEC4ED700EDA6AE588D080F41D1B93842044709FC8349D1A44116692FCCCDC10861DC1E4E317F02F7456E70DCF66945FD9451D83B83F09A85E4
logotron/bot.py
(0 . 0)(1 . 446)
87 #!/usr/bin/python
88
89 import ConfigParser, sys, logging, socket, time, re, requests, urllib
90 from urllib import quote
91
92 # DBism
93 import psycopg2, psycopg2.extras
94 import psycopg2.extensions
95 psycopg2.extensions.register_type(psycopg2.extensions.UNICODE)
96 psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY)
97 import time, datetime
98 from datetime import datetime
99
100 ##############################################################################
101
102 cfg = ConfigParser.ConfigParser()
103
104 ##############################################################################
105
106 # Single mandatory arg: config file path
107 if len(sys.argv[1:]) != 1:
108 # If no args, print usage and exit:
109 print sys.argv[0] + " CONFIG"
110 exit(0)
111
112 # Read Config
113 cfg.readfp(open(sys.argv[1]))
114
115 # Get log path
116 logpath = cfg.get("bofh", "log")
117
118 # Get IRCism debug toggle
119 irc_dbg = cfg.get("irc", "irc_dbg")
120 if irc_dbg == 1:
121 log_lvl = logging.DEBUG
122 else:
123 log_lvl = logging.INFO
124
125 # Init logo
126 logging.basicConfig(filename=logpath, filemode='a', level=log_lvl,
127 format='%(asctime)s %(levelname)s %(message)s',
128 datefmt='%d-%b-%y %H:%M:%S')
129
130 # Date format used in log lines
131 Date_Short_Format = "%Y-%m-%d"
132
133 # Date format used in echoes
134 Date_Long_Format = "%Y-%m-%d %H:%M:%S"
135
136 ##############################################################################
137 # Get the remaining knob values:
138
139 try:
140 # IRCism:
141 Buf_Size = int(cfg.get("tcp", "bufsize"))
142 Timeout = int(cfg.get("tcp", "timeout"))
143 TX_Delay = float(cfg.get("tcp", "t_delay"))
144 Servers = [x.strip() for x in cfg.get("irc", "servers").split(',')]
145 Port = int(cfg.get("irc", "port"))
146 Nick = cfg.get("irc", "nick")
147 Pass = cfg.get("irc", "pass")
148 Channels = [x.strip() for x in cfg.get("irc", "chans").split(',')]
149 Join_Delay = int(cfg.get("irc", "join_t"))
150 Prefix = cfg.get("control", "prefix")
151 # DBism:
152 DB_Name = cfg.get("db", "db_name")
153 DB_User = cfg.get("db", "db_user")
154 DB_DEBUG = cfg.get("db", "db_debug")
155 # Logism:
156 Base_URL = cfg.get("logotron", "base_url")
157 Era = int(cfg.get("logotron", "era"))
158 NewChan_Idx = int(cfg.get("logotron", "newchan_idx"))
159 Src_URL = cfg.get("logotron", "src_url")
160
161 except Exception as e:
162 print "Invalid config: ", e
163 exit(1)
164
165 ##############################################################################
166
167 # Connect to the given DB
168 try:
169 db = psycopg2.connect("dbname=%s user=%s" % (DB_Name, DB_User))
170 except Exception:
171 print "Could not connect to DB!"
172 logging.error("Could not connect to DB!")
173 exit(1)
174 else:
175 logging.info("Connected to DB!")
176
177 ##############################################################################
178
179 def close_db():
180 db.close()
181
182 def exec_db(query, args=()):
183 cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
184 if (DB_DEBUG): logging.debug("query: '{0}'".format(query))
185 if (DB_DEBUG): logging.debug("args: '{0}'".format(args))
186 cur.execute(query, args)
187
188 def query_db(query, args=(), one=False):
189 cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
190 if (DB_DEBUG): logging.debug("query: '{0}'".format(query))
191 cur.execute(query, args)
192 rv = cur.fetchone() if one else cur.fetchall()
193 if (DB_DEBUG): logging.debug("query res: '{0}'".format(rv))
194 return rv
195
196 def rollback_db():
197 cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
198 cur.execute("ROLLBACK")
199 db.commit()
200
201 def commit_db():
202 cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
203 db.commit()
204
205
206 ##############################################################################
207 # IRCism
208 ##############################################################################
209
210 # Used to compute 'uptime'
211 time_last_conn = datetime.now()
212
213 # Init socket:
214 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
215
216 # Set keepalive:
217 sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
218
219 # Initially we are not connected to anything
220 connected = False
221
222 # Connect to given host:port; return whether connected
223 def connect(host, port):
224 logging.info("Connecting to %s:%s" % (host, port))
225 sock.settimeout(Timeout)
226 try:
227 sock.connect((host, port))
228 except (socket.timeout, socket.error) as e:
229 logging.warning(e)
230 return False
231 except Exception as e:
232 logging.exception(e)
233 return False
234 else:
235 logging.info("Connected.")
236 return True
237
238
239 # Attempt connect to each of hosts, in order, on port; return whether connected
240 def connect_any(hosts, port):
241 for host in hosts:
242 if connect(host, port):
243 return True
244 return False
245
246
247 # Transmit IRC message
248 def send(message):
249 global connected
250 if not connected:
251 logging.warning("Tried to send while disconnected?")
252 return False
253 time.sleep(TX_Delay)
254 logging.debug("> '%s'" % message)
255 message = "%s\r\n" % message
256 try:
257 sock.send(message.encode("utf-8"))
258 except (socket.timeout, socket.error) as e:
259 logging.warning("Socket could not send! Disconnecting.")
260 connected = False
261 return False
262 except Exception as e:
263 logging.exception(e)
264 return False
265
266
267 # Speak given message on a selected channel
268 def speak(channel, message):
269 send("PRIVMSG #%s :%s" % (channel, message))
270 # Now save what the bot spoke:
271 save_line(datetime.now(), channel, Nick, False, message)
272
273
274 # Standard incoming IRC line (excludes fleanode liquishit, etc)
275 irc_line_re = re.compile("""^:([^!]+)\!\S+\s+PRIVMSG\s+\#(\S+)\s+\:(.*)""")
276
277 # The '#' prevents interaction via PM, this is not a PM-able bot.
278
279 # 'Actions'
280 irc_act_re = re.compile(""".*ACTION\s+(.*)""")
281
282
283 # A line was received from IRC
284 def received_line(line):
285 # Process the traditional pingpong
286 if line.startswith("PING"):
287 send("PONG " + line.split()[1])
288 else:
289 logging.debug("< '%s'" % line)
290 standard_line = re.search(irc_line_re, line)
291 if standard_line:
292 # Break this line into the standard segments
293 (user, chan, text) = [s.strip() for s in standard_line.groups()]
294 # Determine whether this line is an 'action' :
295 action = False
296 act = re.search(irc_act_re, line)
297 if act:
298 action = True
299 text = act.group(1)
300 # This line is edible, process it.
301 eat_logline(user, chan, text, action)
302
303
304 # IRCate until we get disconnected
305 def irc():
306 global connected
307
308 # Connect to one among the specified servers, in given priority :
309 while not connected:
310 connected = connect_any(Servers, Port)
311
312 # Save time of last successful connect
313 time_last_conn = datetime.now()
314
315 # Auth to server
316 send("NICK %s\r\n" % Nick)
317 send("USER %s %s %s :%s\r\n" % (Nick, Nick, Nick, Nick))
318 send("NICKSERV IDENTIFY %s %s\r\n" % (Nick, Pass))
319
320 time.sleep(Join_Delay) # wait to join until fleanode eats auth
321
322 # Join selected channels
323 for chan in Channels:
324 logging.info("Joining channel '%s'..." % chan)
325 send("JOIN #%s\r\n" % chan)
326
327 while connected:
328 try:
329 data = sock.recv(Buf_Size)
330 except socket.timeout as e:
331 logging.debug("Listen timed out")
332 continue
333 except socket.error as e:
334 logging.warning("Listen socket error, disconnecting.")
335 connected = False
336 continue
337 except Exception as e:
338 logging.exception(e)
339 connected = False
340 continue
341 else:
342 if len(data) == 0:
343 logging.warning("Listen socket closed, disconnecting.")
344 connected = False
345 continue
346 try:
347 data = data.strip(b'\r\n').decode("utf-8")
348 for l in data.splitlines():
349 received_line(l)
350 continue
351 except Exception as e:
352 logging.exception(e)
353 continue
354
355 ##############################################################################
356
357 html_escape_table = {
358 "&": "&",
359 '"': """,
360 "'": "'",
361 ">": ">",
362 "<": "<",
363 }
364
365 def html_escape(text):
366 res = ("".join(html_escape_table.get(c,c) for c in text))
367 return urllib.quote(res.encode('utf-8'))
368
369
370 searcher_re = re.compile("""(\d+) Results""")
371
372 # Retrieve a search result count using the WWWistic frontend.
373 # This way it is not necessary to have query parser in two places.
374 # However it is slightly wasteful of CPU (requires actually loading results.)
375 def get_search_res(chan, query):
376 try:
377 esc_q = html_escape(query)
378 url = Base_URL + "log-search?q=" + esc_q + "&chan=" + chan
379 res = requests.get(url).text
380 t = res[res.find('<title>') + 7 : res.find('</title>')].strip()
381 found = searcher_re.match(t)
382 if found:
383 output = "[" + url + "]" + "[" + found.group(1)
384 output += """ results for "%s" in #%s]""" % (query, chan)
385 return output
386 else:
387 return """No results found for "%s" in #%s""" % (query, chan)
388 except Exception as e:
389 logging.exception(e)
390 return "No results returned (is logotron WWW up ?)"
391
392 ##############################################################################
393
394 # Commands:
395
396 def cmd_help(arg, user, chan):
397 # Speak the 'help' text
398 speak(chan, "%s: my valid commands are: %s" %
399 (user, ', '.join(Commands.keys())));
400
401 def cmd_search(arg, user, chan):
402 logging.debug("search: '%s'" % arg)
403 speak(chan, get_search_res(chan, arg))
404
405 def cmd_seen(arg, user, chan):
406 speak(chan, "%s: this command is not yet implemented." % user);
407
408 def cmd_src(arg, user, chan):
409 speak(chan, "%s: my source code can be seen at: %s" % (user, Src_URL));
410
411 def cmd_uptime(arg, user, chan):
412 uptime_txt = ""
413 uptime = (datetime.now() - time_last_conn)
414 days = uptime.days
415 hours = uptime.seconds/3600
416 minutes = (uptime.seconds%3600)/60
417 uptime_txt += '%dd ' % days
418 uptime_txt += '%dh ' % hours
419 uptime_txt += '%dm' % minutes
420 # Speak the uptime
421 speak(chan, "%s: time since my last reconnect : %s" %
422 (user, uptime_txt));
423
424 Commands = {
425 "help" : cmd_help,
426 "s" : cmd_search,
427 "seen" : cmd_seen,
428 "uptime" : cmd_uptime,
429 "src" : cmd_src
430 }
431
432 ##############################################################################
433
434 # Save given line to perma-log
435 def save_line(time, chan, speaker, action, payload):
436 ## Put in DB:
437 try:
438 # Get index of THIS new line to be saved
439 last_idx = query_db(
440 '''select idx from loglines where chan=%s
441 and idx = (select max(idx) from loglines where chan=%s) ;''',
442 [chan, chan], one=True)
443
444 # Was this chan unseen previously?
445 if last_idx == None:
446 cur_idx = NewChan_Idx # Then use the config'd start index
447 else:
448 cur_idx = last_idx['idx'] + 1 # Otherwise, get the next idx
449
450 logging.debug("Adding log line with index: %s" % cur_idx)
451
452 # Set up the insert
453 exec_db('''insert into loglines (idx, t, chan, era,
454 speaker, self, payload) values (%s, %s, %s, %s, %s, %s, %s) ; ''',
455 [cur_idx, time, chan, Era, speaker, action, payload])
456
457 # Fire
458 commit_db()
459 except Exception as e:
460 rollback_db()
461 logging.warning("DB add failed, rolled back.")
462 logging.exception(e)
463
464
465 # RE for finding log refs
466 logref_re = re.compile(Base_URL + """log\/([^/]+)/([^/]+)#(\d+)""")
467
468
469 # All valid received lines end up here
470 def eat_logline(user, chan, text, action):
471 # If somehow received line from channel which isn't in the set:
472 if chan not in Channels:
473 logging.warning(
474 "Received martian : '%s' : '%s'" % (chan, text))
475 return
476
477 # First, add the line to the log:
478 save_line(datetime.now(), chan, user, action, text)
479
480 # Then, see if the line was a command for this bot:
481 if text.startswith(Prefix):
482 cmd = text.partition(Prefix)[2].strip()
483 cmd = [x.strip() for x in cmd.split(' ', 1)]
484 if len(cmd) == 1:
485 arg = ""
486 else:
487 arg = cmd[1]
488 # Dispatch this command...
489 command = cmd[0]
490 logging.debug("Dispatching command '%s' with arg '%s'.." %
491 (command, arg))
492 func = Commands.get(command)
493 # If this command is undefined:
494 if func == None:
495 logging.debug("Invalid command: %s" % command)
496 # Utter the 'help' text as response to the sad command
497 cmd_help("", user, chan)
498 else:
499 # Is defined command, dispatch it:
500 func(arg, user, chan)
501 else:
502 # Finally, see if contains log refs:
503 for ref in re.findall(logref_re, text):
504 ref_chan, ref_date, ref_idx = ref
505 # Find this line in DB:
506 ref_line = query_db(
507 '''select t, speaker, payload from loglines
508 where chan=%s and idx=%s;''',
509 [ref_chan, ref_idx], one=True)
510 # If retrieved line is valid, echo it:
511 if ref_line != None:
512 time_txt = ref_line['t'].strftime(Date_Long_Format)
513 my_line = "Logged on %s %s: %s" % (time_txt,
514 ref_line['speaker'],
515 ref_line['payload'])
516 # Speak the line echo into the chan where ref was seen
517 speak(chan, my_line)
518
519 ##############################################################################
520
521 # IRCate; if disconnected, reconnect
522 def run():
523 while 1:
524 irc()
525 logging.warning("Disconnected, will reconnect...")
526
527 ##############################################################################
528
529 # Run continuously.
530 run()
531
532 ##############################################################################