cirrus/cirrus.nim

341 lines
10 KiB
Nim

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