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