summary refs log blame commit diff stats
path: root/tests/stdlib/tircbot.nim
blob: f0417c7ac6bae36de3909d119ae830d57639688d (plain) (tree)

































                                                              
                                                                       



                                                  
                                                                     
















































































































































                                                                              
                     















                                                                  
                       


























                                                                
                                                                              



























                                                               
                            













                                                                            
                                                    

                                           
                                                  


                              
                                                    





























                                                                     
                                                 















                                                   





                             


                                       
                                                                 
                
                         
                    
                                          














                                                              
                                                  


                            

                                          











                                                         
                           
















































                                                                               
                       

                                              


                                  
  

                    
            
                                        
                           
                              
                                                                             
                                                        

                                        
 

                         





                                                                       
import irc, sockets, asyncio, json, os, strutils, times, redis

type
  TDb* = object
    r*: TRedis
    lastPing: float

  TBuildResult* = enum
    bUnknown, bFail, bSuccess

  TTestResult* = enum
    tUnknown, tFail, tSuccess

  TEntry* = tuple[c: TCommit, p: seq[TPlatform]]
  
  TCommit* = object
    commitMsg*, username*, hash*: string
    date*: TTime

  TPlatform* = object
    buildResult*: TBuildResult
    testResult*: TTestResult
    failReason*, platform*: string
    total*, passed*, skipped*, failed*: biggestInt
    csources*: bool

const
  listName = "commits"
  failOnExisting = False

proc open*(host = "localhost", port: TPort): TDb =
  result.r = redis.open(host, port)
  result.lastPing = epochTime()

discard """proc customHSet(database: TDb, name, field, value: string) =
  if database.r.hSet(name, field, value).int == 0:
    if failOnExisting:
      assert(false)
    else:
      echo("[Warning:REDIS] ", field, " already exists in ", name)"""

proc updateProperty*(database: TDb, commitHash, platform, property,
                    value: string) =
  var name = platform & ":" & commitHash
  if database.r.hSet(name, property, value).int == 0:
    echo("[INFO:REDIS] '$1' field updated in hash" % [property])
  else:
    echo("[INFO:REDIS] '$1' new field added to hash" % [property])

proc globalProperty*(database: TDb, commitHash, property, value: string) =
  if database.r.hSet(commitHash, property, value).int == 0:
    echo("[INFO:REDIS] '$1' field updated in hash" % [property])
  else:
    echo("[INFO:REDIS] '$1' new field added to hash" % [property])

proc addCommit*(database: TDb, commitHash, commitMsg, user: string) =
  # Add the commit hash to the `commits` list.
  discard database.r.lPush(listName, commitHash)
  # Add the commit message, current date and username as a property
  globalProperty(database, commitHash, "commitMsg", commitMsg)
  globalProperty(database, commitHash, "date", $int(getTime()))
  globalProperty(database, commitHash, "username", user)

proc keepAlive*(database: var TDb) =
  ## Keep the connection alive. Ping redis in this case. This functions does
  ## not guarantee that redis will be pinged.
  var t = epochTime()
  if t - database.lastPing >= 60.0:
    echo("PING -> redis")
    assert(database.r.ping() == "PONG")
    database.lastPing = t
    
proc getCommits*(database: TDb,
                 plStr: var seq[string]): seq[TEntry] =
  result = @[]
  var commitsRaw = database.r.lrange("commits", 0, -1)
  for c in items(commitsRaw):
    var commit: TCommit
    commit.hash = c
    for key, value in database.r.hPairs(c):
      case normalize(key)
      of "commitmsg": commit.commitMsg = value
      of "date": commit.date = TTime(parseInt(value))
      of "username": commit.username = value
      else:
        echo(key)
        assert(false)

    var platformsRaw = database.r.lrange(c & ":platforms", 0, -1)
    var platforms: seq[TPlatform] = @[]
    for p in items(platformsRaw):
      var platform: TPlatform
      for key, value in database.r.hPairs(p & ":" & c):
        case normalize(key)
        of "buildresult":
          platform.buildResult = parseInt(value).TBuildResult
        of "testresult":
          platform.testResult = parseInt(value).TTestResult
        of "failreason":
          platform.failReason = value
        of "total":
          platform.total = parseBiggestInt(value)
        of "passed":
          platform.passed = parseBiggestInt(value)
        of "skipped":
          platform.skipped = parseBiggestInt(value)
        of "failed":
          platform.failed = parseBiggestInt(value)
        of "csources":
          platform.csources = if value == "t": true else: false
        else:
          echo(normalize(key))
          assert(false)
      
      platform.platform = p
      
      platforms.add(platform)
      if p notin plStr:
        plStr.add(p)
    result.add((commit, platforms))

proc commitExists*(database: TDb, commit: string, starts = false): bool =
  # TODO: Consider making the 'commits' list a set.
  for c in items(database.r.lrange("commits", 0, -1)):
    if starts:
      if c.startsWith(commit): return true
    else:
      if c == commit: return true
  return false

proc platformExists*(database: TDb, commit: string, platform: string): bool =
  for p in items(database.r.lrange(commit & ":" & "platforms", 0, -1)):
    if p == platform: return true

proc expandHash*(database: TDb, commit: string): string =
  for c in items(database.r.lrange("commits", 0, -1)):
    if c.startsWith(commit): return c
  assert false

proc isNewest*(database: TDb, commit: string): bool =
  return database.r.lIndex("commits", 0) == commit

proc getNewest*(database: TDb): string =
  return database.r.lIndex("commits", 0)

proc addPlatform*(database: TDb, commit: string, platform: string) =
  assert database.commitExists(commit)
  assert (not database.platformExists(commit, platform))
  var name = platform & ":" & commit
  if database.r.exists(name):
    if failOnExisting: quit("[FAIL] " & name & " already exists!", 1)
    else: echo("[Warning] " & name & " already exists!")

  discard database.r.lPush(commit & ":" & "platforms", platform)

proc `[]`*(p: seq[TPlatform], name: string): TPlatform =
  for platform in items(p):
    if platform.platform == name:
      return platform
  raise newException(EInvalidValue, name & " platforms not found in commits.")
  
proc contains*(p: seq[TPlatform], s: string): bool =
  for i in items(p):
    if i.platform == s:
      return True
    

type
  PState = ref TState
  TState = object of TObject
    dispatcher: PDispatcher
    sock: PAsyncSocket
    ircClient: PAsyncIRC
    hubPort: TPort
    database: TDb
    dbConnected: bool

  TSeenType = enum
    PSeenJoin, PSeenPart, PSeenMsg, PSeenNick, PSeenQuit
  
  TSeen = object
    nick: string
    channel: string
    timestamp: TTime
    case kind*: TSeenType
    of PSeenJoin: nil
    of PSeenPart, PSeenQuit, PSeenMsg:
      msg: string
    of PSeenNick:
      newNick: string

const
  ircServer = "irc.freenode.net"
  joinChans = @["#nimrod"]
  botNickname = "NimBot"

proc setSeen(d: TDb, s: TSeen) =
  discard d.r.del("seen:" & s.nick)

  var hashToSet = @[("type", $s.kind.int), ("channel", s.channel),
                    ("timestamp", $s.timestamp.int)]
  case s.kind
  of PSeenJoin: discard
  of PSeenPart, PSeenMsg, PSeenQuit:
    hashToSet.add(("msg", s.msg))
  of PSeenNick:
    hashToSet.add(("newnick", s.newNick))
  
  d.r.hMSet("seen:" & s.nick, hashToSet)

proc getSeen(d: TDb, nick: string, s: var TSeen): bool =
  if d.r.exists("seen:" & nick):
    result = true
    s.nick = nick
    # Get the type first
    s.kind = d.r.hGet("seen:" & nick, "type").parseInt.TSeenType
    
    for key, value in d.r.hPairs("seen:" & nick):
      case normalize(key)
      of "type":
        #s.kind = value.parseInt.TSeenType
      of "channel":
        s.channel = value
      of "timestamp":
        s.timestamp = TTime(value.parseInt)
      of "msg":
        s.msg = value
      of "newnick":
        s.newNick = value

template createSeen(typ: TSeenType, n, c: string): stmt {.immediate, dirty.} =
  var seenNick: TSeen
  seenNick.kind = typ
  seenNick.nick = n
  seenNick.channel = c
  seenNick.timestamp = getTime()

proc parseReply(line: string, expect: string): Bool =
  var jsonDoc = parseJson(line)
  return jsonDoc["reply"].str == expect

proc limitCommitMsg(m: string): string =
  ## Limits the message to 300 chars and adds ellipsis.
  var m1 = m
  if NewLines in m1:
    m1 = m1.splitLines()[0]
  
  if m1.len >= 300:
    m1 = m1[0..300]

  if m1.len >= 300 or NewLines in m: m1.add("... ")

  if NewLines in m: m1.add($m.splitLines().len & " more lines")

  return m1

proc handleWebMessage(state: PState, line: string) =
  echo("Got message from hub: " & line)
  var json = parseJson(line)
  if json.hasKey("payload"):
    for i in 0..min(4, json["payload"]["commits"].len-1):
      var commit = json["payload"]["commits"][i]
      # Create the message
      var message = ""
      message.add(json["payload"]["repository"]["owner"]["name"].str & "/" &
                  json["payload"]["repository"]["name"].str & " ")
      message.add(commit["id"].str[0..6] & " ")
      message.add(commit["author"]["name"].str & " ")
      message.add("[+" & $commit["added"].len & " ")
      message.add("±" & $commit["modified"].len & " ")
      message.add("-" & $commit["removed"].len & "]: ")
      message.add(limitCommitMsg(commit["message"].str))

      # Send message to #nimrod.
      state.ircClient.privmsg(joinChans[0], message)
  elif json.hasKey("redisinfo"):
    assert json["redisinfo"].hasKey("port")
    #let redisPort = json["redisinfo"]["port"].num
    state.dbConnected = true

proc hubConnect(state: PState)
proc handleConnect(s: PAsyncSocket, state: PState) =
  try:
    # Send greeting
    var obj = newJObject()
    obj["name"] = newJString("irc")
    obj["platform"] = newJString("?")
    state.sock.send($obj & "\c\L")

    # Wait for reply.
    var line = ""
    sleep(1500)
    if state.sock.recvLine(line):
      assert(line != "")
      doAssert parseReply(line, "OK")
      echo("The hub accepted me!")
    else:
      raise newException(EInvalidValue,
                         "Hub didn't accept me. Waited 1.5 seconds.")
    
    # ask for the redis info
    var riobj = newJObject()
    riobj["do"] = newJString("redisinfo")
    state.sock.send($riobj & "\c\L")
    
  except EOS:
    echo(getCurrentExceptionMsg())
    s.close()
    echo("Waiting 5 seconds...")
    sleep(5000)
    state.hubConnect()

proc handleRead(s: PAsyncSocket, state: PState) =
  var line = ""
  if state.sock.recvLine(line):
    if line != "":
      # Handle the message
      state.handleWebMessage(line)
    else:
      echo("Disconnected from hub: ", OSErrorMsg())
      s.close()
      echo("Reconnecting...")
      state.hubConnect()
  else:
    echo(OSErrorMsg())

proc hubConnect(state: PState) =
  state.sock = AsyncSocket()
  state.sock.connect("127.0.0.1", state.hubPort)
  state.sock.handleConnect =
    proc (s: PAsyncSocket) =
      handleConnect(s, state)
  state.sock.handleRead =
    proc (s: PAsyncSocket) =
      handleRead(s, state)

  state.dispatcher.register(state.sock)

proc handleIrc(irc: PAsyncIRC, event: TIRCEvent, state: PState) =
  case event.typ
  of EvConnected: discard
  of EvDisconnected:
    while not state.ircClient.isConnected:
      try:
        state.ircClient.connect()
      except:
        echo("Error reconnecting: ", getCurrentExceptionMsg())
      
      echo("Waiting 5 seconds...")
      sleep(5000)
    echo("Reconnected successfully!")
  of EvMsg:
    echo("< ", event.raw)
    case event.cmd
    of MPrivMsg:
      let msg = event.params[event.params.len-1]
      let words = msg.split(' ')
      template pm(msg: string): stmt =
        state.ircClient.privmsg(event.origin, msg)
      case words[0]
      of "!ping": pm("pong")
      of "!lag":
        if state.ircClient.getLag != -1.0:
          var lag = state.ircClient.getLag
          lag = lag * 1000.0
          pm($int(lag) & "ms between me and the server.")
        else:
          pm("Unknown.")
      of "!seen":
        if words.len > 1:
          let nick = words[1]
          if nick == botNickname:
            pm("Yes, I see myself.")
          echo(nick)
          var seenInfo: TSeen
          if state.database.getSeen(nick, seenInfo):
            #var mSend = ""
            case seenInfo.kind
            of PSeenMsg:
              pm("$1 was last seen on $2 in $3 saying: $4" %
                    [seenInfo.nick, $seenInfo.timestamp,
                     seenInfo.channel, seenInfo.msg])
            of PSeenJoin:
              pm("$1 was last seen on $2 joining $3" %
                        [seenInfo.nick, $seenInfo.timestamp, seenInfo.channel])
            of PSeenPart:
              pm("$1 was last seen on $2 leaving $3 with message: $4" %
                        [seenInfo.nick, $seenInfo.timestamp, seenInfo.channel,
                         seenInfo.msg])
            of PSeenQuit:
              pm("$1 was last seen on $2 quitting with message: $3" %
                        [seenInfo.nick, $seenInfo.timestamp, seenInfo.msg])
            of PSeenNick:
              pm("$1 was last seen on $2 changing nick to $3" %
                        [seenInfo.nick, $seenInfo.timestamp, seenInfo.newNick])
            
          else:
            pm("I have not seen " & nick)
        else:
          pm("Syntax: !seen <nick>")

      # TODO: ... commands

      # -- Seen
      # Log this as activity.
      createSeen(PSeenMsg, event.nick, event.origin)
      seenNick.msg = msg
      state.database.setSeen(seenNick)
    of MJoin:
      createSeen(PSeenJoin, event.nick, event.origin)
      state.database.setSeen(seenNick)
    of MPart:
      createSeen(PSeenPart, event.nick, event.origin)
      let msg = event.params[event.params.high]
      seenNick.msg = msg
      state.database.setSeen(seenNick)
    of MQuit:
      createSeen(PSeenQuit, event.nick, event.origin)
      let msg = event.params[event.params.high]
      seenNick.msg = msg
      state.database.setSeen(seenNick)
    of MNick:
      createSeen(PSeenNick, event.nick, "#nimrod")
      seenNick.newNick = event.params[0]
      state.database.setSeen(seenNick)
    else:
      discard # TODO: ?

proc open(port: TPort = TPort(5123)): PState =
  var res: PState
  new(res)
  res.dispatcher = newDispatcher()
  
  res.hubPort = port
  res.hubConnect()
  let hirc =
    proc (a: PAsyncIRC, ev: TIRCEvent) =
      handleIrc(a, ev, res)
  # Connect to the irc server.
  res.ircClient = AsyncIrc(ircServer, nick = botNickname, user = botNickname,
                 joinChans = joinChans, ircEvent = hirc)
  res.ircClient.connect()
  res.dispatcher.register(res.ircClient)

  res.dbConnected = false
  result = res

var state = tircbot.open() # Connect to the website and the IRC server.

while state.dispatcher.poll():
  if state.dbConnected:
    state.database.keepAlive()