summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.luacheckrc3
-rw-r--r--config.lua46
-rw-r--r--discord.lua24
-rw-r--r--discord/consts.lua45
-rw-r--r--discord/pylon.lua157
-rw-r--r--irc.lua119
-rw-r--r--irc/pylon.lua80
-rw-r--r--irc/rirc.lua (renamed from rirc.lua)7
-rw-r--r--log.lua46
-rw-r--r--main.lua117
-rw-r--r--message.lua19
-rw-r--r--nanochat/pylon.lua85
-rw-r--r--pprint.lua499
-rw-r--r--pylon.lua33
-rw-r--r--quaddle/pylon.lua103
-rw-r--r--queue.lua12
-rw-r--r--store.lua77
-rw-r--r--terminology.txt45
-rw-r--r--test.lua24
-rw-r--r--thing.lua25
-rw-r--r--todo.txt35
-rw-r--r--wilson.ini33
-rw-r--r--wilson.ini.example47
-rw-r--r--xml_old.lua48
-rw-r--r--xmpp.lua184
-rw-r--r--xmpp/base64.lua (renamed from base64.lua)0
-rw-r--r--xmpp/pylon.lua143
-rw-r--r--xmpp/sha1.lua (renamed from sha1.lua)0
-rw-r--r--xmpp/xml.lua (renamed from xml.lua)9
30 files changed, 1558 insertions, 509 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b9a9941
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+wilson.ini
+database.db
diff --git a/.luacheckrc b/.luacheckrc
new file mode 100644
index 0000000..ab1e00e
--- /dev/null
+++ b/.luacheckrc
@@ -0,0 +1,3 @@
+codes = true
+ignore = { "6..", "21.", "23.", "31.", "4.."}
+exclude_files = {"pprint.lua"}
diff --git a/config.lua b/config.lua
new file mode 100644
index 0000000..866382f
--- /dev/null
+++ b/config.lua
@@ -0,0 +1,46 @@
+
+local function kv_syntax(block,line)
+ local k,v = line:match"^([a-z0-9_-]+)%s*=%s*(.*)$"
+ assert(k,"syntax error in kv line: "..line)
+ k = k:gsub("-","_")
+ block[k]=v
+end
+
+local function tuple_syntax(pattern) return function(block,line)
+ local matches = {line:match(pattern)}
+ assert(matches[1], "syntax error in tuple line: "..line)
+ table.insert(block, matches)
+end end
+
+local schema = {
+ top = kv_syntax,
+ pylon = kv_syntax,
+ bus = tuple_syntax"^(%S+)%s+(%S+)$",
+}
+
+local function parse_file(file)
+ local blocks = {}
+ for k in pairs(schema) do blocks[k] = {} end
+ local line_handler = function(block,line) error("line outside of block") end
+ local block
+
+ for line in file:lines() do
+ line = line:gsub(";.*$",""):gsub("^%s*",""):gsub("%s*$","")
+ if line == '' then goto next end
+
+ local block_type, block_name = line:match"%[%s*(%S+)%s+(%S+)%s*%]"
+ if block_type then
+ local blocks_of_this_type = assert(blocks[block_type],"no such block type "..block_type)
+ block = {}
+ blocks_of_this_type[block_name] = block
+ line_handler = schema[block_type]
+ else
+ line_handler(block, line)
+ end
+ ::next::
+ end
+
+ return blocks
+end
+
+return {parse=parse_file}
diff --git a/discord.lua b/discord.lua
deleted file mode 100644
index 27e0644..0000000
--- a/discord.lua
+++ /dev/null
@@ -1,24 +0,0 @@
-local http_request = require'http.request'
-local dkjson = require'dkjson'
-local url = 'https://discord.com/api/v8/webhooks/1277689699254800436/gzYU3voeunQsEdC797-hgXdTIbJDk09IVuj6l1t2alqbX9xS0d_St7bDWRkagUuQ3sat'
-
-local function exec_webhook(url, payload, debug)
- local req = http_request.new_from_uri(url)
- req.headers:upsert('content-type','application/json')
- req.headers:upsert(':method','POST')
- req:set_body(dkjson.encode(payload))
- local resp_head, resp_body = assert(req:go())
- local status = resp_head:get':status'
- if debug then
- resp_head:dump()
- print()
- print(resp_body:get_body_as_string())
- end
- assert(status:sub(1,1) == '2', 'status was '..status..' not 2xx')
-end
-
-return { grom = function(from,body) print(body) exec_webhook(url, {
- content=body,
- username=from,
- }, true) end }
-
diff --git a/discord/consts.lua b/discord/consts.lua
new file mode 100644
index 0000000..4727ef6
--- /dev/null
+++ b/discord/consts.lua
@@ -0,0 +1,45 @@
+local function flip(t)
+for k,v in pairs(t) do t[v]=k end return t end
+
+local opcodes = flip {
+[0] = "dispatch",
+[1] = "heartbeat",
+[2] = "identify",
+[3] = "presence update",
+[4] = "voice state update",
+[6] = "resume",
+[7] = "reconnect",
+[8] = "request guild members",
+[9] = "invalid session",
+[10] = "hello",
+[11] = "heartbeat ack",
+[31] = "request soundboard sounds",
+}
+
+local intents = flip {
+guilds = 1 << 0,
+guild_members = 1 << 1,
+guild_moderation = 1 << 2,
+guild_expressions = 1 << 3,
+guild_integrations = 1 << 4,
+guild_webhooks = 1 << 5,
+guild_invites = 1 << 6,
+guild_voice_states = 1 << 7,
+guild_presences = 1 << 8,
+guild_messages = 1 << 9,
+guild_message_reactions = 1 << 10,
+guild_message_typing = 1 << 11,
+direct_messages = 1 << 12,
+direct_message_reactions = 1 << 13,
+direct_message_typing = 1 << 14,
+message_content = 1 << 15,
+guild_scheduled_events = 1 << 16,
+auto_moderation_configuration = 1 << 20,
+auto_moderation_execution = 1 << 21,
+guild_message_polls = 1 << 24,
+direct_message_polls = 1 << 25,
+}
+return {
+ opcodes = opcodes,
+ intents = intents,
+}
diff --git a/discord/pylon.lua b/discord/pylon.lua
new file mode 100644
index 0000000..6cebc16
--- /dev/null
+++ b/discord/pylon.lua
@@ -0,0 +1,157 @@
+local cqueues = require 'cqueues'
+local websocket = require'http.websocket'
+local request = require'http.request'
+local json = require 'dkjson'
+local class = require 'r.class'
+
+local BasePylon = require 'pylon'
+local Thing = require 'thing'
+local Message = require 'message'
+local consts = require 'discord.consts'
+local opcodes = consts.opcodes
+
+local API_BASE = "https://discord.com/api/v10/"
+
+local Discord = class.extend(BasePylon)
+function Discord.init(self)
+ self:_check_fields "token"
+ self.channel_to_webhook = {}
+end
+
+local function identify_payload(token)
+ local I = consts.intents return json.encode{
+ op = opcodes.identify, d = {
+ properties = {os="wilson",browser="wilson",device="wilson"},
+ intents = I.guilds + I.guild_messages + I.message_content,
+ token = token } } end
+
+function Discord._req(self, url, payload)
+ ::again::
+ local req = request.new_from_uri(API_BASE..url)
+ req.headers:upsert(':method',payload and 'POST' or 'GET')
+ req.headers:upsert('authorization','Bot '..self.token)
+ req.headers:upsert('user-agent','wilson (https://g.gh0.pw/wilson/, v0.0)')
+ if payload then
+ req.headers:upsert('content-type','application/json')
+ req:set_body(assert(json.encode(payload))) end
+
+ local head,body = assert(req:go())
+ local status = head:get':status'
+ local bod = body:get_body_as_string()
+
+ assert(status:match"^2" or status=="429", 'status was '..status..' body '..bod)
+
+ local rl_rem = tonumber(head:get'x-ratelimit-remaining')
+ local rl_wait = tonumber(head:get'x-ratelimit-reset-after')
+ self.log:debug('ratelimit',rl_rem,rl_wait)
+ if status == "429" then
+ self.log:warn("getting 429'd, waiting",rl_wait)
+ cqueues.sleep(rl_wait)
+ goto again
+ elseif rl_rem <= 3 then
+ local wait = rl_wait -- or rl_wait/(rl_rem+1)? their algorithm is mysterious
+ self.log:info("ratelimit limit getting low ",rl_rem,", waiting",wait)
+ if wait>5 then self.log:warn("isn't",wait,"a little long to be waiting?") end
+ cqueues.sleep(wait)
+ end
+
+ if #bod == 0 then return nil end
+ local val, _, err = json.decode(bod)
+ if err then error(err) else return val end
+end
+
+function Discord._connect(self)
+ local uri = "wss://gateway.discord.gg/?v=10&encoding=json"
+ self.ws = websocket.new_from_uri(uri)
+ assert(self.ws:connect())
+ self.ws:send(identify_payload(self.token), 'text')
+
+ local me = self:_req"users/@me"
+ self.bot_id = me.id
+ self.log('logged in as ',me.username)
+
+ for busname, bus in pairs(self.wilson.busses) do
+ for _, channel in ipairs(bus) do
+ if channel.pylon == self then
+ self:add_channel(channel)
+ end
+ end
+ end
+end
+
+function Discord.add_channel(self, channel)
+ local webhooks = self:_req("channels/"..channel.id.."/webhooks")
+ for _,wh in pairs(webhooks) do
+ if wh.application_id == self.bot_id then
+ self.log("Found existing webhook for channel",channel.id)
+ self.channel_to_webhook[channel.id] = wh
+ return
+ end
+ end
+ self.log("Making webhook for channel",channel.id)
+ local wh = self:_req("channels/"..channel.id.."/webhooks", {
+ name=self.name:gsub("discord",""):gsub("clyde","").." wilson hook" })
+ self.channel_to_webhook[channel.id] = wh
+end
+
+function Discord._heartbeating(self, interval_ms)
+ local interval = interval_ms / 1000
+ cqueues.sleep(interval * math.random())
+ while true do
+ self.ws:send(json.encode{
+ op = opcodes.heartbeat,
+ d = self.sequence_number,
+ })
+ cqueues.sleep(interval)
+ end
+end
+
+function Discord.recving(self)
+ for packet in self.ws:each() do
+ local event = json.decode(packet)
+ self.log:debug('event',event.s, event.op, event.t)
+ if false and event.op ~= opcodes.dispatch then
+ self.log:debug(event.d)
+ end
+
+ if event.s then self.sequence_number = event.s end
+
+ if event.op == opcodes.hello then
+ self.cq:wrap(self._heartbeating, self, event.d.heartbeat_interval)
+ elseif event.op == opcodes.dispatch then
+ self:handle_dispatch(event)
+ end
+ end
+end
+
+function Discord.handle_dispatch(self, event)
+ local d = event.d
+ if event.t == 'MESSAGE_CREATE' then
+ self.log:proto('<',d.channel_id,d.author.username,d.content)
+ if
+ self.channel_to_webhook[d.channel_id]
+ and d.author.id ~= self.channel_to_webhook[d.channel_id].id
+ and d.author.id ~= self.bot_id
+ then
+ self.wilson:deliver(Message {
+ pylon = self,
+ channel = Thing(self, d.channel_id, 'TODO discord channel names'),
+ author = Thing(self, d.author.id, d.author.username),
+ body = d.content,
+ })
+ end
+ end
+end
+
+function Discord.sending(self)
+ for dest_channel, message in self.inbox:iter() do
+ self.log:proto('>',dest_channel,message.author,message.body)
+ local wh = self.channel_to_webhook[dest_channel.id]
+ self:_req('webhooks/'..wh.id..'/'..wh.token, {
+ username = message.author:name_with_shortname(),
+ content = message.body,
+ })
+ end
+end
+
+return Discord
diff --git a/irc.lua b/irc.lua
deleted file mode 100644
index 541c725..0000000
--- a/irc.lua
+++ /dev/null
@@ -1,119 +0,0 @@
-local cqueues = require'cqueues'
-local socket = require'cqueues.socket'
-local pprint = require'pprint'
-local rirc = require'rirc'
-local Queue = require'queue'
-
-local Irc = {}
-
-function Irc._send(self, args)
- args.source = args.source or self.nodename
- local sent = rirc.send(self.sock, args)
- print('>', sent)
-end
-
-function Irc.makepylon(pylonname, conf, cq, network)
- local self = {
- pylonname=pylonname,
- cq = cq,
- network = network,
- inbox = Queue.make(),
- }
- local function conf_var(name)
- assert(conf[name] ~= nil, 'missing conf field '..name)
- self[name] = conf[name]
- end
- conf_var 'host'
- conf_var 'port'
- conf_var 'password'
- conf_var 'nodename'
-
- setmetatable(self, {__index=Irc})
- return self
-end
-
-function Irc._connect(self)
- self.sock = assert(socket.connect(self.host, self.port))
- self:_send{'PASS', self.password, '0210-IRC', 'wilson|'}
- self:_send{'SERVER', self.nodename, '1', 'i am wilson'}
-end
-
-function Irc.run(self)
- self:_connect()
- self.cq:wrap(self.recving, self)
- self.cq:wrap(self.sending, self)
-end
-
-function Irc.recving(self)
- for line in self.sock:lines "*l" do
- print('<', line)
- local msg = rirc.parse(line)
- if msg.op == 'PING' then
- self:_send{'PONG', msg.args[1]}
- elseif msg.op == 'PRIVMSG' then
- local channel = msg.args[1]
- local body = msg.args[2]
- local source = msg.source
- self.network:post(self.pylonname, channel, {
- body = body,
- source = source..'[i]'
- })
- end
- end
-end
-
-function Irc.sending(self)
- local nicks_channels = {}
- local function ensure_joined(nick, channel)
- if not nicks_channels[nick] then
- self:_send{'NICK', nick, 1, 'username', 'host', 1, '+', 'realname'}
- nicks_channels[nick] = {}
- end
- if not nicks_channels[nick][channel] then
- self:_send{source=nick, 'JOIN', channel}
- nicks_channels[nick][channel] = true
- end
- end
- local function say(nick, channel, body)
- ensure_joined(nick, channel)
- self:_send{source=nick, 'PRIVMSG', channel, body}
- end
-
- say('WILSON', '#test', 'i am wilson')
-
- for ch, msg in self.inbox:iter() do
- say(msg.source, ch, msg.body)
- end
-end
-
-return Irc
-
--- local cq = cqueues.new()
--- local conf = {
--- host = 'localhost',
--- port = '6667',
--- password = 'mypassword',
--- nodename = 'wilson.ubq323',
--- }
-
--- local dummy_network = {
--- post = function(self, pylonname, channel, message)
--- pprint(pylonname, channel, message)
--- end
--- }
-
--- local pylon = Irc.makepylon('test', conf, cq, dummy_network)
--- pylon:run()
--- cq:wrap(function()
--- local i = 0
--- while true do
--- cqueues.sleep(1)
--- pylon.inbox:enqueue{
--- source = 'helen',
--- channel = '#test',
--- body = 'i am helen '..i,
--- }
--- i = i + 1
--- end
--- end)
--- pprint('cheese', cq:loop())
diff --git a/irc/pylon.lua b/irc/pylon.lua
new file mode 100644
index 0000000..741440c
--- /dev/null
+++ b/irc/pylon.lua
@@ -0,0 +1,80 @@
+local cqueues = require'cqueues'
+local socket = require'cqueues.socket'
+local class = require'r.class'
+
+local Thing = require'thing'
+local BasePylon = require'pylon'
+local Message = require'message'
+local rirc = require'irc.rirc'
+
+local Irc = class.extend(BasePylon)
+
+function Irc._send(self, args)
+ args.source = args.source or self.nodename
+ local sent = rirc.send(self.sock, args)
+ self:log('>', sent)
+end
+
+function Irc.init(self)
+ self:_check_fields "host port password nodename"
+end
+
+function Irc._connect(self)
+ self.sock = assert(socket.connect(self.host, self.port))
+ self:_send{'PASS', self.password, '0210-IRC', 'wilson|'}
+ self:_send{'SERVER', self.nodename, '1', 'i am wilson'}
+end
+
+function Irc.recving(self)
+ for line in self.sock:lines "*l" do
+ self:log('<', line)
+ local msg = rirc.parse(line)
+ if msg.op == 'PING' then
+ self:_send{'PONG', msg.args[1]}
+ elseif msg.op == 'PRIVMSG' then
+ local channel_name = msg.args[1]
+ local body = msg.args[2]
+ local sender = msg.source
+ self.wilson:deliver(Message {
+ pylon = self,
+ channel = Thing(self, channel_name),
+ author = Thing(self, sender),
+ body = body,
+ })
+ elseif msg.op == 'ERROR' then
+ error(msg.args[1])
+ end
+ end
+end
+
+function Irc.sending(self)
+ local nicks_channels = {}
+ local function ensure_joined(nick, channel)
+ if not nicks_channels[nick] then
+ self:_send{'NICK', nick, 1, 'username', 'host', 1, '+', 'realname'}
+ nicks_channels[nick] = {}
+ end
+ if not nicks_channels[nick][channel] then
+ self:_send{source=nick, 'JOIN', channel}
+ nicks_channels[nick][channel] = true
+ end
+ end
+ local function say(nick, channel, body)
+ ensure_joined(nick, channel)
+ self:_send{source=nick, 'PRIVMSG', channel, body}
+ end
+
+ say('WILSON', '#test', 'i am wilson')
+
+ for dest_channel, message in self.inbox:iter() do
+ local nick = message.author:name_with_shortname()
+ local channel_name = dest_channel.id
+ say(nick, channel_name, message.body)
+ end
+end
+
+function Irc.post(self, dest_channel, message)
+ self.inbox:enqueue(dest_channel, message)
+end
+
+return Irc
diff --git a/rirc.lua b/irc/rirc.lua
index 95445ad..0508351 100644
--- a/rirc.lua
+++ b/irc/rirc.lua
@@ -55,12 +55,7 @@ function irc.parse(line)
end
function irc.parse_src(src)
- local s, _, nick, user, host = src:find("^(.*)!(.*)@(.*)$")
- if s then
- return true, nick, user, host
- else
- return false
- end
+ return src:match"^(.*)!(.*)@(.*)$"
end
return irc
diff --git a/log.lua b/log.lua
new file mode 100644
index 0000000..550a4bd
--- /dev/null
+++ b/log.lua
@@ -0,0 +1,46 @@
+local pprint = require 'pprint'
+local class = require 'r.class'
+local qw = require 'r.qw'
+
+local levels = qw"debug proto info warn error"
+
+local Log = class()
+function Log.make(cls, name, level)
+ return setmetatable({
+ name=name,
+ level = level or 'info',
+ },cls)
+end
+local colours = {debug="\x1b[38;5;8m",error="\x1b[31m"}
+function Log.format(self, level, ...)
+ local vals = {} for i=1,select('#',...) do
+ local v = (select(i,...))
+ vals[i] = type(v) == 'string' and v or pprint.pformat(v)
+ end
+ return (colours[level] or '')
+ .. os.date"%Y-%m-%d %H:%M:%S%z"
+ .. ' ' .. level .. (" "):rep(5-#level)
+ .. ' [' .. self.name .. '] '
+ .. table.concat(vals,' ') .. '\x1b[0m' .. '\n'
+end
+function Log.msg(self,level,...)
+ if levels[level] < levels[self.level] then return end
+ io.stderr:write(self:format(level, ...))
+ io.stderr:flush()
+end
+for i,v in ipairs(levels) do
+ levels[v]=i
+ Log[v] = function(self,...) self:msg(v,...) end
+end
+Log.__call = Log.info
+
+-- handle errors correctly
+function Log.loop(self, cq)
+ local ok, err, code, thread, pollee, fd = cq:loop()
+ local msg = debug.traceback(thread, err)
+ self:error(err)
+ error('👉'..self.name..' '..msg,0)
+end
+
+return Log
+
diff --git a/main.lua b/main.lua
index 7d8c97d..d5469d0 100644
--- a/main.lua
+++ b/main.lua
@@ -1,86 +1,81 @@
local cqueues = require'cqueues'
+local class = require'r.class'
+local pprint = require'pprint'
-local pylon_types = {
- irc = require'irc',
- xmpp = require'xmpp',
+local config = require'config'
+local Thing = require'thing'
+local Log = require 'log'
+local Store = require 'store'
+
+local pylon_classes = {
+ irc = require'irc.pylon',
+ xmpp = require'xmpp.pylon',
+ discord = require'discord.pylon',
+ nanochat = require'nanochat.pylon',
+ quaddle = require'quaddle.pylon',
}
-local Network = {}
-function Network.make(pylon_confs, bus_confs)
- local self = {
+local Wilson = class()
+function Wilson.make(cls, conf)
+ local tlc = conf.top.level or {}
+ local self = setmetatable({
pylons={},
busses={},
cq = cqueues.new(),
- }
- for pylonname, conf in pairs(pylon_confs) do
- local pty = pylon_types[conf.type]
- self.pylons[pylonname] = pty.makepylon(pylonname, conf, self.cq, self)
- print("constructed pylon",pylonname,conf.type)
+ log = Log('(toplevel)',tlc.loglevel or 'info'),
+ store = Store(),
+ }, Wilson)
+ for name, pylon_conf in pairs(conf.pylon) do
+ pylon_conf.name = name
+ local pylon_class = pylon_classes[pylon_conf.type]
+ assert(pylon_class, "no such pylon type "..pylon_conf.type)
+ self.pylons[name] = pylon_class:make(self, pylon_conf)
+ self.log("constructed pylon",name,pylon_conf.type)
end
- for busname, conf in pairs(bus_confs) do
+ for name, bus_conf in pairs(conf.bus) do
local bus = {}
- for _, item in ipairs(conf) do
- table.insert(bus, {pylonname=item[1], channel=item[2]})
+ for _, item in ipairs(bus_conf) do
+ local pylon_name, channel_id = item[1], item[2]
+ local pylon = self.pylons[pylon_name]
+ assert(pylon,"no such pylon named "..pylon_name)
+ table.insert(bus, Thing(pylon, channel_id))
end
- self.busses[busname] = bus
+ self.log("constructed bus ",name,'with '..#bus_conf..' channels')
+ self.busses[name] = bus
end
- return setmetatable(self, {__index=Network})
+ return self
end
-function Network._find_bus(self, pylonname, channel)
+
+-- find bus containing given channel
+function Wilson._find_bus(self, channel)
for name, bus in pairs(self.busses) do
- for _, entry in ipairs(bus) do
- if entry.pylonname == pylonname and entry.channel == channel then
- return bus
- end
- end
+ for _, q in ipairs(bus) do if q == channel then return bus end end
end
- print("warning: unfound bus for",pylonname,channel)
+ self.log:warn("unfound bus for",channel)
end
-function Network.post(self, pylonname, channel, message)
- local bus = self:_find_bus(pylonname, channel)
+function Wilson.deliver(self, message)
+ local bus = self:_find_bus(assert(message.channel))
if bus then
- for _, dest in ipairs(bus) do
- if not (dest.pylonname == pylonname and dest.channel == channel) then
- local target_pylon = self.pylons[dest.pylonname]
- target_pylon.inbox:enqueue(dest.channel, message)
+ self.store:store(message)
+ for _, dest_channel in ipairs(bus) do
+ if message.channel ~= dest_channel then
+ self.log:debug(message.channel, "-->", dest_channel, message)
+ dest_channel.pylon:post(dest_channel, message)
end
end
end
end
-function Network.run(self)
+function Wilson.run(self)
for pylonname, pylon in pairs(self.pylons) do
self.cq:wrap(pylon.run, pylon)
- print("now running pylon", pylonname)
+ self.log("now running pylon", pylonname)
end
- print(self.cq:loop())
+ self.cq:wrap(self.store.run, self.store)
+ self.log:loop(self.cq)
end
-
-
-
-local pylon_confs = {
- xmpp_ubq323 = {
- type='xmpp',
- jid='wilson@ubq323.website',
- server='localhost',
- component='wilson.ubq323.website',
- component_secret='super_secret_wilson_password',
- resource='wilson',
- },
- irc_local = {
- type='irc',
- host='localhost',
- port=6667,
- password='mypassword',
- nodename='wilson.ubq323',
- },
-}
-local bus_confs = {
- d = {
- {'xmpp_ubq323', 'd@conference.ubq323.website'},
- {'irc_local', '#test'},
- },
-}
-
-local the_network = Network.make(pylon_confs, bus_confs)
-the_network:run()
+local config_file = assert(io.open("wilson.ini","r"))
+local conf = config.parse(config_file)
+config_file:close()
+local wilson = Wilson:make(conf)
+wilson:run()
diff --git a/message.lua b/message.lua
new file mode 100644
index 0000000..092f332
--- /dev/null
+++ b/message.lua
@@ -0,0 +1,19 @@
+local class = require'r.class'
+
+local BasePylon = require 'pylon'
+local Thing = require 'thing'
+
+local Message = class()
+-- since this is the beating heart of wilson, it pays to do some validation
+function Message.make(cls, self)
+ setmetatable(self,cls)
+ for k,v in pairs{
+ pylon=BasePylon,
+ channel=Thing,
+ author=Thing,
+ } do assert(class.is(self[k],v), 'field '..k..' missing or wrong') end
+ assert(type(self.body)=='string','field body missing or wrong')
+ assert(self.pylon == self.channel.pylon and self.pylon == self.author.pylon,
+ "inconsistent pylons!")
+ return self end
+return Message
diff --git a/nanochat/pylon.lua b/nanochat/pylon.lua
new file mode 100644
index 0000000..6fbd725
--- /dev/null
+++ b/nanochat/pylon.lua
@@ -0,0 +1,85 @@
+local cqueues = require 'cqueues'
+local socket = require 'cqueues.socket'
+local class = require 'r.class'
+
+local BasePylon = require 'pylon'
+local Thing = require 'thing'
+
+local Nanochat = class.extend(BasePylon)
+
+function Nanochat:init()
+ self:_check_fields "server port poll"
+end
+
+function Nanochat:_connect()
+ self.sock = socket.connect(self.server, tonumber(self.port))
+ self.sock:write("LAST 0\n")
+ self.sock:read("*l") -- discard count
+ self.lastid = tonumber(assert(self.sock:read("*l")))
+ self.usids = {}
+end
+
+function Nanochat:recving()
+ while true do
+ self.sock:write("SKIP "..self.lastid.."\n")
+ local deliverables = {} -- holey
+ local n = tonumber(assert(self.sock:read("*l")))
+ for i=1,n do
+ local msg = assert(self.sock:read("*l"))
+ local chan, msgi = msg:match("^([^ ]+) ()")
+ if chan then
+ local sender, body = msg:match("^([^:]+): (.+)$", msgi)
+ if sender then
+ deliverables[i] = {
+ pylon=self,
+ channel=Thing(self, chan),
+ author=Thing(self,sender),
+ body=body
+ }
+ else
+ deliverables[i] = {
+ pylon=self,
+ channel=Thing(self, chan),
+ author=Thing(self,""),
+ body=msg:sub(msgi,-1)
+ }
+ end
+ else -- luacheck: ignore 542
+ -- ignore messages without a channel, they stay nanochat-local
+ end
+ end
+ local s = self.sock:read("*l")
+ self.lastid = tonumber(assert(s))
+
+ for i=1,n do
+ local msg = deliverables[i] ; if msg then
+ local id = i - n + self.lastid
+ if self.usids[id] then
+ self.usids[id] = nil
+ else
+ self.log:proto('<', msg.channel.id, msg.author, msg.body)
+ self.wilson:deliver(msg)
+ end
+ end
+ end
+
+ cqueues.sleep(tonumber(self.poll))
+ end
+end
+
+function Nanochat:sending()
+ for dest_channel, message in self.inbox:iter() do
+ self.log:proto('>',dest_channel,message.author,message.body)
+ assert(self.sock:write(
+ "SEND "
+ ..dest_channel.id
+ ..message.author:name_with_shortname()
+ ..": "..message.body
+ .."\n"))
+ local id = tonumber(assert(self.sock:read("*l")))
+ self.usids[id] = true
+ end
+end
+
+
+return Nanochat
diff --git a/pprint.lua b/pprint.lua
new file mode 100644
index 0000000..870d960
--- /dev/null
+++ b/pprint.lua
@@ -0,0 +1,499 @@
+-- https://github.com/jagt/pprint.lua/blob/master/pprint.lua
+-- public domain
+
+local pprint = { VERSION = '0.1' }
+
+local depth = 1
+
+pprint.defaults = {
+ -- If set to number N, then limit table recursion to N deep.
+ depth_limit = false,
+ -- type display trigger, hide not useful datatypes by default
+ -- custom types are treated as table
+ show_nil = true,
+ show_boolean = true,
+ show_number = true,
+ show_string = true,
+ show_table = true,
+ show_function = false,
+ show_thread = false,
+ show_userdata = false,
+ -- additional display trigger
+ show_metatable = false, -- show metatable
+ show_all = true, -- override other show settings and show everything
+ use_tostring = true, -- use __tostring to print table if available
+ filter_function = nil, -- called like callback(value[,key, parent]), return truty value to hide
+ object_cache = 'local', -- cache blob and table to give it a id, 'local' cache per print, 'global' cache
+ -- per process, falsy value to disable (might cause infinite loop)
+ -- format settings
+ indent_size = 2, -- indent for each nested table level
+ level_width = 80, -- max width per indent level
+ wrap_string = true, -- wrap string when it's longer than level_width
+ wrap_array = false, -- wrap every array elements
+ sort_keys = true, -- sort table keys
+}
+
+local TYPES = {
+ ['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4,
+ ['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8
+}
+
+-- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a'
+local ESCAPE_MAP = {
+ ['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r',
+ ['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\',
+}
+
+-- generic utilities
+local function escape(s)
+ s = s:gsub('([%c\\])', ESCAPE_MAP)
+ local dq = s:find('"')
+ local sq = s:find("'")
+ if dq and sq then
+ return s:gsub('"', '\\"'), '"'
+ elseif sq then
+ return s, '"'
+ else
+ return s, "'"
+ end
+end
+
+local function is_plain_key(key)
+ return type(key) == 'string' and key:match('^[%a_][%a%d_]*$')
+end
+
+local CACHE_TYPES = {
+ ['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true
+}
+
+-- cache would be populated to be like:
+-- {
+-- function = { `fun1` = 1, _cnt = 1 }, -- object id
+-- table = { `table1` = 1, `table2` = 2, _cnt = 2 },
+-- visited_tables = { `table1` = 7, `table2` = 8 }, -- visit count
+-- }
+-- use weakrefs to avoid accidentall adding refcount
+local function cache_apperance(obj, cache, option)
+ if not cache.visited_tables then
+ cache.visited_tables = setmetatable({}, {__mode = 'k'})
+ end
+ local t = type(obj)
+
+ -- TODO can't test filter_function here as we don't have the ix and key,
+ -- might cause different results?
+ -- respect show_xxx and filter_function to be consistent with print results
+ if (not TYPES[t] and not option.show_table)
+ or (TYPES[t] and not option['show_'..t]) then
+ return
+ end
+
+ if CACHE_TYPES[t] or TYPES[t] == nil then
+ if not cache[t] then
+ cache[t] = setmetatable({}, {__mode = 'k'})
+ cache[t]._cnt = 0
+ end
+ if not cache[t][obj] then
+ cache[t]._cnt = cache[t]._cnt + 1
+ cache[t][obj] = cache[t]._cnt
+ end
+ end
+ if t == 'table' or TYPES[t] == nil then
+ if cache.visited_tables[obj] == false then
+ -- already printed, no need to mark this and its children anymore
+ return
+ elseif cache.visited_tables[obj] == nil then
+ cache.visited_tables[obj] = 1
+ else
+ -- visited already, increment and continue
+ cache.visited_tables[obj] = cache.visited_tables[obj] + 1
+ return
+ end
+ for k, v in pairs(obj) do
+ cache_apperance(k, cache, option)
+ cache_apperance(v, cache, option)
+ end
+ local mt = getmetatable(obj)
+ if mt and option.show_metatable then
+ cache_apperance(mt, cache, option)
+ end
+ end
+end
+
+-- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method
+local function str_natural_cmp(lhs, rhs)
+ while #lhs > 0 and #rhs > 0 do
+ local lmid, lend = lhs:find('%d+')
+ local rmid, rend = rhs:find('%d+')
+ if not (lmid and rmid) then return lhs < rhs end
+
+ local lsub = lhs:sub(1, lmid-1)
+ local rsub = rhs:sub(1, rmid-1)
+ if lsub ~= rsub then
+ return lsub < rsub
+ end
+
+ local lnum = tonumber(lhs:sub(lmid, lend))
+ local rnum = tonumber(rhs:sub(rmid, rend))
+ if lnum ~= rnum then
+ return lnum < rnum
+ end
+
+ lhs = lhs:sub(lend+1)
+ rhs = rhs:sub(rend+1)
+ end
+ return lhs < rhs
+end
+
+local function cmp(lhs, rhs)
+ local tleft = type(lhs)
+ local tright = type(rhs)
+ if tleft == 'number' and tright == 'number' then return lhs < rhs end
+ if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end
+ if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end
+
+ -- allow custom types
+ local oleft = TYPES[tleft] or 9
+ local oright = TYPES[tright] or 9
+ return oleft < oright
+end
+
+-- setup option with default
+local function make_option(option)
+ if option == nil then
+ option = {}
+ end
+ for k, v in pairs(pprint.defaults) do
+ if option[k] == nil then
+ option[k] = v
+ end
+ if option.show_all then
+ for t, _ in pairs(TYPES) do
+ option['show_'..t] = true
+ end
+ option.show_metatable = true
+ end
+ end
+ return option
+end
+
+-- override defaults and take effects for all following calls
+function pprint.setup(option)
+ pprint.defaults = make_option(option)
+end
+
+-- format lua object into a string
+function pprint.pformat(obj, option, printer)
+ option = make_option(option)
+ local buf = {}
+ local function default_printer(s)
+ table.insert(buf, s)
+ end
+ printer = printer or default_printer
+
+ local cache
+ if option.object_cache == 'global' then
+ -- steal the cache into a local var so it's not visible from _G or anywhere
+ -- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway
+ cache = pprint._cache or {}
+ pprint._cache = nil
+ elseif option.object_cache == 'local' then
+ cache = {}
+ end
+
+ local last = '' -- used for look back and remove trailing comma
+ local status = {
+ indent = '', -- current indent
+ len = 0, -- current line length
+ }
+
+ local wrapped_printer = function(s)
+ printer(last)
+ last = s
+ end
+
+ local function _indent(d)
+ status.indent = string.rep(' ', d + #(status.indent))
+ end
+
+ local function _n(d)
+ wrapped_printer('\n')
+ wrapped_printer(status.indent)
+ if d then
+ _indent(d)
+ end
+ status.len = 0
+ return true -- used to close bracket correctly
+ end
+
+ local function _p(s, nowrap)
+ status.len = status.len + #s
+ if not nowrap and status.len > option.level_width then
+ _n()
+ wrapped_printer(s)
+ status.len = #s
+ else
+ wrapped_printer(s)
+ end
+ end
+
+ local formatter = {}
+ local function format(v)
+ local f = formatter[type(v)]
+ f = f or formatter.table -- allow patched type()
+ if option.filter_function and option.filter_function(v, nil, nil) then
+ return ''
+ else
+ return f(v)
+ end
+ end
+
+ local function tostring_formatter(v)
+ return tostring(v)
+ end
+
+ local function number_formatter(n)
+ return n == math.huge and '[[math.huge]]' or tostring(n)
+ end
+
+ local function nop_formatter(v)
+ return ''
+ end
+
+ local function make_fixed_formatter(t, has_cache)
+ if has_cache then
+ return function (v)
+ return string.format('[[%s %d]]', t, cache[t][v])
+ end
+ else
+ return function (v)
+ return '[['..t..']]'
+ end
+ end
+ end
+
+ local function make_userdata_formatter(has_cache)
+ local function utype(v)
+ local mt = getmetatable(v)
+ if not mt then return '?' end
+ return mt.__name or '?'
+ end
+ if has_cache then
+ return function(v)
+ return string.format('[[userdata <%s> %d]]', utype(v), cache.userdata[v])
+ end
+ else
+ return function (v)
+ return string.format('[[userdata <%s>]]', utype(v))
+ end
+ end
+ end
+
+ local function string_formatter(s, force_long_quote)
+ local s, quote = escape(s)
+ local quote_len = force_long_quote and 4 or 2
+ if quote_len + #s + status.len > option.level_width then
+ _n()
+ -- only wrap string when is longer than level_width
+ if option.wrap_string and #s + quote_len > option.level_width then
+ -- keep the quotes together
+ _p('[[')
+ while #s + status.len >= option.level_width do
+ local seg = option.level_width - status.len
+ _p(string.sub(s, 1, seg), true)
+ _n()
+ s = string.sub(s, seg+1)
+ end
+ _p(s) -- print the remaining parts
+ return ']]'
+ end
+ end
+
+ return force_long_quote and '[['..s..']]' or quote..s..quote
+ end
+
+ local function table_formatter(t)
+ if option.use_tostring then
+ local mt = getmetatable(t)
+ if mt and mt.__tostring then
+ -- return string_formatter(tostring(t), true)
+ return tostring(t)
+ end
+ end
+
+ local print_header_ix = nil
+ local ttype = type(t)
+ if option.object_cache then
+ local cache_state = cache.visited_tables[t]
+ local tix = cache[ttype][t]
+ -- FIXME should really handle `cache_state == nil`
+ -- as user might add things through filter_function
+ if cache_state == false then
+ -- already printed, just print the the number
+ return string_formatter(string.format('%s %d', ttype, tix), true)
+ elseif cache_state > 1 then
+ -- appeared more than once, print table header with number
+ print_header_ix = tix
+ cache.visited_tables[t] = false
+ else
+ -- appeared exactly once, print like a normal table
+ end
+ end
+
+ local limit = tonumber(option.depth_limit)
+ if limit and depth > limit then
+ if print_header_ix then
+ return string.format('[[%s %d]]...', ttype, print_header_ix)
+ end
+ return string_formatter(tostring(t), true)
+ end
+
+ local tlen = #t
+ local wrapped = false
+ _p('{')
+ _indent(option.indent_size)
+ _p(string.rep(' ', option.indent_size - 1))
+ if print_header_ix then
+ _p(string.format('--[[%s %d]] ', ttype, print_header_ix))
+ end
+ for ix = 1,tlen do
+ local v = t[ix]
+ if formatter[type(v)] == nop_formatter or
+ (option.filter_function and option.filter_function(v, ix, t)) then
+ -- pass
+ else
+ if option.wrap_array then
+ wrapped = _n()
+ end
+ depth = depth+1
+ _p(format(v)..', ')
+ depth = depth-1
+ end
+ end
+
+ -- hashmap part of the table, in contrast to array part
+ local function is_hash_key(k)
+ if type(k) ~= 'number' then
+ return true
+ end
+
+ local numkey = math.floor(tonumber(k))
+ if numkey ~= k or numkey > tlen or numkey <= 0 then
+ return true
+ end
+ end
+
+ local function print_kv(k, v, t)
+ -- can't use option.show_x as obj may contain custom type
+ if formatter[type(v)] == nop_formatter or
+ formatter[type(k)] == nop_formatter or
+ (option.filter_function and option.filter_function(v, k, t)) then
+ return
+ end
+ wrapped = _n()
+ if is_plain_key(k) then
+ _p(k, true)
+ else
+ _p('[')
+ -- [[]] type string in key is illegal, needs to add spaces inbetween
+ local k = format(k)
+ if string.match(k, '%[%[') then
+ _p(' '..k..' ', true)
+ else
+ _p(k, true)
+ end
+ _p(']')
+ end
+ _p(' = ', true)
+ depth = depth+1
+ _p(format(v), true)
+ depth = depth-1
+ _p(',', true)
+ end
+
+ if option.sort_keys then
+ local keys = {}
+ for k, _ in pairs(t) do
+ if is_hash_key(k) then
+ table.insert(keys, k)
+ end
+ end
+ table.sort(keys, cmp)
+ for _, k in ipairs(keys) do
+ print_kv(k, t[k], t)
+ end
+ else
+ for k, v in pairs(t) do
+ if is_hash_key(k) then
+ print_kv(k, v, t)
+ end
+ end
+ end
+
+ if option.show_metatable then
+ local mt = getmetatable(t)
+ if mt then
+ print_kv('__metatable', mt, t)
+ end
+ end
+
+ _indent(-option.indent_size)
+ -- make { } into {}
+ last = string.gsub(last, '^ +$', '')
+ -- peek last to remove trailing comma
+ last = string.gsub(last, ',%s*$', ' ')
+ if wrapped then
+ _n()
+ end
+ _p('}')
+
+ return ''
+ end
+
+ -- set formatters
+ formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter
+ formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter
+ formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge
+ formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter
+ formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter
+ -- formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter
+ formatter['userdata'] = option.show_userdata and make_userdata_formatter(option.object_cache) or nop_formatter
+ formatter['string'] = option.show_string and string_formatter or nop_formatter
+ formatter['table'] = option.show_table and table_formatter or nop_formatter
+
+ if option.object_cache then
+ -- needs to visit the table before start printing
+ cache_apperance(obj, cache, option)
+ end
+
+ _p(format(obj))
+ printer(last) -- close the buffered one
+
+ -- put cache back if global
+ if option.object_cache == 'global' then
+ pprint._cache = cache
+ end
+
+ return table.concat(buf)
+end
+
+-- pprint all the arguments
+function pprint.pprint( ... )
+ local args = {...}
+ -- select will get an accurate count of array len, counting trailing nils
+ local len = select('#', ...)
+ for ix = 1,len do
+ pprint.pformat(args[ix], nil, io.write)
+ io.write('\t')
+ end
+ if len > 0 then
+ io.write('\n')
+ end
+end
+
+setmetatable(pprint, {
+ __call = function (_, ...)
+ pprint.pprint(...)
+ end
+})
+
+return pprint
diff --git a/pylon.lua b/pylon.lua
new file mode 100644
index 0000000..9d4a02d
--- /dev/null
+++ b/pylon.lua
@@ -0,0 +1,33 @@
+local cqueues = require'cqueues'
+local pprint = require 'pprint'
+local class = require 'r.class'
+local qw = require 'r.qw'
+
+local Queue = require 'queue'
+local Log = require'log'
+
+local BasePylon = class()
+
+function BasePylon.make(cls, wilson, conf)
+ local self = setmetatable(conf, cls)
+ self.wilson = wilson
+ self.inbox = Queue()
+ self.log = Log(self.name,self.loglevel)
+ self:_check_fields"shortname"
+ self:init()
+ return self end
+function BasePylon._check_fields(self, fields) for k in qw.i(fields) do
+ assert(self[k]~=nil, self.name..": missing field "..k) end end
+function BasePylon.post(self, dest_channel, message)
+ self.inbox:enqueue(dest_channel, message) end
+function BasePylon.run(self)
+ self.cq = cqueues.new()
+ self.cq:wrap(function()
+ self:_connect()
+ self.log"connected!"
+ self.cq:wrap(self.recving, self)
+ self.cq:wrap(self.sending, self)
+ end)
+ self.log:loop(self.cq)
+end
+return BasePylon
diff --git a/quaddle/pylon.lua b/quaddle/pylon.lua
new file mode 100644
index 0000000..4387342
--- /dev/null
+++ b/quaddle/pylon.lua
@@ -0,0 +1,103 @@
+local websocket = require'http.websocket'
+local request = require'http.request'
+local json = require 'dkjson'
+local class = require 'r.class'
+
+local BasePylon = require 'pylon'
+local Message = require 'message'
+local Thing = require 'thing'
+
+local Quaddle = class.extend(BasePylon)
+function Quaddle.init(self)
+ self:_check_fields "base_url bot_name bot_password"
+end
+
+-- https://codeberg.org/QWD/Quaddle/src/branch/main/doc/api.md
+
+function Quaddle._req(self, url, payload)
+ local req = request.new_from_uri(self.base_url..url)
+ req.headers:upsert(':method',payload and 'POST' or 'GET')
+ if self.token then req.headers:upsert('authorization',self.token) end
+ req.headers:upsert('user-agent','wilson (https://g.gh0.pw/wilson/, v0.0)')
+ if payload then
+ req.headers:upsert('content-type','application/json')
+ req:set_body(assert(json.encode(payload))) end
+
+ local head,body = assert(req:go())
+ local status = head:get':status'
+ local bod = body:get_body_as_string()
+
+ assert(status:match"^2", 'status was '..status..' body '..bod)
+
+ if #bod == 0 then return nil end
+ local val, _, err = json.decode(bod)
+ if err then error(err) else return val end
+end
+
+function Quaddle._connect(self)
+ local r = assert(self:_req("auth/login", { name=self.bot_name, password=self.bot_password }))
+ self.token = r.token
+
+ self.ws = websocket.new_from_uri(self.base_url:gsub("^http","ws").."app")
+ assert(self.ws:connect())
+ self.ws:send(json.encode{ op="identify", token=self.token }, 'text')
+
+ for busname, bus in pairs(self.wilson.busses) do
+ for _, channel in ipairs(bus) do
+ if channel.pylon == self then
+ self:add_channel(channel)
+ end
+ end
+ end
+end
+
+function Quaddle.add_channel(self, channel)
+ self.log("Subscribing to channel",channel.id)
+ self.ws:send(json.encode{ op="subscribe", channel_id=channel.id })
+end
+
+function Quaddle.recving(self)
+ for packet in self.ws:each() do
+ local event = json.decode(packet)
+ self.log:debug('event', event.event)
+ if event.event == "ready" then
+ -- self.session_id = event.session_id
+ self.bot_user = event.user
+ self.log("Logged in as id",self.bot_user.id,"name",self.bot_user.name)
+ elseif event.event == "error" then
+ self.log:error("Error from server:",event.reasons)
+ elseif event.event == "message_create" then
+ self:handle_message(event.message)
+ elseif event.event == "message_edit" then
+ -- TODO: wilson doesn't have edits yet
+ self:handle_message(event.message)
+ -- elseif event.event == "message_delete" then
+ -- TODO
+ else
+ self.log:debug("Unhandled Quaddle event:",json.encode(event))
+ end
+ end
+end
+
+function Quaddle.handle_message(self, msg)
+ self.log:proto('<',msg.channel_id,msg.author.name,msg.content)
+ if msg.author.id ~= self.bot_user.id then
+ self.wilson:deliver(Message {
+ pylon = self,
+ channel = Thing(self, msg.channel),
+ author = Thing(self,msg.author.id,msg.author.username),
+ body = msg.content,
+ })
+ end
+end
+
+function Quaddle.sending(self)
+ for dest_channel, message in self.inbox:iter() do
+ self.log:proto('>', dest_channel, message.author, message.body)
+ self:_req('channels/'..dest_channel.id..'/messages', {
+ content = message.author:name_with_shortname()..": "..message.body,
+ })
+ end
+end
+
+return Quaddle
diff --git a/queue.lua b/queue.lua
index 8c9c373..fec27bd 100644
--- a/queue.lua
+++ b/queue.lua
@@ -1,15 +1,15 @@
local cqueues = require'cqueues'
local condition = require'cqueues.condition'
local cqaux = require'cqueues.auxlib'
+local class = require 'r.class'
-local Queue = {}
+local Queue = class()
-function Queue.make()
+function Queue.make(cls)
return setmetatable({
items = {},
cv = condition.new(),
- }, {__index=Queue})
-end
+ }, cls) end
function Queue.enqueue(self, ...)
local item = table.pack(...)
@@ -27,7 +27,9 @@ function Queue.iter(self)
local items = self.items
self.items = {} -- the old switcheroo
for _, item in ipairs(items) do
- coroutine.yield(table.unpack(item, 1, item.n))
+ (function(...)
+ coroutine.yield(...)
+ end)(table.unpack(item, 1, item.n))
end
end
self.cv:wait()
diff --git a/store.lua b/store.lua
new file mode 100644
index 0000000..b978b17
--- /dev/null
+++ b/store.lua
@@ -0,0 +1,77 @@
+local cqueues = require'cqueues'
+local db = require'r.db'
+local schema = require'r.db.schema'
+local qw = require'r.qw'
+local html = require'r.html'
+local T = html.T
+local class = require'r.class'
+local App = require'r.web.app'
+local server = require'r.web.server'
+
+local Queue = require'queue'
+local Log = require'log'
+
+local Store = class() -- we only have 1 instance of this, but, ough,
+function Store.make(cls)
+ schema.schemafy {
+ messages = {
+ {"p_type","text",not nil},
+ {"p_name","text",not nil},
+ {"channel","text",not nil},
+ {"author","text",not nil},
+ {"ts","text",not nil},
+ {"body","text"},
+ },
+ }
+ return setmetatable({
+ q = Queue(),
+ log = Log"store",
+ },cls)
+end
+
+function Store.storing(self)
+ local conn = db.conn()
+ for msg in self.q:iter() do
+ -- needs to go in its own coro
+ -- so we don't gum up the works while waiting on the db
+ conn:exec([[
+ insert into messages (p_type,
+ p_name,channel,author,ts,body)
+ values (?, ?, ?, ?, ?, ?); ]],
+ msg.pylon.type,msg.pylon.name,
+ msg.channel.id, msg.author.id,
+ os.time(),msg.body)
+ self.log("stored",msg.body)
+ assert(conn:commit()) end end
+function Store.store(self, msg) self.q:enqueue(msg) end
+
+local function view(req)
+ local conn <close> = db.conn()
+ local rows = conn:fetch"select * from messages order by ts asc;"
+ local fields = qw"id p_type p_name channel author ts body"
+ -- list comprehensions? WHAT ARE THOSE?????
+ local ths={} for i,v in ipairs(fields) do ths[i]=T.th(v) end
+ local trs={} for ri,r in ipairs(rows) do
+ local tds = {} for i,v in ipairs(fields) do tds[i]=T.td(r[v]) end
+ trs[ri] = T.tr(tds) end
+ local tab = T.table{
+ T.thead(T.tr(ths)),
+ T.tbody(trs),
+ }
+ return html.html {
+ T.h1'the messages',
+ tab
+ } end
+local app = App{ view = view }
+function Store.serving(self)
+ return server.run(self.cq, app) end
+
+function Store.run(self)
+ self.cq = cqueues.new()
+ self.cq:wrap(self.storing, self)
+ self.cq:wrap(self.serving, self)
+ self.log("now running")
+ self.log:loop(self.cq)
+end
+
+return Store
diff --git a/terminology.txt b/terminology.txt
new file mode 100644
index 0000000..a5a8edd
--- /dev/null
+++ b/terminology.txt
@@ -0,0 +1,45 @@
+note: i'm writing this after not working on this project for 6 months
+so i'm kind of figuring this out for myself
+
+anyway
+
+Pylon: an outgoing connection from wilson to some chat server/service
+ which could be "some irc server", "some xmpp server"
+ or "discord" (discord is centralized so there's only 1 discord to connect to)
+ (though you COULD have multiple Pylons for discord, if you were crazy)
+ note that discord's concept of "server" aka "guild" is unrelated to anything
+
+Channel: a particular chatroom on a particular service
+ eg "#a on apionet irc" or "#general in some discord guild"
+ in the code, a Channel is identified by a combination of a Pylon
+ and a string 'channel descriptor', whose format depends on the Pylon type
+ for instance xmpp channel descriptors are jids, discord uses numeric ids
+
+Thing: used in the code for such a tuple of (pylon, pylon-specific-descriptor)
+ actually it also has a display name, which defaults to the descriptor
+ descriptor is called id in the code because descriptor is too long to type
+ used to represent Channels, Authors, ...
+
+Bus: a family of connected channels that messages will be bridged between
+
+Message: you know what a message is. it has a bunch of fields, which are
+ channel: the channel the message came from (as a Thing)
+ author: the user who created the message (as a Thing)
+ body: the string contents of the message
+
+[RFC] Author: a user or personality on a service who created a message
+ like Channel it's a tuple of (pylon, pylon-specific-descriptor)
+ eg xmpp would use jids, irc would use nicks, discord uses numeric ids
+
+[RFC] let's change the Message fields to
+type=message
+ author: the Author
+ channel: the Channel
+ body: the string contents
+type=delete
+ target=id (? how do we do message ids)
+type=edit
+ target=id
+ body=the new body
+
+... maybe this should be called Event? aaa
diff --git a/test.lua b/test.lua
deleted file mode 100644
index d70f7f2..0000000
--- a/test.lua
+++ /dev/null
@@ -1,24 +0,0 @@
-local cqueues = require'cqueues'
-local condition = require'cqueues.condition'
-
-local cv = condition.new()
-local function task1()
- while true do
- print('1 top')
- cv:wait()
- print('1 waited')
- end
-end
-local function task2()
- while true do
- print('2 top')
- cv:signal()
- print('2 signalled')
- cqueues.poll()
- end
-end
-
-local cq = cqueues.new()
-cq:wrap(task1)
-cq:wrap(task2)
-print(cq:loop())
diff --git a/thing.lua b/thing.lua
new file mode 100644
index 0000000..eee742b
--- /dev/null
+++ b/thing.lua
@@ -0,0 +1,25 @@
+local class = require'r.class'
+-- author or channel or whatever
+-- tuple of (pylon, pylon-specific-descriptor (id), optional display name)
+
+local Thing = class()
+function Thing.make(cls, pylon, id, name)
+ name = name or id
+ return setmetatable({
+ pylon=pylon,
+ id=id,
+ name=name,
+ }, cls)
+end
+function Thing.__eq(self, other)
+ return self.pylon == other.pylon and self.id == other.id
+end
+function Thing.__tostring(self)
+ return self.pylon.name
+ .. ':' .. self.id
+ .. (self.name == self.id and '('..self.name..')' or '')
+end
+function Thing.name_with_shortname(self)
+ return self.name .. ' [' .. self.pylon.shortname .. ']'
+end
+return Thing
diff --git a/todo.txt b/todo.txt
new file mode 100644
index 0000000..8415360
--- /dev/null
+++ b/todo.txt
@@ -0,0 +1,35 @@
+general bridging:
+ pfps
+ xmpp: serve pfps from file, on request
+ xmpp: obtain pfps and turn them into a public url
+ discord: obtain pfps into a file
+ edits and deletions
+ Message should have a type field, everything needs to support that
+ messages without a body but just attachments
+
+config:
+ some ui for changing bridge settings/adding new buses at runtime
+ ? should this rewrite the config file or something?
+
+logging:
+ subloggers, somehow
+ pass this into r.web and so on, somehow
+
+puppeteering:
+ detect existing nicks in use, avoid collisions, keep this in sync
+
+store:
+ think about how to store authors, given that
+ a) we want to be able to search by username
+ b) usernames can change over time on some platforms
+ nicer web ui
+ fts
+ sort by channel, etc
+
+
+irc:
+ port to unrealircd s2s protocol
+ OR switch to multiple c2s connections
+
+
+
diff --git a/wilson.ini b/wilson.ini
deleted file mode 100644
index b875b4f..0000000
--- a/wilson.ini
+++ /dev/null
@@ -1,33 +0,0 @@
-[pylon discord]
-type=discord
-token=abcdef123456
-
-[pylon apionet-irc]
-type=irc
-host=ubq323.website
-nick=wilson
-
-[pylon other-irc]
-type=irc
-host=someotherhost.example
-nick=gregory
-
-[pylon ubq-xmpp]
-type=xmpp
-jid=wilson@ubq323.website
-password=zyxw9876
-default-service=conference.ubq323.website
-
-
-[channel a]
-# apionet a
-discord 12345678 # esoserver #apionet
-discord 98765432 # apionet discord #a
-ubq-xmpp a@
-apionet-irc a
-
-[channel ja]
-discord 3141592654 # apionet discord #ja
-apionet-irc ja
-ubq-xmpp ja@
-
diff --git a/wilson.ini.example b/wilson.ini.example
new file mode 100644
index 0000000..a80ccd4
--- /dev/null
+++ b/wilson.ini.example
@@ -0,0 +1,47 @@
+[top level]
+loglevel=info
+
+[pylon xmpp-barbaz]
+type=xmpp
+shortname=x
+; port 5347
+server=localhost
+component=barbaz.boingsrv.example.org
+component_secret=seeeeeecret_password_here
+
+[pylon irc-boopbanana]
+type=irc
+shortname=i
+host=localhost
+port=6667
+password=seeeeeecret_password_here
+nodename=boopbanana.boing
+
+[pylon discord-foobar]
+type=discord
+shortname=d
+token=bot_token_here
+temp_wh=webhook_here
+
+[pylon nanochat-gloop]
+type=nanochat
+shortname=n
+server=gloop.woop.example
+port=44322
+poll=5
+
+[pylon quaddle-boingy]
+type=quaddle
+shortname=q
+base_url=https://boingy.example/
+bot_username=wilson
+bot_password=seeeeeecret_password_here
+
+
+
+[bus test]
+xmpp-barbaz test@conference.example.org
+irc-boopbanana #test
+discord-foobar channel_id_here
+nanochat-gloop #test
+quaddle-boingy channel_id_here
diff --git a/xml_old.lua b/xml_old.lua
deleted file mode 100644
index a63c0f7..0000000
--- a/xml_old.lua
+++ /dev/null
@@ -1,48 +0,0 @@
-function parseargs(s)
- local arg = {}
- string.gsub(s, "([%-%w]+)=([\"'])(.-)%2", function (w, _, a)
- arg[w] = a
- end)
- return arg
-end
-
-function collect(s)
- local stack = {}
- local top = {}
- table.insert(stack, top)
- local ni,c,label,xarg, empty
- local i, j = 1, 1
- while true do
- ni,j,c,label,xarg, empty = string.find(s, "<(%/?)([%w:]+)(.-)(%/?)>", i)
- if not ni then break end
- local text = string.sub(s, i, ni-1)
- if not string.find(text, "^%s*$") then
- table.insert(top, text)
- end
- if empty == "/" then -- empty element tag
- table.insert(top, {label=label, xarg=parseargs(xarg), empty=1})
- elseif c == "" then -- start tag
- top = {label=label, xarg=parseargs(xarg)}
- table.insert(stack, top) -- new level
- else -- end tag
- local toclose = table.remove(stack) -- remove top
- top = stack[#stack]
- if #stack < 1 then
- error("nothing to close with "..label)
- end
- if toclose.label ~= label then
- error("trying to close "..toclose.label.." with "..label)
- end
- table.insert(top, toclose)
- end
- i = j+1
- end
- local text = string.sub(s, i)
- if not string.find(text, "^%s*$") then
- table.insert(stack[#stack], text)
- end
- if #stack > 1 then
- error("unclosed "..stack[#stack].label)
- end
- return stack[1]
-end
diff --git a/xmpp.lua b/xmpp.lua
deleted file mode 100644
index 24a5a3c..0000000
--- a/xmpp.lua
+++ /dev/null
@@ -1,184 +0,0 @@
-
-local cqueues = require'cqueues'
-local cqaux = require'cqueues.auxlib'
-local socket = require'cqueues.socket'
-local xml = require'xml'
-local X = xml.X
-local xmlify = xml.xmlify
-local pprint=require'pprint'
-local Queue = require'queue'
-local base64 = require'base64'
-local sha1 = require'sha1'
-
-local Xmpp = {}
-
-local function make_auth(authz, authn, password)
- -- sasl plain (RFC4616)
- return base64.encode(authz..'\0'..authn..'\0'..password)
-end
-
-function Xmpp.makepylon(pylonname, conf, cq, network)
- local self = {
- pylonname = pylonname,
- cq = cq,
- network = network,
- inbox = Queue.make(),
- }
- local function conf_var(name)
- assert(conf[name] ~= nil, 'missing conf field '..name)
- self[name] = conf[name]
- end
- conf_var 'jid'
- conf_var 'server'
- conf_var 'resource'
- conf_var 'component'
- conf_var 'component_secret'
-
- setmetatable(self, {__index=Xmpp})
- return self
-
-end
-
-function Xmpp._connect_c2s(self)
- local sock = assert(socket.connect(self.server, 5222))
- self.sock = sock
- sock:setmode('bn','bn')
-
- local start = ([[
-<?xml version='1.0'?><stream:stream from='%s' to='%s' version='1.0' xml:lang='en' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>]]):format(self.jid, self.server)
-
- -- state of the art xml parser
- local function check_and_send(test, text)
- local x = sock:read('-2048')
- assert(x:find(test))
- if text then sock:write(text) end
- end
- local function ietf_urn(v) return 'urn:ietf:params:xml:ns:xmpp-'..v end
-
- sock:write(start)
- check_and_send('starttls', xmlify(X.starttls{xmlns=ietf_urn"tls"}))
- check_and_send('proceed', nil)
- sock:starttls()
- sock:write(start)
- local auth = make_auth('', self.jid:match"(.*)@", self.password)
- check_and_send('PLAIN',
- xmlify(X.auth{xmlns=ietf_urn"sasl", mechanism='PLAIN', auth}))
- check_and_send('success',start)
- check_and_send('bind',
- xmlify(X.iq{type='set', id='aaaa',
- X.bind{xmlns=ietf_urn"bind", X.resource{self.resource}}}))
- check_and_send('jid',X.presence{X.show{'chat'}})
-
- return sock
-end
-
--- this sucks! no tls! no security! only use on local connections!
-function Xmpp._connect_component(self)
- local sock = assert(socket.connect(self.server, 5347))
- self.sock = sock
- sock:setmode('bn','bn')
- -- yes, our component name goes in the 'to' field. don't ask me why
- local start = ([[<stream:stream to='%s' xmlns='jabber:component:accept' xmlns:stream='http://etherx.jabber.org/streams'>]]):format(self.component)
-
- print(start)
-
- -- state of the art xml parser
- local function check_and_send(test, text)
- local x = sock:read('-2048')
- assert(x:find(test))
- if text then sock:write(text) end
- end
-
- sock:write(start)
- local streamhead = sock:read('-2048')
- print('streamhead', streamhead)
- assert(streamhead:find'accept')
- local streamid = streamhead:match"id='(.-)'"
- sock:write(xmlify(X.handshake{sha1.sha1(streamid..self.component_secret)}))
- check_and_send('<handshake/>',nil)
-
- return sock
-end
-
-local THE_MUC = 'd@conference.ubq323.website'
-
-function Xmpp.run(self)
- self:_connect_component()
- self.cq:wrap(self.recving, self)
- self.cq:wrap(self.sending, self)
-end
-
-function Xmpp.recving(self)
- local function getmore()
- local function t(...)
- pprint(...)
- return ...
- end
- return t(self.sock:read'-2048')
- end
- for x in xml.stanzae(getmore) do
- pprint(x)
- print(xmlify(x))
- local body = x'body' and x'body'[1]
- if x.label == 'message' then
- local fr = x.xarg.from
- local t = x.xarg.to
- if body and fr:match"/" and t == 'wilson@'..self.component then
- self.network:post(self.pylonname, THE_MUC, {
- body = body,
- source = '[x]'..x.xarg.from:match("/(.*)")
- })
- end
- end
- end
-end
-
-function Xmpp.sending(self)
- local users_inuse = {}
- local nicks_inuse = {}
- local function ensure_joined(muc,user,nick)
- if nicks_inuse[nick] then return end
- user = user:gsub("[^a-zA-Z0-9%.]","."):match("^%.*(.-)%.*$")
- while users_inuse[user] do user = user..'-' end
- local jid = user..'@'..self.component
- local mucjid = muc..'/'..nick
- nicks_inuse[nick] = true
- users_inuse[user] = true
-
- self.sock:write(xmlify(
- X.presence{from=jid, to=mucjid,
- X.x{xmlns='http://jabber.org/protocol/muc',
- X.history{maxstanzas='0'}}}))
- end
- ensure_joined(THE_MUC, 'wilson', 'wilson')
- for ch, msg in self.inbox:iter() do
- pprint(ch,msg)
- ensure_joined(THE_MUC, msg.source, msg.source)
- local user = msg.source:gsub("[^a-zA-Z0-9%.]","."):match("^%.*(.-)%.*$")
- local jid = user..'@'..self.component
- self.sock:write(xmlify(
- X.message{to=THE_MUC, type='groupchat', from=jid,
- X.body{msg.body}}))
- end
-end
-
-
-return Xmpp
-
--- local cq = cqueues.new()
--- local conf = {
--- jid='wilson@ubq323.website',
--- server='ubq323.website',
--- password='gregory<3',
--- resource='cheese',
--- }
--- local dummy_network = {
--- post = function(self, pylonname, channel, message)
--- pprint(pylonname, channel, message)
--- end
--- }
--- local pylon = Xmpp.makepylon('xmpptest',conf, cq, dummy_network)
--- pylon:run()
-
--- pprint('peas', cq:loop())
-
diff --git a/base64.lua b/xmpp/base64.lua
index 4f9a967..4f9a967 100644
--- a/base64.lua
+++ b/xmpp/base64.lua
diff --git a/xmpp/pylon.lua b/xmpp/pylon.lua
new file mode 100644
index 0000000..9f5ccfe
--- /dev/null
+++ b/xmpp/pylon.lua
@@ -0,0 +1,143 @@
+local socket = require'cqueues.socket'
+local class = require'r.class'
+
+local Thing = require'thing'
+local BasePylon = require 'pylon'
+local Message = require 'message'
+local xml = require'xmpp.xml'
+local X,xmlify = xml.X, xml.xmlify
+local base64 = require'xmpp.base64'
+local sha1 = require'xmpp.sha1'
+
+local Xmpp = class.extend(BasePylon)
+
+function Xmpp.init(self)
+ self:_check_fields"server component component_secret"
+
+ self.mucs = {}
+end
+
+-- only use on local connections!
+function Xmpp._connect(self)
+ local sock = assert(socket.connect(self.server, 5347))
+ self.sock = sock
+ sock:setmode('bn','bn')
+ -- yes, our component name goes in the 'to' field. don't ask me why
+ local start = ([[<stream:stream to='%s' xmlns='jabber:component:accept' xmlns:stream='http://etherx.jabber.org/streams'>]]):format(self.component)
+
+ -- state of the art xml parser
+ local function check_and_send(test, text)
+ local x = sock:read('-2048')
+ assert(x:find(test))
+ if text then sock:write(text) end
+ end
+
+ sock:write(start)
+ local streamhead = sock:read('-2048')
+ assert(streamhead:find'accept')
+ local streamid = streamhead:match"id='(.-)'"
+ sock:write(xmlify(X.handshake{sha1.sha1(streamid..self.component_secret)}))
+ check_and_send('<handshake/>',nil)
+
+ for busname, bus in pairs(self.wilson.busses) do
+ for _, channel in ipairs(bus) do
+ if channel.pylon == self then
+ self:add_channel(channel)
+ end
+ end
+ end
+
+ return sock
+end
+
+function Xmpp.add_channel(self, channel)
+ local muc = { nick_to_user={}, user_to_nick={} }
+ self.mucs[channel.id] = muc
+ local bot = {} ; self.bot = bot
+ bot.user, bot.nick, bot.user_jid, bot.nick_jid = self:ensure_and_get_user(channel.id, "wilson")
+end
+
+function Xmpp.ensure_and_get_user(self, muc_jid, nick, source_pylon)
+ local muc = assert(self.mucs[muc_jid])
+ local user = muc.nick_to_user[nick]
+ if user then
+ local user_jid = user..'@'..self.component
+ local nick_jid = muc_jid..'/'..nick
+ return user, nick, user_jid, nick_jid
+ end
+
+ -- join up new user
+
+ user = nick:gsub("[^a-zA-Z0-9%.]","."):match("^%.*(.-)%.*$")
+ if source_pylon then
+ while muc.user_to_nick[user.."."..source_pylon.name] do user = user..'-' end
+ user = user.."."..source_pylon.name
+ else
+ while muc.user_to_nick[user] do user = user..'-' end
+ end
+ muc.nick_to_user[nick] = user
+ muc.user_to_nick[user] = nick
+
+ local user_jid = user..'@'..self.component
+ local nick_jid = muc_jid..'/'..nick
+
+ self.log("Joining up new user "..user_jid.." to "..nick_jid)
+
+ self.sock:write(xmlify(
+ X.presence{from=user_jid, to=nick_jid,
+ X.x{xmlns='http://jabber.org/protocol/muc',
+ X.history{maxstanzas='0'}}}))
+
+ return user, nick, user_jid, nick_jid
+end
+
+function Xmpp.recving(self)
+ local function getmore()
+ local function t(...)
+ -- pprint(...)
+ return ...
+ end
+ return t(self.sock:read'-2048')
+ end
+ for x in xml.stanzae(getmore) do
+ self.log:debug(xmlify(x))
+ if type(x) == 'string' then
+ self.log:warn("x was a string: ("..x..")")
+ goto next
+ end
+ local body = x'body' and x'body'[1]
+ if x.label == 'message' then
+ local from_bare_jid, from_nick
+ if x.xarg.from then from_bare_jid, from_nick = x.xarg.from:match"(.*)/(.*)" end
+ local muc = self.mucs[from_bare_jid] ; if muc then
+ if
+ x.xarg.to == 'wilson@'..self.component
+ and body
+ and from_nick and not muc.nick_to_user[from_nick]
+ then
+ self.log:proto('<',from_bare_jid,from_nick,body)
+ self.wilson:deliver(Message {
+ pylon = self,
+ channel = Thing(self, from_bare_jid),
+ author = Thing(self, x.xarg.from, from_nick),
+ body = body,
+ })
+ end
+ end
+ end
+ ::next::
+ end
+end
+
+function Xmpp.sending(self)
+ for dest_channel, message in self.inbox:iter() do
+ self.log:proto('>',dest_channel,message.author,message.body)
+ local user, nick, user_jid, nick_jid = self:ensure_and_get_user(
+ dest_channel.id, message.author:name_with_shortname(), message.pylon)
+ self.sock:write(xmlify(
+ X.message{to=dest_channel.id, type='groupchat', from=user_jid,
+ X.body{message.body}}))
+ end
+end
+
+return Xmpp
diff --git a/sha1.lua b/xmpp/sha1.lua
index 7451595..7451595 100644
--- a/sha1.lua
+++ b/xmpp/sha1.lua
diff --git a/xml.lua b/xmpp/xml.lua
index 68fa741..e54f27f 100644
--- a/xml.lua
+++ b/xmpp/xml.lua
@@ -1,21 +1,20 @@
-- originally from http://lua-users.org/wiki/LuaXml
-- modified by me a bit
-
-entity_escapes = {
+local entity_escapes = {
["<"]="&lt;",
[">"]="&gt;",
["&"]="&amp;",
['"']="&quot;",
["'"]="&apos;"
}
-entity_unescapes = {}
+local entity_unescapes = {}
for k,v in pairs(entity_escapes) do entity_unescapes[v]=k end
-function escape(s)
+local function escape(s)
return s:gsub("[<>&'\"]",entity_escapes)
end
-function unescape(s)
+local function unescape(s)
return s:gsub("&[a-z]+;",entity_unescapes)
end