diff --git a/irc/LICENSE.txt b/irc/LICENSE.txt new file mode 100644 index 0000000..0a68ca1 --- /dev/null +++ b/irc/LICENSE.txt @@ -0,0 +1,26 @@ +--[[ + Lua IRC library + + Copyright (c) 2010 Jakob Ovrum + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE.]] + diff --git a/irc/README.markdown b/irc/README.markdown new file mode 100644 index 0000000..8cff1d2 --- /dev/null +++ b/irc/README.markdown @@ -0,0 +1,19 @@ +[![Build Status](https://travis-ci.org/JakobOvrum/LuaIRC.svg?branch=master)](https://travis-ci.org/JakobOvrum/LuaIRC) +LuaIRC +============ + +IRC client library for Lua. + +Dependencies +------------- + + * [LuaSocket](http://w3.impa.br/~diego/software/luasocket/) + +**Only required if you want to make use of the TLS support** + + * [LuaSec](http://www.inf.puc-rio.br/~brunoos/luasec/) + +Documentation +------------- +Documentation can be automatically generated by passing irc.luadoc (in doc/) to [LuaDoc](http://luadoc.luaforge.net/), or pre-generated documentation can be found in the 'gh-pages' branch, which can also be browsed [online](http://jakobovrum.github.com/LuaIRC/doc/modules/irc.html). + diff --git a/irc/asyncoperations.lua b/irc/asyncoperations.lua new file mode 100644 index 0000000..7531b28 --- /dev/null +++ b/irc/asyncoperations.lua @@ -0,0 +1,92 @@ +local msgs = require("irc.messages") + +local meta = {} + +function meta:send(msg, ...) + if type(msg) == "table" then + msg = msg:toRFC1459() + else + if select("#", ...) > 0 then + msg = msg:format(...) + end + end + self:invoke("OnSend", msg) + + local bytes, err = self.socket:send(msg .. "\r\n") + + if not bytes and err ~= "timeout" and err ~= "wantwrite" then + self:invoke("OnDisconnect", err, true) + self:shutdown() + error(err, errlevel) + 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) + end + + return str +end + +function meta:sendChat(target, msg) + -- Split the message into segments if it includes newlines. + for line in msg:gmatch("([^\r\n]+)") do + 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:queue(msgs.notice(verify(target, 3), line)) + end +end + +function meta:join(channel, key) + self:queue(msgs.join( + verify(channel, 3), + key and verify(key, 3) or nil)) +end + +function meta:part(channel, reason) + channel = verify(channel, 3) + self:queue(msgs.part(channel, reason)) + if self.track_users then + self.channels[channel] = nil + end +end + +function meta:trackUsers(b) + self.track_users = b + if not b then + for k,v in pairs(self.channels) do + self.channels[k] = nil + end + end +end + +function meta:setMode(t) + local target = t.target or self.nick + local mode = "" + local add, rem = t.add, t.remove + + assert(add or rem, "table contains neither 'add' nor 'remove'") + + if add then + mode = table.concat{"+", verify(add, 3)} + end + + if rem then + mode = table.concat{mode, "-", verify(rem, 3)} + end + + self:queue(msgs.mode(verify(target, 3), mode)) +end + +return meta + diff --git a/irc/doc/irc.luadoc b/irc/doc/irc.luadoc new file mode 100644 index 0000000..c33a0b1 --- /dev/null +++ b/irc/doc/irc.luadoc @@ -0,0 +1,198 @@ +--- LuaIRC is a low-level IRC library for Lua. +-- All functions raise Lua exceptions on error. +-- +-- Use new to create a new Connection object.
+-- Example:

+-- +--require "irc"
+--local sleep = require "socket".sleep
+--
+--local s = irc.new{nick = "example"}
+--
+--s:hook("OnChat", function(user, channel, message)
+-- print(("[%s] %s: %s"):format(channel, user.nick, message))
+--end)
+--
+--s:connect("irc.example.net")
+--s:join("#example")
+--
+--while true do
+-- s:think()
+-- sleep(0.5)
+--end
+--
+ +module "irc" + +--- 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 Connection object. +-- @see Connection +function new(user) + +--- Hook a function to an event. +-- @param name Name of event. +-- @param id Unique tag. +-- @param f Callback function. [defaults to id] +-- @see Hooks +function irc:hook(name, id, f) + +--- Remove previous hooked callback. +-- @param name Name of event. +-- @param id Unique tag. +function irc:unhook(name, id) + +--- Connect irc to an IRC server. +-- @param host Host address. +-- @param port Server port. [default 6667] +function irc:connect(host, port) + +-- @param table Table of connection details +-- @see ConnectOptions +function irc:connect(table) + +--- Disconnect irc from the server. +-- @param message Quit message. +function irc:disconnect(message) + +--- Handle incoming data for irc, and invoke previously hooked callbacks based on new server input. +-- You should call this in some kind of main loop, or at least often enough to not time out. +function irc:think() + +--- Look up user info. +-- @param nick Nick of user to query. +-- @return Table with fields userinfo, node, channels and account. +function irc:whois(nick) + +--- Look up topic. +-- Use this to invoke the hooks OnTopic and OnTopicInfo at any time. +-- @param channel Channel to query. +function irc:topic(channel) + +--- 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 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 text. +function irc:sendNotice(target, message) + +--- Join a channel. +-- @param channel Channel to join. +-- @param key Channel key. [optional] +function irc:join(channel, key) + +--- Leave a channel. +-- @param channel Channel to leave. +function irc:part(channel) + +--- Turn user information tracking on or off. User tracking is enabled by default. +-- @param b Boolean whether or not to track user information. +function irc:trackUsers(b) + +--- Add/remove modes for a channel or nick. +-- @param t Table with fields target, nick, add and/or rem. target or nick +-- specifies the user or channel to add/remove modes. add is a list of modes to add to the user or channel. +-- rem is a list of modes to remove from the user or channel. +-- @usage Example which sets +m (moderated) for #channel:
+-- irc:setMode{target = "#channel", add = "m"} +function irc:setMode(t) + +--internal +function irc:invoke(name, ...) +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. +-- +-- * Event also invoked for yourself. +-- † Channel passed only when user tracking is enabled +-- @name Hooks +-- @class table + +--- Table with information about a user. +-- +-- Fields may be missing. To fill them in, enable user tracking and use irc:whois. +-- @name User +-- @class table + diff --git a/irc/handlers.lua b/irc/handlers.lua new file mode 100644 index 0000000..f01936f --- /dev/null +++ b/irc/handlers.lua @@ -0,0 +1,277 @@ +local util = require("irc.util") +local msgs = require("irc.messages") +local Message = msgs.Message + +local 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["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(conn, msg) + conn.authed = true + conn.nick = msg.args[1] +end + +handlers["PRIVMSG"] = function(conn, msg) + conn:invoke("OnChat", msg.user, msg.args[1], msg.args[2]) +end + +handlers["NOTICE"] = function(conn, msg) + conn:invoke("OnNotice", msg.user, msg.args[1], msg.args[2]) +end + +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 + conn.channels[channel].users[msg.user.nick] = msg.user + end + end + + conn:invoke("OnJoin", msg.user, msg.args[1]) +end + +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 + conn.channels[channel].users[msg.user.nick] = nil + end + end + conn:invoke("OnPart", msg.user, msg.args[1], msg.args[2]) +end + +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 + conn:invoke("OnQuit", msg.user, msg.args[1], msg.args[2]) +end + +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[msg.user.nick] = nil + conn:invoke("NickChange", msg.user, newNick, chanName) + end + end + else + conn:invoke("NickChange", msg.user, newNick) + end + if msg.user.nick == conn.nick then + conn.nick = newNick + end +end + +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) +handlers["432"] = needNewNick + +-- ERR_NICKNAMEINUSE +handlers["433"] = needNewNick + +-- 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 + +-- 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 = util.parseNick(conn, nick) + users[name] = {access = access} + end + end +end + +-- 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(conn, msg) + conn:invoke("OnTopic", msg.args[2], nil) +end + +handlers["TOPIC"] = function(conn, msg) + conn:invoke("OnTopic", msg.args[1], msg.args[2]) +end + +handlers["332"] = function(conn, msg) + conn:invoke("OnTopic", msg.args[2], msg.args[3]) +end + +-- 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(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(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(conn, msg) + conn:invoke("OnChannelMode", msg.args[2], msg.args[3]) +end + +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 argNum = 1 + util.updatePrefixModes(conn) + for c in modes:gmatch(".") do + if c == "+" then add = true + elseif c == "-" then add = false + 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 + conn:invoke("OnModeChange", msg.user, target, modes, unpack(optList)) +end + +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/irc/init.lua b/irc/init.lua new file mode 100644 index 0000000..5c49e00 --- /dev/null +++ b/irc/init.lua @@ -0,0 +1,257 @@ +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 + + +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 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 util.defaultNickGenerator; + hooks = {}; + track_users = true; + supports = {}; + messageQueue = {}; + lastThought = 0; + recentMessages = 0; + availableCapabilities = {}; + wantedCapabilities = {}; + capabilities = {}; + } + assert(util.checkNick(o.nick), "Erroneous nickname passed to irc.new") + return setmetatable(o, meta_preconnect) +end + +function meta:hook(name, id, f) + f = f or id + self.hooks[name] = self.hooks[name] or {} + self.hooks[name][id] = f + return id or f +end +meta_preconnect.hook = meta.hook + + +function meta:unhook(name, id) + local hooks = self.hooks[name] + + assert(hooks, "no hooks exist for this event") + assert(hooks[id], "hook ID not found") + + hooks[id] = nil +end +meta_preconnect.unhook = meta.unhook + +function meta:invoke(name, ...) + local hooks = self.hooks[name] + if hooks then + for id, f in pairs(hooks) do + local ret = f(...) + if ret then + return ret + end + end + end +end + +function meta_preconnect:connect(_host, _port) + local host, port, password, secure, timeout + + if type(_host) == "table" then + host = _host.host + port = _host.port + timeout = _host.timeout + password = _host.password + secure = _host.secure + else + host = _host + port = _port + end + + host = host or error("host name required to connect", 2) + port = port or 6667 + + local s = socket.tcp() + + s:settimeout(timeout or 30) + assert(s:connect(host, port)) + + if secure then + local work, ssl = pcall(require, "ssl") + if not work then + error("LuaSec required for secure connections", 2) + end + + local params + if type(secure) == "table" then + params = secure + else + params = {mode = "client", protocol = "any"} + end + + s = ssl.wrap(s, params) + local success, errmsg = s:dohandshake() + if not success then + error(("could not make secure connection: %s"):format(errmsg), 2) + end + end + + self.socket = s + setmetatable(self, meta) + + self:invoke("PreRegister", self) + + if password then + self:queue(Message({command="PASS", args={password}})) + end + + self:queue(msgs.nick(self.nick)) + self:queue(Message({command="USER", args={self.username, "0", "*", self.realname}})) + + self.channels = {} + + s:settimeout(0) + + repeat + self:think() + socket.sleep(0.1) + until self.authed +end + +function meta:disconnect(message) + message = message or "Bye!" + + self:invoke("OnDisconnect", message, false) + self:send(msgs.quit(message)) + + self:shutdown() +end + +function meta:shutdown() + self.socket:close() + setmetatable(self, meta_preconnect) +end + +local function getline(self, errlevel) + local line, err = self.socket:receive("*l") + + if not line and err ~= "timeout" and err ~= "wantread" then + self:invoke("OnDisconnect", err, true) + self:shutdown() + error(err, errlevel) + end + + return line +end + +function meta:think() + while true do + local line = getline(self, 3) + if line and #line > 0 then + if not self:invoke("OnRaw", line) then + self:handle(Message({raw=line})) + end + else + break + end + end + + -- 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(msg) + local handler = handlers[msg.command] + if handler then + handler(self, msg) + end + self:invoke("Do" .. util.capitalize(msg.command), msg) +end + +local whoisHandlers = { + ["311"] = "userinfo"; + ["312"] = "node"; + ["319"] = "channels"; + ["330"] = "account"; -- Freenode + ["307"] = "registered"; -- Unreal +} + +function meta:whois(nick) + self:send(msgs.whois(nick)) + + local result = {} + + while true do + local line = getline(self, 3) + if line then + local msg = Message({raw=line}) + + local handler = whoisHandlers[msg.command] + if handler then + result[handler] = msg.args + elseif msg.command == "318" then + break + else + self:handle(msg) + end + end + end + + if result.account then + result.account = result.account[3] + elseif result.registered then + result.account = result.registered[2] + end + + return result +end + +function meta:topic(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/irc/messages.lua b/irc/messages.lua new file mode 100644 index 0000000..48aa687 --- /dev/null +++ b/irc/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/irc/push-luadoc.sh b/irc/push-luadoc.sh new file mode 100644 index 0000000..6f77ff2 --- /dev/null +++ b/irc/push-luadoc.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +if [ "$TRAVIS_REPO_SLUG" == "JakobOvrum/LuaIRC" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_BRANCH" == "master" ]; then + + echo -e "Generating luadoc...\n" + + git config --global user.email "travis@travis-ci.org" + git config --global user.name "travis-ci" + git clone --quiet --branch=gh-pages https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG} gh-pages > /dev/null + + cd gh-pages + git rm -rf ./doc + sh ./generate.sh + git add -f ./doc + git commit -m "Lastest documentation on successful travis build $TRAVIS_BUILD_NUMBER auto-pushed to gh-pages" + git push -fq origin gh-pages > /dev/null + + echo -e "Published luadoc to gh-pages.\n" +fi diff --git a/irc/set.lua b/irc/set.lua new file mode 100644 index 0000000..a4c6881 --- /dev/null +++ b/irc/set.lua @@ -0,0 +1,52 @@ +local select = require("socket").select + +local m = {} +local set = {} +set.__index = set + +function m.new(t) + t.connections = {} + t.sockets = {} + return setmetatable(t, set) +end + +function set:add(connection) + local socket = connection.socket + insert(self.sockets, socket) + + self.connections[socket] = connection + insert(self.connections, connection) +end + +function set:remove(connection) + local socket = connection.socket + self.connections[socket] = nil + for k, s in ipairs(self.sockets) do + if socket == s then + remove(self.sockets, k) + remove(self.connections, k) + break + end + end +end + +function set:select() + local read, write, err = select(self.sockets, nil, self.timeout) + + if read then + for k, socket in ipairs(read) do + read[k] = self.connections[socket] + end + end + + return read, err +end + +-- Select - but if it times out, it returns all connections. +function set:poll() + local read, err = self:select() + return err == "timeout" and self.connections or read +end + +return m + diff --git a/irc/util.lua b/irc/util.lua new file mode 100644 index 0000000..5916857 --- /dev/null +++ b/irc/util.lua @@ -0,0 +1,120 @@ + +-- Module table +local m = {} + +function m.updatePrefixModes(conn) + if conn.prefixmode and conn.modeprefix then + return + end + 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 + else + conn.prefixmode['@'] = 'o' + conn.prefixmode['+'] = 'v' + conn.modeprefix['o'] = '@' + conn.modeprefix['v'] = '+' + end +end + +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 + access.op = access.o + access.voice = access.v + local name = nick:sub(namestart) + return access, name +end + +-- mIRC markup scheme (de-facto standard) +m.color = { + black = 1, + blue = 2, + green = 3, + red = 4, + lightred = 5, + purple = 6, + brown = 7, + yellow = 8, + lightgreen = 9, + navy = 10, + cyan = 11, + lightblue = 12, + violet = 13, + gray = 14, + lightgray = 15, + white = 16 +} + +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 = string.char(2) +function m.bold(text) + return boldByte..text..boldByte +end + +local underlineByte = string.char(31) +function m.underline(text) + return underlineByte..text..underlineByte +end + +function m.checkNick(nick) + return nick:find("^[a-zA-Z_%-%[|%]%^{|}`][a-zA-Z0-9_%-%[|%]%^{|}`]*$") ~= nil +end + +function m.defaultNickGenerator(nick) + -- LuaBot -> LuaCot -> LuaCou -> ... + -- 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 = 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 = 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 +