diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .luacheckrc | 3 | ||||
| -rw-r--r-- | config.lua | 46 | ||||
| -rw-r--r-- | discord.lua | 24 | ||||
| -rw-r--r-- | discord/consts.lua | 45 | ||||
| -rw-r--r-- | discord/pylon.lua | 157 | ||||
| -rw-r--r-- | irc.lua | 119 | ||||
| -rw-r--r-- | irc/pylon.lua | 80 | ||||
| -rw-r--r-- | irc/rirc.lua (renamed from rirc.lua) | 7 | ||||
| -rw-r--r-- | log.lua | 46 | ||||
| -rw-r--r-- | main.lua | 117 | ||||
| -rw-r--r-- | message.lua | 19 | ||||
| -rw-r--r-- | nanochat/pylon.lua | 85 | ||||
| -rw-r--r-- | pprint.lua | 499 | ||||
| -rw-r--r-- | pylon.lua | 33 | ||||
| -rw-r--r-- | quaddle/pylon.lua | 103 | ||||
| -rw-r--r-- | queue.lua | 12 | ||||
| -rw-r--r-- | store.lua | 77 | ||||
| -rw-r--r-- | terminology.txt | 45 | ||||
| -rw-r--r-- | test.lua | 24 | ||||
| -rw-r--r-- | thing.lua | 25 | ||||
| -rw-r--r-- | todo.txt | 35 | ||||
| -rw-r--r-- | wilson.ini | 33 | ||||
| -rw-r--r-- | wilson.ini.example | 47 | ||||
| -rw-r--r-- | xml_old.lua | 48 | ||||
| -rw-r--r-- | xmpp.lua | 184 | ||||
| -rw-r--r-- | xmpp/base64.lua (renamed from base64.lua) | 0 | ||||
| -rw-r--r-- | xmpp/pylon.lua | 143 | ||||
| -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 @@ -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 @@ -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 + @@ -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 @@ -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 @@ -1,21 +1,20 @@ -- originally from http://lua-users.org/wiki/LuaXml -- modified by me a bit - -entity_escapes = { +local entity_escapes = { ["<"]="<", [">"]=">", ["&"]="&", ['"']=""", ["'"]="'" } -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 |
