This repository has been archived on 2024-05-06. You can view files and clone it, but cannot push or open issues/pull-requests.
cirrus/modules/grammar.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