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 = [] } })