# author: deadc0de6
# contact: https://github.com/deadc0de6
#
# python IRC library
#
# Copyright (C) 2015 deadc0de6
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# ref:
#   RFC 2812: https://tools.ietf.org/html/rfc2812
#   https://www.alien.net.au/irc/irc2numerics.html
#

import socket
import ssl
import sys
import os
import time
import datetime
import string
import random
import threading
import signal
import select
import Queue

class libirc():
  BUFLEN = 65535 # buffer to read IRC server message
  DEBUG = False
  LOGCHAT_NBLINE = 100 # per channel
  CONN_TIMEOUT = 5 # second
  SELECTTO = 1
  ISOPER = False
  SSL_CERT_PATH = '/etc/ssl/certs/ca-certificates.crt'

  IRC_CMD_QUIT = 'quit'
  IRC_CMD_PASS = 'pass'
  IRC_CMD_JOIN = 'join' # <channel>
  IRC_CMD_USER = 'user' # <username> <hostname> <servername> <realname>
  IRC_CMD_NICK = 'nick' # <nickname>
  IRC_CMD_PRIVMSG = 'privmsg' # <user> <message>
  IRC_CMD_PING = 'ping'
  IRC_CMD_PONG = 'pong'
  IRC_CMD_LIST = 'list'
  IRC_CMD_OPER = 'oper'
  IRC_CMD_KICK = 'kick'
  IRC_CMD_KILL = 'kill'
  IRC_CMD_ERROR = 'error'
  IRC_CMD_NAMES = 'names'
  IRC_CMD_WHO = 'who'
  IRC_CMD_NOTICE = 'notice'
  IRC_CMD_CHANNEL = '321'
  IRC_CMD_ACHANNEL = '322'
  IRC_CMD_CHANNEL_END = '323'
  IRC_CMD_MOTD = '375'
  IRC_CMD_MTD_END = '376'
  IRC_ERR_NICK = '433'
  IRC_CMD_CHAN_NAMES = '353'
  IRC_CMD_OPER_OK = '381'

  # @host: irc server address
  # @port: irc server port
  # @ssl: use SSL (True) or not (False
  # @nick: the bot nick
  # @channel: the bot channel
  # @threaded: is it to be threaded
  # TODO really need the threaded option ??
  def __init__(self, host, port, ssl, nick, channel, threaded=True):
    self._server_host = host
    self._server_port = port
    self._server_ssl = ssl
    self._nick = nick
    self._orichannel = channel
    self._threaded = threaded # call to callback are threaded
    # some glob vars
    self._pwd = None # server password
    self._channels = [] # list of channels
    self._conn_channels = [] # list of channels
    self._nicks = [] # list of nicks on channels I'm on
    self._connected = False
    self._stop = False
    self._logchat = False
    self._logchat_logs = {} # for each channel name a list of exchange
    self._sndqueue = Queue.Queue()
    # callbacks
    self._irc_event_on_message = None
    self._irc_event_on_privmessage = None
    self._irc_event_on_disconnect = None
    self._irc_event_on_connect = None
    self._irc_event_on_new_channel = None
    self._irc_event_on_servping = None
    self._irc_event_on_othermsg = None
    self._irc_event_on_conn_channel = None
    # dirty hack as SIG are not catched by the KeyboardInterrupt
    signal.signal(signal.SIGINT, self._signal_handler)

  def set_password(self, pwd):
    self._pwd = pwd

  # enable/disable the log chat features
  # logs N lines of each channel it is on
  def irc_set_logchat(self, boolean):
    self._logchat = boolean

  def set_callback(self, on_privmessage = None, on_message = None,\
     on_disconnect = None, on_connect = None,\
     on_new_channel = None, on_servping = None, on_othermsg = None,\
     on_conn_chan = None):
    self._irc_event_on_privmessage = on_privmessage
    self._irc_event_on_message = on_message
    self._irc_event_on_disconnect = on_disconnect
    self._irc_event_on_connect = on_connect
    self._irc_event_on_new_channel = on_new_channel
    self._irc_event_on_servping = on_servping
    self._irc_event_on_othermsg = on_othermsg
    self._irc_event_on_conn_channel = on_conn_chan

  def irc_listchannels(self):
    if self._socket == None:
      return False
    self._irc_send_cmd(self.IRC_CMD_LIST, [])
    return True

  def irc_join_channel(self, chanstr):
    if self._socket == None:
      return False
    if not chanstr in self._conn_channels:
      self._irc_send_cmd(self.IRC_CMD_JOIN, [chanstr])
      self._conn_channels.append(chanstr)
    return True

  # send a message to all channel I'm on
  def irc_send_to_all_channels(self, msg):
    for chan in self._conn_channels:
      self._irc_send_cmd(self.IRC_CMD_PRIVMSG, [chan], [msg])

  # send a message to a channel
  def irc_channel_broadcast(self, chan, msg):
    if not self._connected:
      return False
    if not chan in self._conn_channels:
      return False
    self._irc_send_cmd(self.IRC_CMD_PRIVMSG, [chan], [msg])
    return True

  # send a private message to a user
  def irc_privmsg(self, user, msg):
    if not self._connected:
      return False
    for i in msg.splitlines():
      self._irc_send_cmd(self.IRC_CMD_PRIVMSG, [user], [i])
    return True

  # kill a user
  def irc_kill(self, user, reason):
    if not self._connected:
      return False
    if not self.ISOPER:
      return False
    self._irc_send_cmd(self.IRC_CMD_KILL, [user, reason])
    return True

  # kick user from all channels
  def irc_kick_all(self, user, reason):
    if not self._connected:
      return False
    if not self.ISOPER:
      return False
    self.irc_kick(user, '*', reason)
    return True

  # kick a user from a channel
  # syntax: kick <channel> <user> <reason>
  def irc_kick(self, user, channel, reason):
    if not self._connected:
      return False
    if not self.ISOPER:
      return False
    self._irc_send_cmd(self.IRC_CMD_KICK, [user, channel, reason])
    return True

  # do we know this user
  def irc_known_nick(self, user):
    return (user in self._nicks)

  # 0 means all line
  def irc_get_chan_logs(self, chan, nbline=0):
    nbline = int(nbline)
    if not self._logchat:
      return None
    if not chan in self._logchat_logs:
      return None
    if nbline == 0 or nbline > len(self._logchat_logs[chan]):
      return self._logchat_logs[chan]
    else:
      tmp = self._logchat_logs[chan][-nbline:]
      return tmp

  def _signal_handler(self, signum, frame):
    self._stop = True

  # timestamp to string
  def _ts2str(self, ts):
    return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))

  # print debug message
  def _irc_debug(self, txt):
    datestr = self._ts2str(time.time())
    sys.stderr.write("[IRC] <" + datestr + "> " + txt + "\n")

  # print error message
  def _irc_err(self, txt):
    sys.stderr.write("[IRC ERROR] " + txt)

  # this is the main loop reading from the socket
  def _irc_read_loop(self):
    success = True
    self._irc_debug('bot started (read loop started) ....')
    self._socket.setblocking(0)
    try:
      while not self._stop:
        try:
          wsocks = []
          rsocks = [self._socket]
          esocks = [self._socket]
          if not self._sndqueue.empty():
            wsocks.append(self._socket)
          iready,oready,eready = select.select(rsocks, wsocks, esocks,
            self.SELECTTO)
          if self._socket in eready:
            self._irc_debug('[ERROR] socket error in select')
          if self._socket in oready and not self._sndqueue.empty():
            data = self._sndqueue.get()
            self._socket.send(data)
          if self._socket in iready:
            data = self._socket.recv(self.BUFLEN)
            for line in data.splitlines():
              if not self._irc_handle_line(line):
                # not very resilient
                success = False
                break
            if not success:
              break
        except socket.error as serr:
          #pass
          # TODO
          self._irc_err('socket error')
          print serr
        except select.error as serr:
          #pass
          # TODO
          self._irc_err('select error')
          print serr
    except KeyboardInterrupt:
      print 'interrupted ...'
    self._irc_debug('bot ended (read loop exited)')
    return success

  # handle anything that appears on the IRC and that I see
  def _irc_handle_line(self, line):
    if line == None or line == '':
      return True
    #try:
    if self.DEBUG:
      self._irc_debug("<- " + line)

    # TODO from here
    msg = ircmsg(line)

    if msg.cmd == self.IRC_CMD_CHANNEL:
      # start of channel list
      self._irc_debug('start of channel listing')
    elif msg.cmd == self.IRC_CMD_CHANNEL_END:
      # end of channel list
      self._irc_debug('end of channel listing')
    elif msg.cmd == self.IRC_CMD_ACHANNEL:
      # new channel
      self._irc_debug('<- new channel: %s' % (msg.param[1]))
      self._channels.append(msg.param[1])
      if self._irc_event_on_new_channel != None:
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_new_channel,
            args=(msg.param[1],))
          t.start()
        else:
          self._irc_event_on_new_channel(name)
    elif msg.cmd == self.IRC_CMD_MTD_END:
      # end of MOTD
      self._irc_debug('joining channel: %s' % (self._orichannel))
      self._connected = True
      self.irc_join_channel(self._orichannel)
    elif msg.cmd == self.IRC_CMD_OPER_OK:
      self.ISOPER = True
    elif msg.cmd == self.IRC_ERR_NICK:
      # nick registration error
      self._nick = self._nick + "^"
      self._irc_debug('changing nick to: %s' % (self._nick))
      self._irc_send_cmd(self.IRC_CMD_NICK, [self._nick])
    elif msg.cmd == self.IRC_CMD_PING:
      # this is a PING from server
      self._irc_debug('<- \"ping\"')
      self._irc_send_cmd(self.IRC_CMD_PONG, [msg.src])
      if self._irc_event_on_servping != None:
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_servping)
          t.start()
        else:
          self._irc_event_on_servping()
    elif msg.cmd == self.IRC_CMD_PRIVMSG and msg.param[0] == self._nick:
      # this is a private message sent to myself
      self._irc_debug('<- private message to me')
      if self._irc_event_on_privmessage != None:
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_privmessage, args=(msg,))
          t.start()
        else:
          self._irc_event_on_privmessage(msg)
    elif msg.cmd == self.IRC_CMD_PRIVMSG and msg.param[0] != self._nick:
      # this is a message sent to a channel I'm on
      self._irc_debug('<- message in channel: %s' % (msg.param[0]))
      if self._irc_event_on_message != None:
        if self._logchat:
          self._irc_log(msg)
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_message, args=(msg,))
          t.start()
        else:
          self._irc_event_on_message(msg)
    elif msg.cmd == self.IRC_CMD_JOIN:
      if msg.user != self._nick:
        # another user join our channel
        self._nicks.append(msg.user)
      else:
        if msg.msg[0] in self._conn_channels:
          if self._irc_event_on_conn_channel != None:
            if self._threaded:
              t = threading.Thread(target=self._irc_event_on_conn_channel,
                args=(msg.msg[0],))
              t.start()
            else:
              self._irc_event_on_conn_channel(msg.msg[0])
    elif msg.cmd == self.IRC_CMD_CHAN_NAMES:
      # list of names in the channel
      for u in msg.msg:
        if not u in self._nicks and u != self._nick:
          self._nicks.append(u)
      self._irc_debug('nicks list updated: %s' % (','.join(self._nicks)))
    elif msg.cmd == self.IRC_CMD_ERROR:
      # oops some error
      self._irc_debug('ERROR ! (%s)' % (' '.join(msg.msg)))
      return False
    elif msg.cmd == self.IRC_CMD_NOTICE:
      # this is a notice
      self._irc_debug('notice received: %s %s' % (msg.param[0],
        ' '.join(msg.msg)))
      if self._irc_event_on_othermsg != None:
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_othermsg,
            args=(msg,))
          t.start()
        else:
          self._irc_event_on_othermsg(msg)
    else:
      # this is what's left
      #self._irc_debug('other message received: %s' % (msg.cmd))
      if self._irc_event_on_othermsg != None:
        if self._threaded:
          t = threading.Thread(target=self._irc_event_on_othermsg,
            args=(msg,))
          t.start()
        else:
          self._irc_event_on_othermsg(msg)
    #except Exception as msg:
    #  print msg
    #  exc_type, exc_obj, exc_tb = sys.exc_info()
    #  fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
    #  print exc_type, fname, exc_tb.tb_lineno
    #  return False
    return True

  def _irc_log(self, ircmsg):
    user = ircmsg.user
    chan = ircmsg.param[0]
    msg = ' '.join(ircmsg.msg)

    if chan in self._logchat_logs:
      tmp = self._logchat_logs[chan]
      if len(self._logchat_logs) > self.LOGCHAT_NBLINE:
        # remove one
        del tmp[0]
      tmp.append('[%s] %s' % (user, msg))
      self._logchat_logs[chan] = tmp
    else:
      l = ['[%s] %s' % (user, msg)]
      self._logchat_logs[chan] = l

  # send an IRC command to the server
  # if txt is true, it accepts space in message (last part)
  # usually for command use txt=False and for message use txt=True
  def _irc_send_cmd(self, cmd, params, msg=[], log=True):
    if self._socket == None:
      return False
    data = '%s %s' % (cmd, ' '.join(params))
    if msg != []:
      data += ' :%s' % (' '.join(msg))
    data += '\n'
    if log:
      self._irc_debug('-> \"%s\"' % (data.rstrip()))
    self._sndqueue.put(data)
    return True

  # connect to IRC server
  def irc_connect(self):
    sock = None
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
      if self._server_ssl:
        # https://docs.python.org/2/library/ssl.html
        sock = ssl.wrap_socket(s, server_side=False)
      else:
        sock = s
      sock.settimeout(self.CONN_TIMEOUT)
      sock.connect((self._server_host, int(self._server_port)))
      sock.settimeout(None)
    except socket.error as err:
      self._irc_err("Connection error: ")
      print err
      if self._server_ssl:
        sock.unwrap()
      sock.close()
      return False

    self._irc_debug("IRC socket connected!")
    self._socket = sock

    if self._irc_event_on_connect != None:
      self._irc_debug('calling connect callback ...')
      if self._threaded:
        t = threading.Thread(target=self._irc_event_on_connect)
        t.start()
      else:
        self._irc_event_on_connect()

    #self._irc_debug('calling read-loop ...')
    #t = threading.Thread(target=self._irc_read_loop)
    #t.start()

    # conn and nick
    time.sleep(1)
    self._irc_debug('sending nick and other information to the server ...')
    self._irc_send_cmd(self.IRC_CMD_PASS, [self._pwd], log=False)
    self._irc_send_cmd(self.IRC_CMD_USER, [self._nick, self._nick, self._nick], [self._nick], log=False)
    self._irc_send_cmd(self.IRC_CMD_NICK, [self._nick], log=False)

    # start the read loop
    self._irc_debug('calling read-loop ...')
    res = self._irc_read_loop()
    try:
      if self._server_ssl:
        self._socket.unwrap()
    except:
      pass
    self._socket.close()
    return res

  def irc_gooper(self, username, password):
    if username != '' and password != '':
      self._irc_debug('going operator ...')
      self._irc_send_cmd(self.IRC_CMD_OPER, [username, password], log=False)

  # disconnect to IRC server and close socket
  def irc_disconnect(self):
    # first send the quit command
    self._irc_debug('disconnecting ...')
    self._stop = True
    if self._socket != None or not self._connected:
      self._irc_debug("sending the IRC quit command !")
      self._irc_send_cmd(self.IRC_CMD_QUIT, [])

    # close the socket
    if self._socket != None:
      self._socket.close()

    self._irc_debug('calling disconnect callback...')
    if self._irc_event_on_disconnect != None:
      if self._threaded:
        t = threading.Thread(target=self._irc_event_on_disconnect)
        t.start()
      else:
        self._irc_event_on_disconnect()

class ircmsg():
  DEBUG = False
  def __init__(self, line):
    self._line = line
    self.user = '' # the user that sent the message
    self.src = '' # the address of the user (username@IP)
    self.cmd = '' # the IRC command in the message
    self.param = [] # the target of the message (#<channel> or user)
    self.msg = [] # the message
    self._parse_line()

  # this is a naive parser
  def _parse_line(self):
    # format:
    #   :<prefix> <command> <params> :<trailing>
    #try:
    # identify the parts
    if self.DEBUG:
      self._err('full line: \"%s\"' % (self._line))
    prefixend = 0
    trailingstart = len(self._line)
    if self._line.startswith(':'):
      # prefix
      dummy = self._line[1:]
      prefix = dummy.split(' ')[0]
      prefixend = len(prefix)+1
      if '!' in prefix:
        self.user = prefix.split('!')[0].lower()
        self.src = prefix.split('!')[1].lower()
      else:
        self.src = prefix
      if self.DEBUG:
        self._err('prefix: %s' % (prefix))
    if ' :' in self._line:
      # trailing
      trailing = self._line.split(' :')[1]
      trailingstart = self._line.index(' :')
      if self.DEBUG:
        self._err('trailing: %s' % (trailing))
      self.msg = [x.lower() for x in trailing.split(' ')]

    cmdparams = self._line[prefixend:trailingstart].strip()
    if self.DEBUG:
      self._err('cmdparams: %s' % (cmdparams))
    self.cmd = cmdparams.split(' ')[0].lower()
    self.param = [x.lower() for x in cmdparams.split(' ')[1:]]
    #except:
    #  self._err(self._line)

    if self.DEBUG:
      self.print_msg()

  def _err(self, string):
    sys.stderr.write('[IRCMSG] %s\n' % (string))

  def print_msg(self):
    self._err('Message (%s)' % (self._line))
    self._err('\tuser: %s' % (self.user))
    self._err('\tsrc: %s' % (self.src))
    self._err('\tcmd: %s' % (self.cmd))
    self._err('\tparam: %s' % (','.join(self.param)))
    self._err('\tmsg: %s' % (','.join(self.msg)))

