Files
testbot/src/index.mjs
2025-01-22 20:06:53 +00:00

259 lines
9.6 KiB
JavaScript

import irc from "irc-upd"
import * as childProcess from "child_process"
import syllables from "syllable"
import * as R from "ramda"
import pluralize from "pluralize"
const { HyperLogLog } = await import("hyperlolo")
import starsList from "../stars.json" with { type: "json" }
import dash from "lodash"
const { DB, SQL } = await import("./db.mjs")
console.log("connecting...")
var client = new irc.Client(process.argv[2] || "irc.osmarks.net", "testbot", {
channels: ["#a", "#botrobots", "#b"],
userName: "testbot",
encoding: "utf8",
port: 6667
});
let links = null
let linksResolve = null
const logEv = (ty, thing) => SQL`INSERT INTO thing_log (timestamp, type, thing) VALUES (${Date.now()}, ${ty}, ${thing})`.run()
const scanlinks = () => {
return new Promise(resolve => {
if (!linksResolve) {
client.send("LINKS")
linksResolve = [resolve]
links = []
} else {
linksResolve.push(resolve)
}
})
}
let sylhist = []
let mhist = []
// pluralize seems to drop unicode sometimes
// hackily cope with this
const safelyOperate = (s, fn) => {
let res = fn(s)
if (res.trim() === "") { return s }
return res
}
const handleOf = (thing, fn) => {
const ofsplt = /^(.*)\W+of\W+(.*)$/.exec(thing)
if (ofsplt) {
return `${fn(ofsplt[1])} of ${ofsplt[2]}`
} else {
return fn(thing)
}
}
const PLURALS = [
["spoonful", "spoonfuls"],
["index", "indices"],
["lackey", "lackeys"],
["abiogenesis", "abiogeneses"],
["sheaf", "sheaves"],
["goose", "geese"],
["$DEITY", "$DEITIES"],
["ox", "oxen"],
["Category:English irregular plurals", "Categories:English irregular plurals"],
["enigma", "enigmas"],
["radix", "radixes"],
["funny", "funnies"],
["octopus", "octopoda"],
["the place in the world which is northernmost", "the places in the world which are northernmost"],
["datum", "data"],
["testbot", "testbots"],
["notion of LLM use", "notions of LLM use"],
["oil of vitriol", "oil of vitriol"],
["noun phrase", "noun phrases"],
["information", "information"],
["This phrase pluralizes as 'yellows'", "These phrases pluralize as 'yellows'"],
["fireman", "firemen"],
["potions of cryoapiocity", "potions of cryoapiocity"],
["sulfuric acid", "sulfuric acid"],
["dollar", "dollars"],
["acid", "acid"],
["water", "water"],
]
const PLURALS_KEEP_SAME = [
"mice",
"computers",
"lackeys",
"dodecahedra",
"those things which must not be seen",
"announcements",
"rotations",
"spider-based countermeasures"
]
const SINGULARS_KEEP_SAME = [
"mouse",
"computer",
"lackey",
"dodecahedron",
"that thing which must not be seen",
"announcement",
"rotation",
"spider-based countermeasure"
]
const spliceIn = (xs, ys) => {
const locations = new Map(ys.map((y, i) => [Math.floor(xs.length * (i / ys.length)), y]))
const out = xs.map((x, i) => (locations.get(i) ? [locations.get(i), x] : [x]))
const realOut = []
for (const x of out) {
for (const y of x) {
realOut.push(y)
}
}
return realOut
}
const queryLLM = async prompt => {
const res = await fetch("https://gpt.osmarks.net/v1/completions", {
body: JSON.stringify({
"max_tokens": 40,
"stop": ["\n"],
prompt
}),
method: "POST",
headers: {"Content-type": "application/json"}
})
const data = await res.json()
return data["choices"][0]["text"]
}
const deriveLLMMapping = (things, splice, fallback) => async input => {
const prompt = spliceIn(things, splice).map(([a, b]) => `${a} :: ${b}`).join("\n") + "\n" + input + " ::"
try {
const res = await queryLLM(prompt)
return res.trim()
} catch (e) {
console.warn("switched to fallback", e)
return fallback(input)
}
}
const pluralizer = deriveLLMMapping(PLURALS, PLURALS_KEEP_SAME.map(x => ([x, x])), pluralize.plural)
const singularizer = deriveLLMMapping(PLURALS.map(([a, b]) => ([b, a])), SINGULARS_KEEP_SAME.map(x => ([x, x])), pluralize.singular)
const renderItem = async x => x.quantity === 1 ? x.thing : `${x.quantity} ${await pluralizer(x.thing)}`
client.addListener("message", async (nick, channel, message, ev) => {
const e = /^(?:<([^>]+)>|\x03\d{1,2}([^\[]+)\[[a-z]\]:\x0f) (.*)$/.exec(message)
if (e) {
nick = e[1] || e[2]
message = e[3]
}
const allStatsRaw = SQL`SELECT * FROM stats`.all().map(x => [x.stat, x.value])
const origStats = new Map(allStatsRaw)
const allStats = new Map(allStatsRaw)
allStats.set("messages", (allStats.get("messages") ?? 0) + 1)
const res = /^([A-Za-z0-9_-]+)[:, ]*([A-Za-z0-9_-]+) *(.*)$/.exec(message)
const uniqueUsersCounter = allStats.get("users") ? HyperLogLog.deserialize(allStats.get("users")) : new HyperLogLog({ hasherId: "jenkins32", precision: 5 })
uniqueUsersCounter.add(nick)
allStats.set("users", uniqueUsersCounter.serialize())
const uniqueMessagesCounter = allStats.get("uniqueMessages") ? HyperLogLog.deserialize(allStats.get("uniqueMessages")) : new HyperLogLog({ hasherId: "jenkins32", precision: 5 })
uniqueMessagesCounter.add(message)
allStats.set("uniqueMessages", uniqueMessagesCounter.serialize())
if (res) {
let [_, tnick, cmd, args] = res
tnick = tnick.toLowerCase()
args = args.trim().replace(/[.!?]$/, "")
cmd = cmd.toLowerCase()
if (tnick === client.nick) {
console.log(nick, cmd, channel)
allStats.set("commands", (allStats.get("commands") ?? 0) + 1)
if (cmd === "starch") {
client.say(channel, `Starch has been cancelled. We apologize to our valued customers for any inconvenience this may have caused.`)
} else if (cmd === "stats") {
client.say(channel, `${allStats.get("commands")} commands run, ${allStats.get("messages")} messages seen, about ${Math.floor(uniqueUsersCounter.count())} nicks seen, about ${Math.floor(uniqueMessagesCounter.count())} unique messages seen.`)
} else if (cmd === "help") {
client.say(channel, "Nobody can help you now.")
} else if (cmd === "fortune") {
childProcess.exec("fortune", (err, out) => {
if (err) { return console.warn(err) }
client.say(channel, out)
})
} else if (cmd === "cat") {
client.say(channel, "Meow.")
} else if (cmd === "servers") {
const links = await scanlinks()
client.say(channel, links.map(x => `${x[1]}: ${/^[0-9]+ (.*)$/.exec(x[3])[1]}`).join("\n"))
} else if (cmd === "take" || cmd === "have") {
const things = args.split(/(,| and )/).map(x => x.trim().replace(",", "").replace(/^(an?|the) /, "")).filter(x => x !== "" && x !== "and")
for (let thing of things) {
let qty = 1
const res = /^([-0-9.]+|some|many|half)\b\s*(.*)$/iu.exec(thing)
if (res) {
const [_, rrqty, rthing] = res
thing = rthing
const rqty = rrqty.toLowerCase()
if (rqty === "some") {
qty = Math.floor(Math.random() * 17)
} else if (rqty === "many") {
qty = Math.floor(Math.random() * 2300)
} else if (rqty === "half") {
qty = 0.5
} else {
qty = parseFloat(rqty)
}
}
thing = await singularizer(thing)
const currQty = SQL`SELECT * FROM inventory WHERE thing = ${thing}`.get()?.quantity
if (currQty) { qty += currQty }
qty = qty || 0
SQL`INSERT OR REPLACE INTO inventory VALUES (${thing}, ${Date.now()}, ${qty})`.run()
console.log("attained", qty, thing)
const hadThing = await renderItem({ thing, quantity: qty })
client.say(channel, `I have ${hadThing}.`)
}
} else if (cmd.startsWith("inv")) {
const inv = SQL`SELECT * FROM inventory ORDER BY obtained_at DESC LIMIT 10`.all()
for (const i of inv) {
client.say(channel, await renderItem(i))
}
} else if (cmd === "deploy") {
client.say(channel, `Deploying ${args}.`)
} else if (cmd === "stars") {
const shuf = dash.shuffle(starsList)
const res = shuf.slice(0, 5).join(", ")
client.say(channel, `${res}.`)
}
}
}
const messageContent = message.replace(/^(\s*[<\[][^\]>]+[>\]]\s*)+/, "")
sylhist = R.takeLast(3, R.append(syllables(messageContent), sylhist))
mhist = R.takeLast(50, R.append(messageContent, mhist))
if (R.equals(sylhist, [5, 7, 5])) {
client.say(channel, "Haiku detected!")
logEv("haiku", R.takeLast(3, mhist).join("\n"))
}
DB.transaction(() => {
for (const [k, v] of allStats) {
if (origStats.get(k) !== v) {
SQL`INSERT OR REPLACE INTO stats VALUES (${k}, ${v})`.run()
}
}
})()
})
client.addListener("raw", ev => {
if (ev.command === "rpl_links") {
links.push(ev.args)
} else if (ev.command === "rpl_endoflinks" && linksResolve) {
linksResolve.forEach(r => r(links))
linksResolve = null
links = []
}
})