From 83bb66d94cde2c620377b3eea74d443b00e8d0b0 Mon Sep 17 00:00:00 2001 From: mio Date: Fri, 20 Jan 2023 18:31:02 +0000 Subject: [PATCH] Initial commit --- .gitignore | 2 + bots/javapool/.config-sample/codes.json | 74 +++ bots/javapool/.config-sample/config.json | 20 + .../.config-sample/grammar/8ball.json | 24 + .../.config-sample/grammar/burger.json | 57 +++ .../javapool/.config-sample/grammar/ping.json | 1 + bots/javapool/bot.nim | 12 + bots/javapool/chara.nim | 18 + bots/javapool/init/javapool-openrc | 6 + bots/javapool/init/javapool.service | 11 + cirrus.nim | 340 ++++++++++++++ modules/grammar.nim | 432 ++++++++++++++++++ modules/javapool.nim | 123 +++++ readme.md | 8 + 14 files changed, 1128 insertions(+) create mode 100644 .gitignore create mode 100644 bots/javapool/.config-sample/codes.json create mode 100644 bots/javapool/.config-sample/config.json create mode 100644 bots/javapool/.config-sample/grammar/8ball.json create mode 100644 bots/javapool/.config-sample/grammar/burger.json create mode 100644 bots/javapool/.config-sample/grammar/ping.json create mode 100644 bots/javapool/bot.nim create mode 100644 bots/javapool/chara.nim create mode 100755 bots/javapool/init/javapool-openrc create mode 100644 bots/javapool/init/javapool.service create mode 100644 cirrus.nim create mode 100644 modules/grammar.nim create mode 100644 modules/javapool.nim create mode 100644 readme.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c12062 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bots/*/bot +bots/*/.config diff --git a/bots/javapool/.config-sample/codes.json b/bots/javapool/.config-sample/codes.json new file mode 100644 index 0000000..09a96a0 --- /dev/null +++ b/bots/javapool/.config-sample/codes.json @@ -0,0 +1,74 @@ +[ + { + "code": "help", + "grammar": "", + "handler": "hdlCharaHelp", + "allowOverwrite": false + }, + { + "code": "learn", + "grammar": "", + "handler": "hdlGrammarLearn", + "allowOverwrite": false + }, + { + "code": "unlearn", + "grammar": "", + "handler": "hdlGrammarUnlearn", + "allowOverwrite": false + }, + { + "code": "forget", + "grammar": "", + "handler": "hdlGrammarUnlearn", + "allowOverwrite": false + }, + { + "code": "show", + "grammar": "", + "handler": "hdlGrammarShow", + "allowOverwrite": false + }, + { + "code": "ping", + "grammar": "ping.json", + "handler": "", + "allowOverwrite": false + }, + { + "code": "pool", + "grammar": "", + "handler": "hdlPoolLook", + "allowOverwrite": false + }, + { + "code": "pooladd", + "grammar": "", + "handler": "hdlPoolAdd", + "allowOverwrite": false + }, + { + "code": "poolls", + "grammar": "", + "handler": "hdlPoolList", + "allowOverwrite": false + }, + { + "code": "poolrm", + "grammar": "", + "handler": "hdlPoolRemove", + "allowOverwrite": false + }, + { + "code": "8ball", + "grammar": "8ball.json", + "handler": "", + "allowOverwrite": true + }, + { + "code": "burger", + "grammar": "burger.json", + "handler": "", + "allowOverwrite": true + } +] diff --git a/bots/javapool/.config-sample/config.json b/bots/javapool/.config-sample/config.json new file mode 100644 index 0000000..5e26ba8 --- /dev/null +++ b/bots/javapool/.config-sample/config.json @@ -0,0 +1,20 @@ +{ + "debug": false, + "servers": [ + { + "host": "ircserver.tld", + "port": 6697, + "user": "botUser", + "pass": "botPassword", + "nick": "botNick", + "channels": ["#channel1", "#channel2"], + "codePrefix": "!", + "admins": [ + { + "user": "adminUser", + "pass": "adminPass" + } + ] + } + ] +} diff --git a/bots/javapool/.config-sample/grammar/8ball.json b/bots/javapool/.config-sample/grammar/8ball.json new file mode 100644 index 0000000..61e359e --- /dev/null +++ b/bots/javapool/.config-sample/grammar/8ball.json @@ -0,0 +1,24 @@ +{ + "origin": [ + "it is certain", + "it is decidedly so", + "without a doubt", + "yes — definitely", + "you may rely on it", + "as I see it, yes", + "most likely", + "outlook good", + "yes", + "signs point to yes", + "reply hazy try again", + "ask again later", + "better not tell you now", + "cannot predict now", + "concentrate and ask again", + "don't count on it", + "my reply is no", + "my sources say no", + "outlook not so good", + "very doubtful" + ] +} diff --git a/bots/javapool/.config-sample/grammar/burger.json b/bots/javapool/.config-sample/grammar/burger.json new file mode 100644 index 0000000..fbfaf0e --- /dev/null +++ b/bots/javapool/.config-sample/grammar/burger.json @@ -0,0 +1,57 @@ +{ + "patty": [ + "coconut", + "nutty", + "possible", + "soy", + "tofu", + "veggie delight" + ], + "veggie": [ + "kimchi", + "jalapeño peppers", + "lettuce", + "onions", + "pickles", + "tomatoes" + ], + "cheese": [ + "vegan mozzarella cheese" + ], + "sauce": [ + "barbecue sauce", + "curry sauce", + "Dijon mustard", + "ketchup" + ], + "burger": [ + "#patty# burger with #veggie#, #veggie# and #cheese# in #sauce#", + "#patty# burger with #veggie#, #veggie# and #veggie# in #sauce#" + ], + "side": [ + "avocado salad", + "BBQ baked beans", + "BBQ potato chips", + "caesar salad", + "coleslaw", + "corn on the cob", + "corn salad", + "french fries", + "french potato salad", + "fried pickles", + "fried shishito peppers", + "a fruit bowl", + "vegan mac and cheese", + "onion rings", + "potato wedges", + "roasted tomatoes", + "sweet potato fries", + "a three-bean salad", + "tempura green beans", + "zucchini chips" + ], + "origin": [ + "Here, have a #burger#!", + "Here, have a #burger#, served with #side#!" + ] +} diff --git a/bots/javapool/.config-sample/grammar/ping.json b/bots/javapool/.config-sample/grammar/ping.json new file mode 100644 index 0000000..a6f746f --- /dev/null +++ b/bots/javapool/.config-sample/grammar/ping.json @@ -0,0 +1 @@ +{ "origin": ["pong!"] } diff --git a/bots/javapool/bot.nim b/bots/javapool/bot.nim new file mode 100644 index 0000000..46010e1 --- /dev/null +++ b/bots/javapool/bot.nim @@ -0,0 +1,12 @@ +import ../../cirrus +import ../../modules/[grammar, javapool] +import chara + + +# Initialise cirrus +var + config = loadIrcConfig() + states = initServers(config.servers) + +# Initialise grammar +listenGrammar(states) diff --git a/bots/javapool/chara.nim b/bots/javapool/chara.nim new file mode 100644 index 0000000..d20a6d5 --- /dev/null +++ b/bots/javapool/chara.nim @@ -0,0 +1,18 @@ +import std/[strutils, tables] +import ../../cirrus +from ../../modules/grammar import getGrammarNames + + +proc hdlCharaHelp*(st: ServerState, msg: Message): void = + var + names = getGrammarNames(st.codePrefix) + help = "Hi, I'm " & st.nick & ", a bot with random features. " & + "You can find my source code at https://git.tilde.town/mio/cirrus. " & + "I'll respond to these phrases: " & join(names, ", ") + message(st.sock, msg.replyTo, help) + + +when defined(nimHasUsed): + {.used.} + +handlers["hdlCharaHelp"] = hdlCharaHelp diff --git a/bots/javapool/init/javapool-openrc b/bots/javapool/init/javapool-openrc new file mode 100755 index 0000000..672fb92 --- /dev/null +++ b/bots/javapool/init/javapool-openrc @@ -0,0 +1,6 @@ +#!/sbin/openrc-run + +name="javapool" +command="$HOME/bin/cirrus/bots/javapool/bot" +pidfile="/var/run/javapool.pid" +start_stop_daemon_args="--background --make-pidfile" diff --git a/bots/javapool/init/javapool.service b/bots/javapool/init/javapool.service new file mode 100644 index 0000000..33e39c2 --- /dev/null +++ b/bots/javapool/init/javapool.service @@ -0,0 +1,11 @@ +[Unit] +Description=javapool — an IRC bot + +[Service] +WorkingDirectory=%h/bin/cirrus/bots/javapool +ExecStart=./bot +Restart=always +RestartSec=300 + +[Install] +WantedBy=default.target diff --git a/cirrus.nim b/cirrus.nim new file mode 100644 index 0000000..02e6272 --- /dev/null +++ b/cirrus.nim @@ -0,0 +1,340 @@ +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, "!") != -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 diff --git a/modules/grammar.nim b/modules/grammar.nim new file mode 100644 index 0000000..ed6b0d0 --- /dev/null +++ b/modules/grammar.nim @@ -0,0 +1,432 @@ +import std/[httpclient, json, jsonutils, net, os, random, sequtils, strutils, + tables] +import ../cirrus + + +type + GrammarCode* = tuple + code: string + grammar: string + handler: string + allowOverwrite: bool + + GrammarCodes* = seq[GrammarCode] + + Grammar* = OrderedTable[string, JsonNode] + + Grammars* = seq[tuple[code: string, grammar: Grammar]] + + +var + grammarDir = ircConfigDir & "grammar/" + codeFile = ircConfigDir & "codes.json" + grammars*: Grammars + grammarCodes*: GrammarCodes + reservedCodes* = @["help", "forget", "learn", "unlearn"] + pasteUrls* = @["https://ttm.sh"] + + +# Set the grammar and code file location. +proc setGrammarDir*(configDir = ircConfigDir, gramDir = grammarDir, + codes = codeFile): void = + ircConfigDir = configDir + grammarDir = gramDir + codeFile = codes + + +# Download a url and save to a file path. Return true if successful or false +# otherwise. +proc fetchUrl*(url: string, file: string): bool = + var + cl = newHttpClient(userAgent = defUserAgent, + sslContext = newContext(verifyMode = CVerifyPeer)) + data: string + try: + data = cl.getContent(url) + except OSError: + debug("fetchUrl", "error: cannot fetch url " & url) + except ValueError: + debug("fetchUrl", "error: no uri scheme found for " & url) + if data != "": + try: + writeFile(unixToNativePath(file), data) + return true + except IOError: + debug("fetchUrl", "error: could not write to file " & file) + return false + else: + debug("fetchUrl", "error: file saved from " & url & " has no contents") + return false + + +# Upload a grammar JSON file to a pastebin and return the paste url. +proc postGrammar*(file: string): string = + var + cl = newHttpClient(userAgent = defUserAgent, + sslContext = newContext(verifyMode = CVerifyPeer)) + data = newMultiPartData() + res: string + cl.headers = newHttpHeaders({"Content-Type": "application/json"}) + randomize() + var host = sample(pasteUrls) + try: + data.addFiles({"file": file}) + res = cl.postContent(host, multipart = data) + except OSError: + debug("postGrammar", "error: cannot post file " & file) + if res != "": + return strip(res) + else: + debug("postGrammar", "error: no response received from " & host) + return "" + + +# Load and return the grammar given a grammar JSON file path. +proc loadGrammar*(file: string): Grammar = + var gram: Grammar + try: + gram = getFields(parseFile(unixToNativePath(file))) + except IOError: + debug("loadGrammar", "error: could not read JSON file " & file) + except JsonParsingError: + debug("loadGrammar", "error: invalid JSON in file " & file) + return gram + + +# Check whether a code is writable. Return true if writable or false otherwise. +proc checkWritable*(name: string, codes = grammarCodes): bool = + # Check if the code is a reserved keyword + for c in reservedCodes: + if name == c: + return false + # Check if the code allows overwriting + for c in codes: + if name == c.code and c.allowOverwrite == false: + return false + return true + + +# Return the aliases of a given code name as a sequence and their indices, or +# the code name given an alias and its index. Return an empty sequence if none +# are found. Aliases are code names sharing the same grammar file or handler +# name. +proc lookupAliases*(name: string, ty = "aliases", codes = grammarCodes): + seq[(string, int)] = + var + grammar: string + handler: string + res: seq[(string, int)] + count = 0 + tyCount = 0 + for c in codes: + if name == c.code: + grammar = c.grammar + handler = c.handler + break + count += 1 + case ty + of "aliases": + for c in codes: + if grammar != "" and grammar == c.grammar and c.code != name: + add(res, (c.code, tyCount)) + elif handler != "" and handler == c.handler and c.code != name: + add(res, (c.code, tyCount)) + tyCount += 1 + else: + if grammar != "": + res = @[(split(grammar, ".")[0], count)] + else: + for c in codes: + # Take the first result found + if handler != "" and handler == c.handler and c.code != name: + add(res, (c.code, tyCount)) + break + tyCount += 1 + return res + + +# Return a list of service code names given the code prefix. +proc getGrammarNames*(prefix: string, codes = grammarCodes): seq[string] = + return map(grammarCodes, proc(c: GrammarCode): string = prefix & c.code) + + +# Given a service code name and grammars, return the associated grammar and the +# index of the grammar. If no grammar is found for the code, return an empty +# grammar and -1. +proc lookupGrammar*(code: string, grams = grammars): (Grammar, int) = + var + gram: Grammar + found = false + count = 0 + for g in grams: + if g.code == code: + gram = g.grammar + found = true + break + count += 1 + if found: + return (gram, count) + else: + return (gram, -1) + + +# Return a random origin string from a grammar. If the grammar origin array is +# empty, return an empty string. +proc getOrigin*(gram: Grammar): string = + if getOrDefault(gram, "origin") != nil: + var + line = getStr(sample(getElems(getOrDefault(gram, "origin")))) + spliced = false + by = "#" + check: seq[bool] + splice: string + word: string + exp: string + sub: seq[string] + # Convert splices to random selections from their respective blocks + while not spliced: + check = @[] + for w in split(line): + # Splices need to be at least 3 characters long, e.g. "#s#" + if w.len() >= 3: + # Allow for punctuation after a splice, e.g. "#block#," + if rfind(w, by) == w.len() - 2: + splice = w[0..(w.len() - 2)] + word = w[1..(w.len() - 3)] + else: + splice = w[0..(w.len() - 1)] + word = w[1..(w.len() - 2)] + if find(w, by) == 0 and (rfind(w, by) == (w.len() - 1) or + rfind(w, by) == (w.len() - 2)): + if getOrDefault(gram, word) != nil: + randomize() + exp = getStr(sample(getElems(getOrDefault(gram, word)))) + sub = split(line, splice, 1) + line = join([sub[0], exp, sub[1]]) + # Confirm all splices have been expanded + for w in split(line): + if (find(w, by) != 0 and (rfind(w, by) != 0 or rfind(w, by) != 1)) or + w.len() <= 2: + add(check, true) + else: + add(check, false) + if find(check, false) == -1: + spliced = true + return line + else: + return "" + + +# Load JSON service code file to global variable `grammarCodes`. +proc loadGrammarCodes*(file = codeFile): void = + if fileExists(unixToNativePath(file)): + var + nodes: JsonNode + grammar: OrderedTable[string, JsonNode] + try: + nodes = parseFile(unixToNativePath(file)) + except IOError: + debug("loadGrammar", "error: could not read file " & file) + try: + grammarCodes = to(nodes, GrammarCodes) + # Show error if the code index file is missing fields + except KeyError: + debug("loadGrammarCodes", "error: invalid code file " & file) + + # Fetch the grammar for each code and add it to the grammars tuple. A code + # can have either grammar or handler, not both. If both are defined, the + # grammar will be selected (see `listenCodes()`). + for c in grammarCodes: + if fileExists(unixToNativePath(grammarDir & c.grammar)): + grammar = loadGrammar(grammarDir & c.grammar) + add(grammars, (code: c.code, grammar: grammar)) + for a in lookupAliases(c.code): + add(grammars, (code: a[0], grammar: grammar)) + else: + debug("loadGrammarCodes", "warning: code " & c.code & + " not added, no grammar file or handler found") + else: + debug("loadGrammarCodes", "error: cannot find service codes file") + + +# Write service codes to a JSON file. +proc saveGrammarCodes*(codes: GrammarCodes, file = codeFile): void = + try: + writeFile(unixToNativePath(file), pretty(toJson(codes))) + except IOError: + debug("saveGrammarCodes", "error: could not write to file " & file) + + +# Add new grammar. +proc addGrammar*(sock: Socket, msg: Message): void = + if msg.codeParams.len() >= 2: + var nameSplit = split(msg.codeParams[0], ",") + if not checkWritable(nameSplit[0]): + message(sock, msg.replyTo, "Sorry, " & nameSplit[0] & + " is not writable. Please use another code.") + return + + var + aliases: seq[string] + file = grammarDir & nameSplit[0] & ".json" + urlFetched = fetchUrl(msg.codeParams[1], file) + grammar = loadGrammar(file) + origin = getOrigin(grammar) + if nameSplit.len() > 1: + var aliasesLen = nameSplit.len() - 1 + aliases = nameSplit[1..aliasesLen] + + # Add to the global codes and grammar tuples and save to the codes file + if origin != "" and urlFetched: + add(grammarCodes, (code: nameSplit[0], grammar: nameSplit[0] & ".json", + handler: "", allowOverwrite: true)) + add(grammars, (code: nameSplit[0], grammar: grammar)) + # Add aliases + for a in aliases: + if lookupGrammar(a)[1] == -1: + add(grammarCodes, (code: a, grammar: nameSplit[0] & ".json", + handler: "", allowOverwrite: true)) + add(grammars, (code: a, grammar: grammar)) + saveGrammarCodes(grammarCodes) + message(sock, msg.replyTo, "Learned.") + else: + message(sock, msg.replyTo, "Sorry, I didn't get that. " & + "The grammar file may be empty or invalid.") + # Remove the grammar file if the grammar is invalid + removeFile(unixToNativePath(file)) + else: + message(sock, msg.replyTo, "Sorry, I didn't get that. " & + "Please provide a code name and JSON url.") + + +# Remove grammar. Only codes that are writable can be removed. +proc removeGrammar*(sock: Socket, msg: Message): void = + if msg.codeParams.len() >= 1: + # Alias entries will follow the writable status of the code name entry + # since the aliases depend on the same grammar file, which can be removed + # if the code name entry is writable. + if lookupAliases(msg.codeParams[0], "code").len() == 0: + message(sock, msg.replyTo, "Sorry, I don't remember learning that.") + return + var name = lookupAliases(msg.codeParams[0], "code")[0][0] + if not checkWritable(name) and name != msg.codeParams[0]: + message(sock, msg.replyTo, join(["Sorry, ", msg.codeParams[0], + " (alias of ", name, ") cannot be removed", "because ", name, + " is not writable."])) + return + # Remove from the global codes and grammars tuples, save to the codes file + # and remove the grammar json + var aliases = lookupAliases(name) + for a in aliases: + delete(grammars, lookupGrammar(a[0])[1]) + delete(grammarCodes, a[1]) + delete(grammars, lookupGrammar(name)[1]) + delete(grammarCodes, lookupAliases(name, "code")[0][1]) + saveGrammarCodes(grammarCodes) + removeFile(unixToNativePath(grammarDir & name & ".json")) + message(sock, msg.replyTo, msg.codeParams[0] & "? What's that?") + else: + message(sock, msg.replyTo, "Sorry, I didn't get that. " & + "Please provide a code.") + + +# Show the paste url of a grammar code. +proc showGrammar*(sock: Socket, msg: Message): void = + var reply: string + if msg.codeParams.len() > 0: + var + names = map(grammarCodes, proc(c: GrammarCode): string = c.code) + files = map(grammarCodes, proc(c: GrammarCode): string = c.grammar) + url: string + file: string + for p in msg.codeParams: + if find(names, p) != -1: + file = files[find(names, p)] + if file != "": + url = postGrammar(grammarDir & file) + if url != "": + message(sock, msg.replyTo, url) + else: + reply = "No grammar linked to " & p + message(sock, msg.replyTo, reply) + else: + reply = "No grammar found for " & p + message(sock, msg.replyTo, reply) + else: + reply = "Please provide a code." + message(sock, msg.replyTo, reply) + + +# Send a grammar response to the server. +proc runGrammar*(gram: Grammar, sock: Socket, msg: Message): void = + var origin = getOrigin(gram) + if origin != "": + message(sock, msg.replyTo, origin) + else: + debug("runGrammar", "warning: no origin found in grammar") + + +# Parse message strings received and pass them to handlers. +proc listenGrammarCodes*(st: ServerState, str: string): void = + var msg = parseMessage(st.codePrefix, str) + for c in grammarCodes: + if msg.code == c.code: + if lookupGrammar(c.code)[1] != -1: + runGrammar(lookupGrammar(c.code)[0], st.sock, msg) + else: + runHandler(c.handler, st, msg) + break + + +# Initialise a loop and add listeners to respond to service codes. +proc listenGrammar*(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) + listenGrammarCodes(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 + + +# Handlers +# ---------------------------------------------------------------------------- + +proc hdlGrammarHelp*(st: ServerState, msg: Message): void = + var + names = getGrammarNames(st.codePrefix) + help = "Hello, I'm a tracery-inspired grammar bot. " & + "I respond to the following service codes: " & join(names, ", ") + message(st.sock, msg.replyTo, help) + + +proc hdlGrammarLearn*(st: ServerState, msg: Message): void = + addGrammar(st.sock, msg) + + +proc hdlGrammarShow*(st: ServerState, msg: Message): void = + showGrammar(st.sock, msg) + + +proc hdlGrammarUnlearn*(st: ServerState, msg: Message): void = + removeGrammar(st.sock, msg) + + +# Init +# ---------------------------------------------------------------------------- + +setGrammarDir() +loadGrammarCodes() +handlers["hdlGrammarHelp"] = hdlGrammarHelp +handlers["hdlGrammarLearn"] = hdlGrammarLearn +handlers["hdlGrammarShow"] = hdlGrammarShow +handlers["hdlGrammarUnlearn"] = hdlGrammarUnlearn diff --git a/modules/javapool.nim b/modules/javapool.nim new file mode 100644 index 0000000..01c31cf --- /dev/null +++ b/modules/javapool.nim @@ -0,0 +1,123 @@ +import std/[json, jsonutils, os, sequtils, strutils, tables] +import ../cirrus + + +type + PoolItem = tuple + name: string + desc: string + + JavaPool = seq[PoolItem] + +var + javaPool: JavaPool + poolFile* = ircConfigDir & "javapool.json" + + +# Save pool to pool config file. +proc savePool(file = poolFile, pool = javaPool): void = + try: + writeFile(unixToNativePath(file), pretty(toJson(pool))) + except IOError: + debug("savePool", "error: could not write to file " & file) + + +# Load pool config file. +proc loadPool(file = poolFile): JavaPool = + # If config file doesn't exist, create one + if not fileExists(unixToNativePath(file)): + savePool(file, @[]) + try: + var + nodes = parseFile(unixToNativePath(file)) + conf = to(nodes, JavaPool) + return conf + except IOError: + debug("loadPool", "error: could not read JSON file " & file) + except JsonParsingError: + debug("loadPool", "error: invalid JSON in file " & file) + + +# Given an item name, return its description and its index if the item is in +# the pool, or empty string and -1 otherwise. +proc lookupPoolItem(name: string, pool = javaPool): (string, int) = + var count = 0 + for i in pool: + if name == i.name: + return (desc: i.desc, index: count) + count += 1 + return (desc: "", index: -1) + + +# Handlers +# ---------------------------------------------------------------------------- + +# Show a list of pool items. +proc hdlPoolList*(st: ServerState, msg: Message): void = + var reply: string + if javaPool.len() == 0: + reply = "There are no items in the pool yet." + else: + reply = "There are " & intToStr(javaPool.len()) & " items in the pool: " & + join(map(javaPool, proc(i: PoolItem): string = i.name), ", ") + message(st.sock, msg.replyTo, reply) + + +# Show an item's description if it exists in the pool, or list the pool items +# if no item name was given in the message. +proc hdlPoolLook*(st: ServerState, msg: Message): void = + if msg.codeParams.len() > 0: + var reply: string + for p in msg.codeParams: + if lookupPoolItem(p)[1] != -1: + reply = join(["The ", p, " is ", lookupPoolItem(p)[0], "."]) + else: + reply = "There is no " & p & " in the pool." + message(st.sock, msg.replyTo, reply) + else: + hdlPoolList(st, msg) + + +# Add an item to the pool. +proc hdlPoolAdd*(st: ServerState, msg: Message): void = + var reply: string + if msg.codeParams.len() >= 2: + var desc = join(msg.codeParams[1..(msg.codeParams.len() - 1)], " ") + add(javaPool, (name: msg.codeParams[0], desc: desc)) + savePool() + reply = "Item " & msg.codeParams[0] & " added to the pool." + message(st.sock, msg.replyTo, reply) + else: + reply = "Usage: " & st.codePrefix & msg.code & " [name] [description]" + message(st.sock, msg.replyTo, reply) + + +# Remove an item from the pool. +proc hdlPoolRemove*(st: ServerState, msg: Message): void = + var reply: string + if msg.codeParams.len() >= 1: + for p in msg.codeParams: + if lookupPoolItem(p)[1] != -1: + delete(javaPool, lookupPoolItem(p)[1]) + reply = "Item " & p & " removed from the pool." + message(st.sock, msg.replyTo, reply) + else: + reply = "There is no " & p & " in the pool." + message(st.sock, msg.replyTo, reply) + savePool() + else: + reply = "Usage: " & st.codePrefix & msg.code & " [name]" + message(st.sock, msg.replyTo, reply) + + +# Init +# ---------------------------------------------------------------------------- + +when defined(nimHasUsed): + {.used.} + +javaPool = loadPool() +handlers["hdlPoolList"] = hdlPoolList +handlers["hdlPoolLook"] = hdlPoolLook +handlers["hdlPoolAdd"] = hdlPoolAdd +handlers["hdlPoolRemove"] = hdlPoolRemove diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ae75aa1 --- /dev/null +++ b/readme.md @@ -0,0 +1,8 @@ +# Readme + +Cirrus is a IRC bot script. + + +## License + +BSD-3