import std/[base64, json, jsonutils, net, os, sequtils, strutils, tables] type Admin* = tuple user: string pass: string Server* = tuple host: string port: int user: string pass: string nick: string channels: seq[string] codePrefix: string admins: seq[Admin] ServerState* = tuple host: string sock: Socket isAuth: bool channels: seq[string] codePrefix: string nick: string admins: seq[Admin] State* = seq[ServerState] Message* = tuple code: string codeParams: seq[string] sender: string recipient: string replyTo: string body: string IrcConfig* = tuple debug: bool servers: seq[Server] SvcCode* = tuple code: string handler: string SvcCodes* = seq[SvcCode] # Look for .config directory in the bot executable's directory # or in $HOME/.config/cirrus var ircConfigDir*: string if dirExists(unixToNativePath(getAppDir() & "/.config")): ircConfigDir = getAppDir() & "/.config/" else: ircConfigDir = getConfigDir() & "cirrus/" var ircConfigFile* = ircConfigDir & "config.json" ircCodeFile* = ircConfigDir & "codes.json" ircDebug* = false serviceCodes*: SvcCodes handlers* = initTable[string, proc(st: ServerState, msg: Message): void]() # Echo string to stdout. proc debug*(tag: string, str: string): void = if ircDebug: echo join(["[debug]", "[", tag, "] ", str]) # Load JSON config file and return a config tuple of keys and values. proc loadIrcConfig*(file = ircConfigFile): IrcConfig = if fileExists(unixToNativePath(file)): try: var nodes = parseFile(unixToNativePath(file)) conf = to(nodes, IrcConfig) if conf.debug.type() is bool: ircDebug = conf.debug return conf except IOError: debug("loadIrcConfig", "error: could not read config file " & file) except JsonParsingError: debug("loadIrcConfig", "error: invalid JSON in config file " & file) else: debug("loadIrcConfig", "error: cannot find config file " & file) # Connect to a server at the given host and port. Return a Socket object if # successful, or output an error otherwise. proc connectServer*(host: string, port: int, useSSL = true): Socket = var sock = newSocket() if useSSL: var ctx = newContext(verifyMode = CVerifyNone) wrapSocket(ctx, sock) try: connect(sock, host, Port(port)) except OSError: debug("connectServer", "error: cannot connect to the server " & host & "/" & intToStr(port)) return sock # Wrap net's send() with carriage return and debug echo. proc sendServer*(sock: Socket, str: string): void = send(sock, str & "\r\n") debug("sendServer", str) # Wrap net's recvLine() with debug echo. proc recvServer*(sock: Socket, debug = true): string = var str = recvLine(sock) debug("recvServer", str) return str # Send an IRC-formatted message to the server for a channel or user. proc message*(sock: Socket, recipient: string, str: string): void = sendServer(sock, join(["PRIVMSG ", recipient, " :", str])) # Disconnect from a server with an optional quit message. proc disconnectServer*(sock: Socket, quitMsg = "Bye."): void = sendServer(sock, "QUIT :" & quitMsg) close(sock) # Authenticate to a server. Authentication modes: sasl-plain (default), # nickserv. proc authServer*(sock: Socket, user: string, pass: string, nick: string, mode = "sasl-plain"): bool = var resp: string case mode of "sasl-plain": sendServer(sock, "CAP LS 302") resp = recvServer(sock) if find(resp, "sasl") != -1: sendServer(sock, join(["USER", user, "0 *", user], " ")) sendServer(sock, "NICK " & nick) sendServer(sock, "CAP REQ sasl") resp = recvServer(sock) if find(resp, "ACK") != 1: sendServer(sock, "AUTHENTICATE PLAIN") resp = recvServer(sock) if find(resp, "AUTHENTICATE +") != 1: # Format of the string to encode: "user\0user\0password" sendServer(sock, "AUTHENTICATE " & encode(join([user, "\0", user, "\0", pass]))) resp = recvServer(sock) if find(resp, "successful") != 1: sendServer(sock, "CAP END") sendServer(sock, "NICK " & nick) return true return false # PASS + Nickserv else: sendServer(sock, join(["PASS ", user, ":", pass])) sendServer(sock, join(["USER", user, "0 *", user], " ")) sendServer(sock, "NICK " & nick) sendServer(sock, join(["NickServ IDENTIFY", user, pass], " ")) resp = recvServer(sock) if find(resp, "logged in") != -1 or find(resp, "identified") != -1: return true return false # Respond to server pings to keep the socket connected. proc sendPong*(sock: Socket, str: string): void = if find(str, "PING") == 0: sendServer(sock, "PONG " & split(str, " ")[1]) # Join a list of channels on a server. proc joinChannels*(sock: Socket, chans: seq): void = if chans.len() == 1: sendServer(sock, "JOIN " & chans[0]) elif chans.len() > 1: sendServer(sock, "JOIN " & join(chans, ",")) else: debug("joinChannels", "warning: no channels joined, none found") # Given a code prefix and message string from a server, parse the message # string and return a message tuple of keys and values. The code prefix is set # for a given server instance in the config file. # # Initial received string format: # :[sender-nick]!~[sender-user]@domain.tld PRIVMSG [channel|recipient] :[body] proc parseMessage*(prefix: string, str: string): Message = var msg: Message strLen: int if find(str, "PRIVMSG") != -1 and find(str, ":" & prefix) != -1: # code strLen = split(str)[3].len() - 1 var cPLen = prefix.len() msg.code = split(str)[3][(1 + cPLen)..strLen] # codeParams if split(split(str, ":")[2]).len() > 1: strLen = split(str).len() - 1 msg.codeParams = split(str)[4..strLen] else: msg.codeParams = @[] # sender strLen = split(split(str, "!")[1], "@")[0].len() - 1 msg.sender = split(split(str, "!")[1], "@")[0][1..strLen] # recipient and replyTo msg.recipient = split(split(str, "PRIVMSG ")[1])[0] msg.replyTo = msg.sender if find(msg.recipient, "#") == 0: msg.replyTo = msg.recipient # body strLen = split(str).len() - 1 var bodyLen = join(split(str)[3..strLen], " ").len() - 1 msg.body = join(split(str)[3..strLen], " ")[1..bodyLen] return msg # Load JSON service code file to global variable `serviceCodes`. proc loadCodes*(file = ircCodeFile): void = if fileExists(unixToNativePath(file)): try: var nodes = parseFile(unixToNativePath(file)) serviceCodes = to(nodes, SvcCodes) except IOError: debug("loadCodes", "error: could not JSON file " & file) # Show error if the code file is missing fields except KeyError: debug("loadCodes", "error: invalid " & file) # Warn if a code has no handler linked to it for c in serviceCodes: if c.handler == "": debug("loadCodes", "warning: code " & c.code & " not added, no " & "handler found") else: debug("loadCodes", "error: cannot find service codes file") # Write service codes to a JSON file. proc saveCodes*(codes: SvcCodes, file = ircCodeFile): void = writeFile(unixToNativePath(file), pretty(toJson(codes))) # Return a list of service code names given the code prefix. proc getCodeNames*(prefix: string, codes = serviceCodes): seq[string] = return map(serviceCodes, proc(c: SvcCode): string = prefix & c.code) # Check whether a user and pass matches an admin credentials. Return true # if there is a match or false otherwise. proc isAdmin*(admins: seq[Admin], user: string, pass: string): bool = for a in admins: if user == a.user and pass == a.pass: return true return false # Send a handler response to the server. proc runHandler*(prc: string, st: ServerState, msg: Message, hdl = handlers): void = if hasKey(hdl, prc): hdl[prc](st, msg) else: debug("runHandler", "warning: no corresponding handler found for " & prc) # Given a sequence of servers, connect and authenticate to each server, and # join pre-configured channels. Return a sequence of server states. proc initServers*(servers: seq[Server]): State = var states: State for svr in servers: var sock = connectServer(svr.host, svr.port) isAuth = authServer(sock, svr.user, svr.pass, svr.nick) add(states, (host: svr.host, sock: sock, isAuth: isAuth, channels: @[], codePrefix: svr.codePrefix, nick: svr.nick, admins: svr.admins)) if isAuth: joinChannels(sock, svr.channels) states[states.len() - 1].channels = svr.channels return states # Parse message strings received and pass them to handlers. proc listenCodes*(st: ServerState, str: string): void = var msg = parseMessage(st.codePrefix, str) for c in serviceCodes: if msg.code == c.code: runHandler(c.handler, st, msg) break # Initialise a loop and add listeners to respond to service codes. proc listen*(states: seq[ServerState]): void = while true: var statesBuf = states count = 0 for st in statesBuf: try: var recv = recvServer(st.sock) sendPong(st.sock, recv) listenCodes(st, recv) except AssertionDefect: # Remove server from the states sequence delete(statesBuf, count) # Exit if there was only one server connection if states.len() == 1: quit() count += 1 # Pre-configured handlers # ---------------------------------------------------------------------------- # Show a greeting message with a list of service codes. proc hdlIrcHelp*(st: ServerState, msg: Message): void = var names = getCodeNames(st.codePrefix) help = "Hello, I'm a bot. I respond to the following codes: " & join(names, ", ") message(st.sock, msg.replyTo, help) # Disconnect from the current server. proc hdlIrcQuit*(st: ServerState, msg: Message): void = if msg.codeParams.len() == 1: if isAdmin(st.admins, msg.sender, msg.codeParams[0]): disconnectServer(st.sock) else: message(st.sock, msg.replyTo, "Unauthorised user or password.") else: message(st.sock, msg.replyTo, "Unauthorised user or password.") # Init # ---------------------------------------------------------------------------- handlers["hdlIrcHelp"] = hdlIrcHelp handlers["hdlIrcQuit"] = hdlIrcQuit