341 lines
10 KiB
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
|