diff --git a/.gitignore b/.gitignore index cf44522..3ad5d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /gh-pages/ +*~ + diff --git a/asyncoperations.lua b/asyncoperations.lua index 9e9ba13..7531b28 100644 --- a/asyncoperations.lua +++ b/asyncoperations.lua @@ -1,16 +1,14 @@ -local table = table -local assert = assert -local error = error -local select = select -local pairs = pairs +local msgs = require("irc.messages") -module "irc" - -local meta = _META +local meta = {} function meta:send(msg, ...) - if select("#", ...) > 0 then - msg = msg:format(...) + if type(msg) == "table" then + msg = msg:toRFC1459() + else + if select("#", ...) > 0 then + msg = msg:format(...) + end end self:invoke("OnSend", msg) @@ -23,6 +21,10 @@ function meta:send(msg, ...) end end +function meta:queue(msg) + table.insert(self.messageQueue, msg) +end + local function verify(str, errLevel) if str:find("^:") or str:find("%s%z") then error(("malformed parameter '%s' to irc command"):format(str), errLevel) @@ -34,28 +36,26 @@ end function meta:sendChat(target, msg) -- Split the message into segments if it includes newlines. for line in msg:gmatch("([^\r\n]+)") do - self:send("PRIVMSG %s :%s", verify(target, 3), line) + self:queue(msgs.privmsg(verify(target, 3), line)) end end function meta:sendNotice(target, msg) -- Split the message into segments if it includes newlines. for line in msg:gmatch("([^\r\n]+)") do - self:send("NOTICE %s :%s", verify(target, 3), line) + self:queue(msgs.notice(verify(target, 3), line)) end end function meta:join(channel, key) - if key then - self:send("JOIN %s :%s", verify(channel, 3), verify(key, 3)) - else - self:send("JOIN %s", verify(channel, 3)) - end + self:queue(msgs.join( + verify(channel, 3), + key and verify(key, 3) or nil)) end -function meta:part(channel) +function meta:part(channel, reason) channel = verify(channel, 3) - self:send("PART %s", channel) + self:queue(msgs.part(channel, reason)) if self.track_users then self.channels[channel] = nil end @@ -85,5 +85,8 @@ function meta:setMode(t) mode = table.concat{mode, "-", verify(rem, 3)} end - self:send("MODE %s %s", verify(target, 3), mode) + self:queue(msgs.mode(verify(target, 3), mode)) end + +return meta + diff --git a/doc/irc.luadoc b/doc/irc.luadoc index 111de8e..c33a0b1 100644 --- a/doc/irc.luadoc +++ b/doc/irc.luadoc @@ -1,7 +1,7 @@ --- LuaIRC is a low-level IRC library for Lua. -- All functions raise Lua exceptions on error. -- --- Use new to create a new IRC object.
+-- Use new to create a new Connection object.
-- Example:

-- --require "irc"
@@ -24,11 +24,12 @@ module "irc" ---- Create a new IRC object. Use irc:connect to connect to a server. +--- Create a new Connection object. Use irc:connect to connect to a server. -- @param user Table with fields nick, username and realname. -- The nick field is required. -- --- @return Returns a new irc object. +-- @return Returns a new Connection object. +-- @see Connection function new(user) --- Hook a function to an event. @@ -49,7 +50,7 @@ function irc:unhook(name, id) function irc:connect(host, port) -- @param table Table of connection details --- @see Connection +-- @see ConnectOptions function irc:connect(table) --- Disconnect irc from the server. @@ -70,24 +71,28 @@ function irc:whois(nick) -- @param channel Channel to query. function irc:topic(channel) ---- Send a raw line of IRC to the server. --- @param msg Line to be sent, excluding newline characters. +--- Send a IRC message to the server. +-- @param msg Message or raw line to send, excluding newline characters. -- @param ... Format parameters for msg, with string.format semantics. [optional] function irc:send(msg, ...) +--- Queue Message to be sent to the server. +-- @param msg Message to be sent. +function irc:queue(msg) + --- Send a message to a channel or user. -- @param target Nick or channel to send to. --- @param message Message to send. +-- @param message Message text. function irc:sendChat(target, message) --- Send a notice to a channel or user. -- @param target Nick or channel to send to. --- @param message Notice to send. +-- @param message Notice text. function irc:sendNotice(target, message) --- Join a channel. -- @param channel Channel to join. --- @param key Channel password. [optional] +-- @param key Channel key. [optional] function irc:join(channel, key) --- Leave a channel. @@ -108,25 +113,51 @@ function irc:setMode(t) --internal function irc:invoke(name, ...) -function irc:handle(prefix, cmd, params) +function irc:handle(msg) function irc:shutdown() --- Table with connection information. --- +-- @name ConnectOptions +-- @class table +-- @field host Server host name. +-- @field port Server port. [defaults to 6667] +-- @field timeout Connect timeout. [defaults to 30] +-- @field password Server password. +-- @field secure Boolean to enable TLS connection, pass a params table (described, [luasec]) to control -- [luasec]: http://www.inf.puc-rio.br/~brunoos/luasec/reference.html + +--- Class representing a connection. -- @name Connection -- @class table +-- @field authed Boolean indicating whether the connection has completed registration. +-- @field connected Whether the connection is currently connected. +-- @field motd The server's message of the day. Can be nil. +-- @field nick The current nickname. +-- @field realname The real name sent to the server. +-- @field username The username/ident sent to the server. +-- @field socket Raw socket used by the library. +-- @field supports What the server claims to support in it's ISUPPORT message. + +--- Class representing an IRC message. +-- @name Message +-- @class table +-- @field args A list of the command arguments +-- @field command The IRC command +-- @field prefix The prefix of the message +-- @field raw A raw IRC line for this message +-- @field tags A table of IRCv3 tags +-- @field user A User object describing the sender of the message +-- Fields may be missing. +-- Messages have the following methods: +-- ---- List of hooks you can use with irc:hook. The parameter list describes the parameters passed to the callback function. +--- List of hooks you can use with irc:hook. +-- The parameter list describes the parameters passed to the callback function. -- -- * Event also invoked for yourself. -- � Channel passed only when user tracking is enabled @@ -150,12 +185,14 @@ function irc:shutdown() --- Table with information about a user. -- --- Apart from nick, fields may be missing. To fill them in, enable user tracking and use irc:whois. +-- Fields may be missing. To fill them in, enable user tracking and use irc:whois. -- @name User -- @class table + diff --git a/handlers.lua b/handlers.lua index cbd1c9e..f01936f 100644 --- a/handlers.lua +++ b/handlers.lua @@ -1,87 +1,144 @@ -local pairs = pairs -local error = error -local tonumber = tonumber -local table = table +local util = require("irc.util") +local msgs = require("irc.messages") +local Message = msgs.Message -module "irc" +local handlers = {} -handlers = {} +handlers["PING"] = function(conn, msg) + conn:send(Message({command="PONG", args=msg.args})) +end + +local function requestWanted(conn, wanted) + local args = {} + for cap, value in pairs(wanted) do + if type(value) == "string" then + cap = cap .. "=" .. value + end + if not conn.capabilities[cap] then + table.insert(args, cap) + end + end + conn:queue(Message({ + command = "CAP", + args = {"REQ", table.concat(args, " ")} + }) + ) +end -handlers["PING"] = function(o, prefix, query) - o:send("PONG :%s", query) +handlers["CAP"] = function(conn, msg) + local cmd = msg.args[2] + if not cmd then + return + end + if cmd == "LS" then + local list = msg.args[3] + local last = false + if list == "*" then + list = msg.args[4] + else + last = true + end + local avail = conn.availableCapabilities + local wanted = conn.wantedCapabilities + for item in list:gmatch("(%S+)") do + local eq = item:find("=", 1, true) + local k, v + if eq then + k, v = item:sub(1, eq - 1), item:sub(eq + 1) + else + k, v = item, true + end + if not avail[k] or avail[k] ~= v then + wanted[k] = conn:invoke("OnCapabilityAvailable", k, v) + end + avail[k] = v + end + if last then + if next(wanted) then + requestWanted(conn, wanted) + end + conn:invoke("OnCapabilityList", conn.availableCapabilities) + end + elseif cmd == "ACK" then + for item in msg.args[3]:gmatch("(%S+)") do + local enabled = (item:sub(1, 1) ~= "-") + local name = enabled and item or item:sub(2) + conn:invoke("OnCapabilitySet", name, enabled) + conn.capabilities[name] = enabled + end + end end -handlers["001"] = function(o, prefix, me) - o.authed = true - o.nick = me +handlers["001"] = function(conn, msg) + conn.authed = true + conn.nick = msg.args[1] end -handlers["PRIVMSG"] = function(o, prefix, channel, message) - o:invoke("OnChat", parsePrefix(prefix), channel, message) +handlers["PRIVMSG"] = function(conn, msg) + conn:invoke("OnChat", msg.user, msg.args[1], msg.args[2]) end -handlers["NOTICE"] = function(o, prefix, channel, message) - o:invoke("OnNotice", parsePrefix(prefix), channel, message) +handlers["NOTICE"] = function(conn, msg) + conn:invoke("OnNotice", msg.user, msg.args[1], msg.args[2]) end -handlers["JOIN"] = function(o, prefix, channel) - local user = parsePrefix(prefix) - if o.track_users then - if user.nick == o.nick then - o.channels[channel] = {users = {}} +handlers["JOIN"] = function(conn, msg) + local channel = msg.args[1] + if conn.track_users then + if msg.user.nick == conn.nick then + conn.channels[channel] = {users = {}} else - o.channels[channel].users[user.nick] = user + conn.channels[channel].users[msg.user.nick] = msg.user end end - o:invoke("OnJoin", user, channel) + conn:invoke("OnJoin", msg.user, msg.args[1]) end -handlers["PART"] = function(o, prefix, channel, reason) - local user = parsePrefix(prefix) - if o.track_users then - if user.nick == o.nick then - o.channels[channel] = nil +handlers["PART"] = function(conn, msg) + local channel = msg.args[1] + if conn.track_users then + if msg.user.nick == conn.nick then + conn.channels[channel] = nil else - o.channels[channel].users[user.nick] = nil + conn.channels[channel].users[msg.user.nick] = nil end end - o:invoke("OnPart", user, channel, reason) + conn:invoke("OnPart", msg.user, msg.args[1], msg.args[2]) end -handlers["QUIT"] = function(o, prefix, msg) - local user = parsePrefix(prefix) - if o.track_users then - for channel, v in pairs(o.channels) do - v.users[user.nick] = nil +handlers["QUIT"] = function(conn, msg) + if conn.track_users then + for chanName, chan in pairs(conn.channels) do + chan.users[msg.user.nick] = nil end end - o:invoke("OnQuit", user, msg) + conn:invoke("OnQuit", msg.user, msg.args[1], msg.args[2]) end -handlers["NICK"] = function(o, prefix, newnick) - local user = parsePrefix(prefix) - if o.track_users then - for channel, v in pairs(o.channels) do - local users = v.users - local oldinfo = users[user.nick] +handlers["NICK"] = function(conn, msg) + local newNick = msg.args[1] + if conn.track_users then + for chanName, chan in pairs(conn.channels) do + local users = chan.users + local oldinfo = users[msg.user.nick] if oldinfo then - users[newnick] = oldinfo - users[user.nick] = nil - o:invoke("NickChange", user, newnick, channel) + users[newNick] = oldinfo + users[msg.user.nick] = nil + conn:invoke("NickChange", msg.user, newNick, chanName) end end else - o:invoke("NickChange", user, newnick) + conn:invoke("NickChange", msg.user, newNick) end - if user.nick == o.nick then - o.nick = newnick + if msg.user.nick == conn.nick then + conn.nick = newNick end end -local function needNewNick(o, prefix, target, badnick) - local newnick = o.nickGenerator(badnick) - o:send("NICK %s", newnick) +local function needNewNick(conn, msg) + local newnick = conn.nickGenerator(msg.args[2]) + conn:queue(irc.msgs.nick(newnick)) end -- ERR_ERRONEUSNICKNAME (Misspelt but remains for historical reasons) @@ -90,85 +147,131 @@ handlers["432"] = needNewNick -- ERR_NICKNAMEINUSE handlers["433"] = needNewNick ---NAMES list -handlers["353"] = function(o, prefix, me, chanType, channel, names) - if o.track_users then - o.channels[channel] = o.channels[channel] or {users = {}, type = chanType} +-- ERR_UNAVAILRESOURCE +handlers["437"] = function(conn, msg) + if not conn.authed then + needNewNick(conn, msg) + end +end + +-- RPL_ISUPPORT +handlers["005"] = function(conn, msg) + local arglen = #msg.args + -- Skip first and last parameters (nick and info) + for i = 2, arglen - 1 do + local item = msg.args[i] + local pos = item:find("=") + if pos then + conn.supports[item:sub(1, pos - 1)] = item:sub(pos + 1) + else + conn.supports[item] = true + end + end +end - local users = o.channels[channel].users +-- RPL_MOTDSTART +handlers["375"] = function(conn, msg) + conn.motd = "" +end + +-- RPL_MOTD +handlers["372"] = function(conn, msg) + -- MOTD lines have a "- " prefix, strip it. + conn.motd = conn.motd .. msg.args[2]:sub(3) .. '\n' +end + +-- NAMES list +handlers["353"] = function(conn, msg) + local chanType = msg.args[2] + local channel = msg.args[3] + local names = msg.args[4] + if conn.track_users then + conn.channels[channel] = conn.channels[channel] or {users = {}, type = chanType} + + local users = conn.channels[channel].users for nick in names:gmatch("(%S+)") do - local access, name = parseNick(nick) + local access, name = util.parseNick(conn, nick) users[name] = {access = access} end end end ---end of NAMES -handlers["366"] = function(o, prefix, me, channel, msg) - if o.track_users then - o:invoke("NameList", channel, msg) +-- End of NAMES list +handlers["366"] = function(conn, msg) + if conn.track_users then + conn:invoke("NameList", msg.args[2], msg.args[3]) end end ---no topic -handlers["331"] = function(o, prefix, me, channel) - o:invoke("OnTopic", channel, nil) +-- No topic +handlers["331"] = function(conn, msg) + conn:invoke("OnTopic", msg.args[2], nil) end ---new topic -handlers["TOPIC"] = function(o, prefix, channel, topic) - o:invoke("OnTopic", channel, topic) +handlers["TOPIC"] = function(conn, msg) + conn:invoke("OnTopic", msg.args[1], msg.args[2]) end -handlers["332"] = function(o, prefix, me, channel, topic) - o:invoke("OnTopic", channel, topic) +handlers["332"] = function(conn, msg) + conn:invoke("OnTopic", msg.args[2], msg.args[3]) end ---topic creation info -handlers["333"] = function(o, prefix, me, channel, nick, time) - o:invoke("OnTopicInfo", channel, nick, tonumber(time)) +-- Topic creation info +handlers["333"] = function(conn, msg) + conn:invoke("OnTopicInfo", msg.args[2], msg.args[3], tonumber(msg.args[4])) end -handlers["KICK"] = function(o, prefix, channel, kicked, reason) - o:invoke("OnKick", channel, kicked, parsePrefix(prefix), reason) +handlers["KICK"] = function(conn, msg) + conn:invoke("OnKick", msg.args[1], msg.args[2], msg.user, msg.args[3]) end ---RPL_UMODEIS ---To answer a query about a client's own mode, RPL_UMODEIS is sent back -handlers["221"] = function(o, prefix, user, modes) - o:invoke("OnUserMode", modes) +-- RPL_UMODEIS +-- To answer a query about a client's own mode, RPL_UMODEIS is sent back +handlers["221"] = function(conn, msg) + conn:invoke("OnUserMode", msg.args[2]) end ---RPL_CHANNELMODEIS ---The result from common irc servers differs from that defined by the rfc -handlers["324"] = function(o, prefix, user, channel, modes) - o:invoke("OnChannelMode", channel, modes) +-- RPL_CHANNELMODEIS +-- The result from common irc servers differs from that defined by the rfc +handlers["324"] = function(conn, msg) + conn:invoke("OnChannelMode", msg.args[2], msg.args[3]) end -handlers["MODE"] = function(o, prefix, target, modes, ...) - if o.track_users and target ~= o.nick then +handlers["MODE"] = function(conn, msg) + local target = msg.args[1] + local modes = msg.args[2] + local optList = {} + for i = 3, #msg.args do + table.insert(optList, msg.args[i]) + end + if conn.track_users and target ~= conn.nick then local add = true - local optList = {...} + local argNum = 1 + util.updatePrefixModes(conn) for c in modes:gmatch(".") do if c == "+" then add = true elseif c == "-" then add = false - elseif c == "o" then - local user = table.remove(optList, 1) - o.channels[target].users[user].access.op = add - elseif c == "h" then - local user = table.remove(optList, 1) - o.channels[target].users[user].access.halfop = add - elseif c == "v" then - local user = table.remove(optList, 1) - o.channels[target].users[user].access.voice = add + elseif conn.modeprefix[c] then + local nick = optList[argNum] + argNum = argNum + 1 + local user = conn.channels[target].users[nick] + user.access = user.access or {} + local access = user.access + access[c] = add + if c == "o" then access.op = add + elseif c == "v" then access.voice = add + end end end end - o:invoke("OnModeChange", parsePrefix(prefix), target, modes, ...) + conn:invoke("OnModeChange", msg.user, target, modes, unpack(optList)) end -handlers["ERROR"] = function(o, prefix, message) - o:invoke("OnDisconnect", message, true) - o:shutdown() - error(message, 3) +handlers["ERROR"] = function(conn, msg) + conn:invoke("OnDisconnect", msg.args[1], true) + conn:shutdown() + error(msg.args[1], 3) end + +return handlers + diff --git a/init.lua b/init.lua index 99ae7f5..194b692 100644 --- a/init.lua +++ b/init.lua @@ -1,46 +1,47 @@ -local socket = require "socket" - -local error = error -local setmetatable = setmetatable -local rawget = rawget -local unpack = unpack -local pairs = pairs -local assert = assert -local require = require -local tonumber = tonumber -local type = type -local pcall = pcall - -module "irc" +local socket = require("socket") +local util = require("irc.util") +local handlers = require("irc.handlers") +local msgs = require("irc.messages") +local Message = msgs.Message local meta = {} meta.__index = meta -_META = meta -require "irc.util" -require "irc.asyncoperations" -require "irc.handlers" + +for k, v in pairs(require("irc.asyncoperations")) do + meta[k] = v +end local meta_preconnect = {} function meta_preconnect.__index(o, k) local v = rawget(meta_preconnect, k) - if not v and meta[k] then + if v == nil and meta[k] ~= nil then error(("field '%s' is not accessible before connecting"):format(k), 2) end return v end +meta.connected = true +meta_preconnect.connected = false + function new(data) local o = { nick = assert(data.nick, "Field 'nick' is required"); username = data.username or "lua"; realname = data.realname or "Lua owns"; - nickGenerator = data.nickGenerator or defaultNickGenerator; + nickGenerator = data.nickGenerator or util.defaultNickGenerator; hooks = {}; track_users = true; + supports = {}; + messageQueue = {}; + lastThought = 0; + recentMessages = 0; + availableCapabilities = {}; + wantedCapabilities = {}; + capabilities = {}; } - assert(checkNick(o.nick), "Erroneous nickname passed to irc.new") + assert(util.checkNick(o.nick), "Erroneous nickname passed to irc.new") return setmetatable(o, meta_preconnect) end @@ -66,9 +67,10 @@ meta_preconnect.unhook = meta.unhook function meta:invoke(name, ...) local hooks = self.hooks[name] if hooks then - for id,f in pairs(hooks) do - if f(...) then - return true + for id, f in pairs(hooks) do + local ret = f(...) + if ret then + return ret end end end @@ -110,7 +112,7 @@ function meta_preconnect:connect(_host, _port) end s = ssl.wrap(s, params) - success, errmsg = s:dohandshake() + local success, errmsg = s:dohandshake() if not success then error(("could not make secure connection: %s"):format(errmsg), 2) end @@ -119,17 +121,14 @@ function meta_preconnect:connect(_host, _port) self.socket = s setmetatable(self, meta) - self:send("CAP REQ multi-prefix") - self:invoke("PreRegister", self) - self:send("CAP END") if password then - self:send("PASS %s", password) + self:queue(Message({command="PASS", args={password}})) end - self:send("NICK %s", self.nick) - self:send("USER %s 0 * :%s", self.username, self.realname) + self:queue(msgs.nick(self.nick)) + self:queue(Message({command="USER", args={self.username, "0", "*", self.realname}})) self.channels = {} @@ -137,7 +136,7 @@ function meta_preconnect:connect(_host, _port) repeat self:think() - socket.select(nil, nil, 0.1) -- Sleep so that we don't eat CPU + socket.sleep(0.1) until self.authed end @@ -145,14 +144,14 @@ function meta:disconnect(message) message = message or "Bye!" self:invoke("OnDisconnect", message, false) - self:send("QUIT :%s", message) + self:send(msgs.quit(message)) self:shutdown() end function meta:shutdown() self.socket:close() - setmetatable(self, nil) + setmetatable(self, meta_preconnect) end local function getline(self, errlevel) @@ -172,21 +171,35 @@ function meta:think() local line = getline(self, 3) if line and #line > 0 then if not self:invoke("OnRaw", line) then - self:handle(parse(line)) + self:handle(Message({raw=line})) end else break end end -end -local handlers = handlers + -- Handle outgoing message queue + local diff = socket.gettime() - self.lastThought + self.recentMessages = self.recentMessages - (diff * 2) + if self.recentMessages < 0 then + self.recentMessages = 0 + end + for i = 1, #self.messageQueue do + if self.recentMessages > 4 then + break + end + self:send(table.remove(self.messageQueue, 1)) + self.recentMessages = self.recentMessages + 1 + end + self.lastThought = socket.gettime() +end -function meta:handle(prefix, cmd, params) - local handler = handlers[cmd] +function meta:handle(msg) + local handler = handlers[msg.command] if handler then - return handler(self, prefix, unpack(params)) + handler(self, msg) end + self:invoke("Do" .. util.capitalize(msg.command), msg) end local whoisHandlers = { @@ -198,22 +211,22 @@ local whoisHandlers = { } function meta:whois(nick) - self:send("WHOIS %s", nick) + self:send(msgs.whois(nick)) local result = {} while true do local line = getline(self, 3) if line then - local prefix, cmd, args = parse(line) + local msg = Message({raw=line}) - local handler = whoisHandlers[cmd] + local handler = whoisHandlers[msg.command] if handler then - result[handler] = args - elseif cmd == "318" then + result[handler] = msg.args + elseif msg.command == "318" then break else - self:handle(prefix, cmd, args) + self:handle(msg) end end end @@ -228,6 +241,17 @@ function meta:whois(nick) end function meta:topic(channel) - self:send("TOPIC %s", channel) + self:queue(msgs.topic(channel)) end +return { + new = new; + + Message = Message; + msgs = msgs; + + color = util.color; + bold = util.bold; + underline = util.underline; +} + diff --git a/messages.lua b/messages.lua new file mode 100644 index 0000000..48aa687 --- /dev/null +++ b/messages.lua @@ -0,0 +1,207 @@ + +-- Module table +local m = {} + +local msg_meta = {} +msg_meta.__index = msg_meta + +local function Message(opts) + opts = opts or {} + setmetatable(opts, msg_meta) + if opts.raw then + opts:fromRFC1459(opts.raw) + end + return opts +end + +m.Message = Message + +local tag_escapes = { + [";"] = "\\:", + [" "] = "\\s", + ["\0"] = "\\0", + ["\\"] = "\\\\", + ["\r"] = "\\r", + ["\n"] = "\\n", +} + +local tag_unescapes = {} +for x, y in pairs(tag_escapes) do tag_unescapes[y] = x end + +function msg_meta:toRFC1459() + local s = "" + + if self.tags then + s = s.."@" + for key, value in pairs(self.tags) do + s = s..key + if value ~= true then + value = value:gsub("[; %z\\\r\n]", tag_escapes) + s = s.."="..value + end + s = s..";" + end + -- Strip trailing semicolon + s = s:sub(1, -2) + s = s.." " + end + + s = s..self.command + + local argnum = #self.args + for i = 1, argnum do + local arg = self.args[i] + local startsWithColon = (arg:sub(1, 1) == ":") + local hasSpace = arg:find(" ") + if i == argnum and (hasSpace or startsWithColon) then + s = s.." :" + else + assert(not hasSpace and not startsWithColon, + "Message arguments can not be " + .."serialized to RFC1459 format") + s = s.." " + end + s = s..arg + end + + return s +end + +local function parsePrefix(prefix) + local user = {} + user.nick, user.username, user.host = prefix:match("^(.+)!(.+)@(.+)$") + if not user.nick and prefix:find(".", 1, true) then + user.server = prefix + end + return user +end + +function msg_meta:fromRFC1459(line) + -- IRCv3 tags + if line:sub(1, 1) == "@" then + self.tags = {} + local space = line:find(" ", 1, true) + -- For each semicolon-delimited section from after + -- the @ character to before the space character. + for tag in line:sub(2, space - 1):gmatch("([^;]+)") do + local eq = tag:find("=", 1, true) + if eq then + self.tags[tag:sub(1, eq - 1)] = + tag:sub(eq + 1):gsub("\\([:s0\\rn])", tag_unescapes) + else + self.tags[tag] = true + end + end + line = line:sub(space + 1) + end + + if line:sub(1, 1) == ":" then + local space = line:find(" ", 1, true) + self.prefix = line:sub(2, space - 1) + self.user = parsePrefix(self.prefix) + line = line:sub(space + 1) + end + + local pos + self.command, pos = line:match("(%S+)()") + line = line:sub(pos) + + self.args = self.args or {} + for pos, param in line:gmatch("()(%S+)") do + if param:sub(1, 1) == ":" then + param = line:sub(pos + 1) + table.insert(self.args, param) + break + end + table.insert(self.args, param) + end +end + +function m.privmsg(to, text) + return Message({command="PRIVMSG", args={to, text}}) +end + +function m.notice(to, text) + return Message({command="NOTICE", args={to, text}}) +end + +function m.action(to, text) + return Message({command="PRIVMSG", args={to, ("\x01ACTION %s\x01"):format(text)}}) +end + +function m.ctcp(command, to, args) + s = "\x01"..command + if args then + s = ' '..args + end + s = s..'\x01' + return Message({command="PRIVMSG", args={to, s}}) +end + +function m.kick(channel, target, reason) + return Message({command="KICK", args={channel, target, reason}}) +end + +function m.join(channel, key) + return Message({command="JOIN", args={channel, key}}) +end + +function m.part(channel, reason) + return Message({command="PART", args={channel, reason}}) +end + +function m.quit(reason) + return Message({command="QUIT", args={reason}}) +end + +function m.kill(target, reason) + return Message({command="KILL", args={target, reason}}) +end + +function m.kline(time, mask, reason, operreason) + local args = nil + if time then + args = {time, mask, reason..'|'..operreason} + else + args = {mask, reason..'|'..operreason} + end + return Message({command="KLINE", args=args}) +end + +function m.whois(nick, server) + local args = nil + if server then + args = {server, nick} + else + args = {nick} + end + return Message({command="WHOIS", args=args}) +end + +function m.topic(channel, text) + return Message({command="TOPIC", args={channel, text}}) +end + +function m.invite(channel, target) + return Message({command="INVITE", args={channel, target}}) +end + +function m.nick(nick) + return Message({command="NICK", args={nick}}) +end + +function m.mode(target, modes) + -- We have to split the modes parameter because the mode string and + -- each parameter are seperate arguments (The first command is incorrect) + -- MODE foo :+ov Nick1 Nick2 + -- MODE foo +ov Nick1 Nick2 + local mt = util.split(modes) + return Message({command="MODE", args={target, unpack(mt)}}) +end + +function m.cap(cmd, ...) + return Message({command="CAP", args={cmd, ...}}) +end + +return m + diff --git a/set.lua b/set.lua index 13952c3..a4c6881 100644 --- a/set.lua +++ b/set.lua @@ -1,17 +1,10 @@ -local select = require "socket".select - -local setmetatable = setmetatable -local insert = table.insert -local remove = table.remove -local ipairs = ipairs -local error = error - -module "irc.set" +local select = require("socket").select +local m = {} local set = {} set.__index = set -function new(t) +function m.new(t) t.connections = {} t.sockets = {} return setmetatable(t, set) @@ -54,3 +47,6 @@ function set:poll() local read, err = self:select() return err == "timeout" and self.connections or read end + +return m + diff --git a/util.lua b/util.lua index 2bdb222..5916857 100644 --- a/util.lua +++ b/util.lua @@ -1,82 +1,48 @@ -local setmetatable = setmetatable -local sub = string.sub -local byte = string.byte -local char = string.char -local table = table -local assert = assert -local tostring = tostring -local type = type -local random = math.random -module "irc" +-- Module table +local m = {} ---protocol parsing -function parse(line) - local prefix - local lineStart = 1 - if line:sub(1,1) == ":" then - local space = line:find(" ") - prefix = line:sub(2, space-1) - lineStart = space +function m.updatePrefixModes(conn) + if conn.prefixmode and conn.modeprefix then + return end - - local _, trailToken = line:find("%s+:", lineStart) - local lineStop = line:len() - local trailing - if trailToken then - trailing = line:sub(trailToken + 1) - lineStop = trailToken - 2 - end - - local params = {} - - local _, cmdEnd, cmd = line:find("(%S+)", lineStart) - local pos = cmdEnd + 1 - while true do - local _, stop, param = line:find("(%S+)", pos) - - if not param or stop > lineStop then - break + conn.prefixmode = {} + conn.modeprefix = {} + if conn.supports.PREFIX then + local modes, prefixes = conn.supports.PREFIX:match("%(([^%)]*)%)(.*)") + for i = 1, #modes do + conn.prefixmode[prefixes:sub(i, i)] = modes:sub(i, i) + conn.modeprefix[ modes:sub(i, i)] = prefixes:sub(i, i) end - - pos = stop + 1 - params[#params + 1] = param + else + conn.prefixmode['@'] = 'o' + conn.prefixmode['+'] = 'v' + conn.modeprefix['o'] = '@' + conn.modeprefix['v'] = '+' end - - if trailing then - params[#params + 1] = trailing - end - - return prefix, cmd, params -end - -function parseNick(nick) - local access, name = nick:match("^([%+@]*)(.+)$") - return parseAccess(access or ""), name end -function parsePrefix(prefix) - local user = {} - if prefix then - user.access, user.nick, user.username, user.host = prefix:match("^([%+@]*)(.+)!(.+)@(.+)$") - end - user.access = parseAccess(user.access or "") - return user -end - -function parseAccess(accessString) - local access = {op = false, halfop = false, voice = false} - for c in accessString:gmatch(".") do - if c == "@" then access.op = true - elseif c == "%" then access.halfop = true - elseif c == "+" then access.voice = true +function m.parseNick(conn, nick) + local access = {} + m.updatePrefixModes(conn) + local namestart = 1 + for i = 1, #nick - 1 do + local c = nick:sub(i, i) + if conn.prefixmode[c] then + access[conn.prefixmode[c]] = true + else + namestart = i + break end end - return access + access.op = access.o + access.voice = access.v + local name = nick:sub(namestart) + return access, name end ---mIRC markup scheme (de-facto standard) -color = { +-- mIRC markup scheme (de-facto standard) +m.color = { black = 1, blue = 2, green = 3, @@ -95,42 +61,60 @@ color = { white = 16 } -local colByte = char(3) -setmetatable(color, {__call = function(_, text, colornum) - colornum = type(colornum) == "string" and assert(color[colornum], "Invalid color '"..colornum.."'") or colornum +local colByte = string.char(3) +setmetatable(m.color, {__call = function(_, text, colornum) + colornum = (type(colornum) == "string" and + assert(color[colornum], "Invalid color '"..colornum.."'") or + colornum) return table.concat{colByte, tostring(colornum), text, colByte} end}) -local boldByte = char(2) -function bold(text) +local boldByte = string.char(2) +function m.bold(text) return boldByte..text..boldByte end -local underlineByte = char(31) -function underline(text) +local underlineByte = string.char(31) +function m.underline(text) return underlineByte..text..underlineByte end -function checkNick(nick) +function m.checkNick(nick) return nick:find("^[a-zA-Z_%-%[|%]%^{|}`][a-zA-Z0-9_%-%[|%]%^{|}`]*$") ~= nil end -function defaultNickGenerator(nick) +function m.defaultNickGenerator(nick) -- LuaBot -> LuaCot -> LuaCou -> ... - -- We change a random charachter rather than appending to the + -- We change a random character rather than appending to the -- nickname as otherwise the new nick could exceed the ircd's -- maximum nickname length. - local randindex = random(1, #nick) - local randchar = sub(nick, randindex, randindex) - local b = byte(randchar) + local randindex = math.random(1, #nick) + local randchar = string.sub(nick, randindex, randindex) + local b = string.byte(randchar) b = b + 1 if b < 65 or b > 125 then b = 65 end -- Get the halves before and after the changed character - local first = sub(nick, 1, randindex - 1) - local last = sub(nick, randindex + 1, #nick) - nick = first..char(b)..last -- Insert the new charachter + local first = string.sub(nick, 1, randindex - 1) + local last = string.sub(nick, randindex + 1, #nick) + nick = first .. string.char(b) .. last -- Insert the new charachter return nick end +function m.capitalize(text) + -- Converts first character to upercase and the rest to lowercase. + -- "PING" -> "Ping" | "hello" -> "Hello" | "123" -> "123" + return text:sub(1, 1):upper()..text:sub(2):lower() +end + +function m.split(str, sep) + local t = {} + for s in str:gmatch("%S+") do + table.insert(t, s) + end + return t +end + +return m +