433 lines
14 KiB
Nim
433 lines
14 KiB
Nim
|
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
|