2 -- Implementation of the main LuaIRC module
6 local constants = require 'irc.constants'
7 local irc_debug = require 'irc.debug'
8 local message = require 'irc.message'
9 local misc = require 'irc.misc'
10 local socket = require 'socket'
11 local os = require 'os'
12 local string = require 'string'
13 local table = require 'table'
17 -- LuaIRC - IRC framework written in Lua
22 _VERSION = 'LuaIRC 0.2'
26 local Channel = base.require 'irc.channel'
29 -- local variables {{{
43 local requestinfo = {whois = {}}
45 local ctcp_handlers = {}
50 TIMEOUT = 60 -- connection timeout
51 NETWORK = "localhost" -- default network
52 PORT = 6667 -- default port
53 NICK = "luabot" -- default nick
54 USERNAME = "LuaIRC" -- default username
55 REALNAME = "LuaIRC" -- default realname
56 DEBUG = false -- whether we want extra debug information
57 OUTFILE = nil -- file to send debug output to - nil is stdout
60 -- private functions {{{
62 local function main_loop_iter()
63 if #rsockets == 0 and #wsockets == 0 then return false end
64 local rready, wready, err = socket.select(rsockets, wsockets)
65 if err then irc_debug.err(err); return false; end
67 for _, sock in base.ipairs(rready) do
68 local cb = socket.protect(rcallbacks[sock])
69 local ret, err = cb(sock)
71 irc_debug.warn("socket error: " .. err)
72 _unregister_socket(sock, 'r')
76 for _, sock in base.ipairs(wready) do
77 local cb = socket.protect(wcallbacks[sock])
78 local ret, err = cb(sock)
80 irc_debug.warn("socket error: " .. err)
81 _unregister_socket(sock, 'w')
89 -- begin_main_loop {{{
90 local function begin_main_loop()
91 while main_loop_iter() do end
95 -- incoming_message {{{
96 local function incoming_message(sock)
97 local raw_msg = socket.try(sock:receive())
98 irc_debug.message("RECV", raw_msg)
99 local msg = message.parse(raw_msg)
100 misc.try_call_warn("Unhandled server message: " .. msg.command,
101 handlers["on_" .. msg.command:lower()],
102 (misc.parse_user(msg.from)), base.unpack(msg.args))
108 -- internal message handlers {{{
109 -- command handlers {{{
111 function handlers.on_nick(from, new_nick)
112 for chan in channels() do
113 chan:change_nick(from, new_nick)
115 misc.try_call(on_nick_change, new_nick, from)
120 function handlers.on_join(from, chan)
121 base.assert(serverinfo.channels[chan],
122 "Received join message for unknown channel: " .. chan)
123 if serverinfo.channels[chan].join_complete then
124 serverinfo.channels[chan]:add_user(from)
125 misc.try_call(on_join, serverinfo.channels[chan], from)
131 function handlers.on_part(from, chan, part_msg)
132 -- don't assert on chan here, since we get part messages for ourselves
133 -- after we remove the channel from the channel list
134 if not serverinfo.channels[chan] then return end
135 if serverinfo.channels[chan].join_complete then
136 serverinfo.channels[chan]:remove_user(from)
137 misc.try_call(on_part, serverinfo.channels[chan], from, part_msg)
143 function handlers.on_mode(from, to, mode_string, ...)
144 local dir = mode_string:sub(1, 1)
145 mode_string = mode_string:sub(2)
148 if to:sub(1, 1) == "#" then
149 -- handle channel mode requests {{{
150 base.assert(serverinfo.channels[to],
151 "Received mode change for unknown channel: " .. to)
152 local chan = serverinfo.channels[to]
154 for i = 1, mode_string:len() do
155 local mode = mode_string:sub(i, i)
156 local target = args[ind]
157 -- channel modes other than op/voice will be implemented as
158 -- information request commands
159 if mode == "o" then -- channel op {{{
160 chan:change_status(target, dir == "+", "o")
161 misc.try_call(({["+"] = on_op, ["-"] = on_deop})[dir],
165 elseif mode == "v" then -- voice {{{
166 chan:change_status(target, dir == "+", "v")
167 misc.try_call(({["+"] = on_voice, ["-"] = on_devoice})[dir],
174 elseif from == to then
175 -- handle user mode requests {{{
176 -- TODO: make users more easily accessible so this is actually
177 -- reasonably possible
178 for i = 1, mode_string:len() do
179 local mode = mode_string:sub(i, i)
180 if mode == "i" then -- invisible {{{
182 elseif mode == "s" then -- server messages {{{
184 elseif mode == "w" then -- wallops messages {{{
186 elseif mode == "o" then -- ircop {{{
196 function handlers.on_topic(from, chan, new_topic)
197 base.assert(serverinfo.channels[chan],
198 "Received topic message for unknown channel: " .. chan)
199 serverinfo.channels[chan]._topic.text = new_topic
200 serverinfo.channels[chan]._topic.user = (misc.parse_user(from))
201 serverinfo.channels[chan]._topic.time = os.time()
202 if serverinfo.channels[chan].join_complete then
203 misc.try_call(on_topic_change, serverinfo.channels[chan])
209 function handlers.on_invite(from, to, chan)
210 misc.try_call(on_invite, from, chan)
215 function handlers.on_kick(from, chan, to)
216 base.assert(serverinfo.channels[chan],
217 "Received kick message for unknown channel: " .. chan)
218 if serverinfo.channels[chan].join_complete then
219 serverinfo.channels[chan]:remove_user(to)
220 misc.try_call(on_kick, serverinfo.channels[chan], to, from)
226 function handlers.on_privmsg(from, to, msg)
227 local msgs = ctcp.ctcp_split(msg, true)
228 for _, v in base.ipairs(msgs) do
229 if base.type(v) == "string" then
230 -- normal message {{{
231 if to:sub(1, 1) == "#" then
232 base.assert(serverinfo.channels[to],
233 "Received channel msg from unknown channel: " .. to)
234 misc.try_call(on_channel_msg, serverinfo.channels[to], from, v)
236 misc.try_call(on_private_msg, from, v)
239 elseif base.type(v) == "table" then
241 local words = misc.split(v[1])
242 local received_command = words[1]
243 local cb = "on_" .. received_command:lower()
244 table.remove(words, 1)
245 -- not using try_call here because the ctcp specification requires
246 -- an error response to nonexistant commands
247 if base.type(ctcp_handlers[cb]) == "function" then
248 ctcp_handlers[cb](from, to, table.concat(words, " "))
250 notice(from, {"ERRMSG Unknown query: " .. received_command})
259 function handlers.on_notice(from, to, msg)
260 local msgs = ctcp.ctcp_split(msg, true)
261 for _, v in base.ipairs(msgs) do
262 if base.type(v) == "string" then
263 -- normal message {{{
264 if to:sub(1, 1) == "#" then
265 base.assert(serverinfo.channels[to],
266 "Received channel msg from unknown channel: " .. to)
267 misc.try_call(on_channel_notice, serverinfo.channels[to],
270 misc.try_call(on_private_notice, from, v)
273 elseif base.type(v) == "table" then
275 local words = misc.split(v[1])
276 local command = words[1]:lower()
277 table.remove(words, 1)
278 misc.try_call_warn("Unknown CTCP message: " .. command,
279 ctcp_handlers["on_rpl_"..command], from, to,
280 table.concat(words, ' '))
288 function handlers.on_quit(from, quit_msg)
289 for name, chan in base.pairs(serverinfo.channels) do
290 chan:remove_user(from)
292 misc.try_call(on_quit, from, quit_msg)
297 -- respond to server pings to make sure it knows we are alive
298 function handlers.on_ping(from, respond_to)
299 send("PONG", respond_to)
304 -- server replies {{{
306 -- catch topic changes
307 function handlers.on_rpl_topic(from, chan, topic)
308 base.assert(serverinfo.channels[chan],
309 "Received topic information about unknown channel: " .. chan)
310 serverinfo.channels[chan]._topic.text = topic
314 -- on_rpl_notopic {{{
315 function handlers.on_rpl_notopic(from, chan)
316 base.assert(serverinfo.channels[chan],
317 "Received topic information about unknown channel: " .. chan)
318 serverinfo.channels[chan]._topic.text = ""
322 -- on_rpl_topicdate {{{
323 -- "topic was set by <user> at <time>"
324 function handlers.on_rpl_topicdate(from, chan, user, time)
325 base.assert(serverinfo.channels[chan],
326 "Received topic information about unknown channel: " .. chan)
327 serverinfo.channels[chan]._topic.user = user
328 serverinfo.channels[chan]._topic.time = base.tonumber(time)
332 -- on_rpl_namreply {{{
333 -- handles a NAMES reply
334 function handlers.on_rpl_namreply(from, chanmode, chan, userlist)
335 base.assert(serverinfo.channels[chan],
336 "Received user information about unknown channel: " .. chan)
337 serverinfo.channels[chan]._chanmode = constants.chanmodes[chanmode]
338 local users = misc.split(userlist)
339 for k,v in base.ipairs(users) do
340 if v:sub(1, 1) == "@" or v:sub(1, 1) == "+" then
341 local nick = v:sub(2)
342 serverinfo.channels[chan]:add_user(nick, v:sub(1, 1))
344 serverinfo.channels[chan]:add_user(v)
350 -- on_rpl_endofnames {{{
351 -- when we get this message, the channel join has completed, so call the
353 function handlers.on_rpl_endofnames(from, chan)
354 base.assert(serverinfo.channels[chan],
355 "Received user information about unknown channel: " .. chan)
356 if not serverinfo.channels[chan].join_complete then
357 misc.try_call(on_me_join, serverinfo.channels[chan])
358 serverinfo.channels[chan].join_complete = true
363 -- on_rpl_welcome {{{
364 function handlers.on_rpl_welcome(from)
373 -- on_rpl_yourhost {{{
374 function handlers.on_rpl_yourhost(from, msg)
375 serverinfo.host = from
379 -- on_rpl_motdstart {{{
380 function handlers.on_rpl_motdstart(from)
386 function handlers.on_rpl_motd(from, motd)
387 serverinfo.motd = (serverinfo.motd or "") .. motd .. "\n"
391 -- on_rpl_endofmotd {{{
392 function handlers.on_rpl_endofmotd(from)
393 if not serverinfo.connected then
394 serverinfo.connected = true
395 serverinfo.connecting = false
396 misc.try_call(on_connect)
401 -- on_rpl_whoisuser {{{
402 function handlers.on_rpl_whoisuser(from, nick, user, host, star, realname)
404 requestinfo.whois[nick].user = user
405 requestinfo.whois[nick].host = host
406 requestinfo.whois[nick].realname = realname
410 -- on_rpl_whoischannels {{{
411 function handlers.on_rpl_whoischannels(from, nick, channel_list)
413 if not requestinfo.whois[nick].channels then
414 requestinfo.whois[nick].channels = {}
416 for _, channel in base.ipairs(misc.split(channel_list)) do
417 table.insert(requestinfo.whois[nick].channels, channel)
422 -- on_rpl_whoisserver {{{
423 function handlers.on_rpl_whoisserver(from, nick, server, serverinfo)
425 requestinfo.whois[nick].server = server
426 requestinfo.whois[nick].serverinfo = serverinfo
431 function handlers.on_rpl_away(from, nick, away_msg)
433 if requestinfo.whois[nick] then
434 requestinfo.whois[nick].away_msg = away_msg
439 -- on_rpl_whoisoperator {{{
440 function handlers.on_rpl_whoisoperator(from, nick)
441 requestinfo.whois[nick:lower()].is_oper = true
445 -- on_rpl_whoisidle {{{
446 function handlers.on_rpl_whoisidle(from, nick, idle_seconds)
447 requestinfo.whois[nick:lower()].idle_time = idle_seconds
451 -- on_rpl_endofwhois {{{
452 function handlers.on_rpl_endofwhois(from, nick)
454 local cb = table.remove(icallbacks.whois[nick], 1)
455 cb(requestinfo.whois[nick])
456 requestinfo.whois[nick] = nil
457 if #icallbacks.whois[nick] > 0 then send("WHOIS", nick)
458 else icallbacks.whois[nick] = nil
463 -- on_rpl_version {{{
464 function handlers.on_rpl_version(from, version, server, comments)
465 local cb = table.remove(icallbacks.serverversion[server], 1)
466 cb({version = version, server = server, comments = comments})
467 if #icallbacks.serverversion[server] > 0 then send("VERSION", server)
468 else icallbacks.serverversion[server] = nil
474 function on_rpl_time(from, server, time)
475 local cb = table.remove(icallbacks.servertime[server], 1)
476 cb({time = time, server = server})
477 if #icallbacks.servertime[server] > 0 then send("TIME", server)
478 else icallbacks.servertime[server] = nil
487 function ctcp_handlers.on_action(from, to, message)
488 if to:sub(1, 1) == "#" then
489 base.assert(serverinfo.channels[to],
490 "Received channel msg from unknown channel: " .. to)
491 misc.try_call(on_channel_act, serverinfo.channels[to], from, message)
493 misc.try_call(on_private_act, from, message)
499 function ctcp_handlers.on_dcc(from, to, message)
500 local type, argument, address, port, size = base.unpack(misc.split(message, " ", nil, '"', '"'))
501 if type == "SEND" then
502 if misc.try_call(on_dcc, from, to, argument, address, port, size) then
503 dcc.accept(argument, address, port, size)
505 elseif type == "CHAT" then
506 -- TODO: implement this? do people ever use this?
512 function ctcp_handlers.on_version(from, to)
513 notice(from, {"VERSION " .. _VERSION .. " running under " .. base._VERSION .. " with " .. socket._VERSION})
518 function ctcp_handlers.on_errmsg(from, to, message)
519 notice(from, {"ERRMSG " .. message .. "No error has occurred"})
524 function ctcp_handlers.on_ping(from, to, timestamp)
525 notice(from, {"PING " .. timestamp})
530 function ctcp_handlers.on_time(from, to)
531 notice(from, {"TIME " .. os.date()})
538 -- actions are handled the same, notice or not
539 ctcp_handlers.on_rpl_action = ctcp_handlers.on_action
542 -- on_rpl_version {{{
543 function ctcp_handlers.on_rpl_version(from, to, version)
544 local cb = table.remove(icallbacks.ctcp_version[from], 1)
545 cb({version = version, nick = from})
546 if #icallbacks.ctcp_version[from] > 0 then say(from, {"VERSION"})
547 else icallbacks.ctcp_version[from] = nil
553 function ctcp_handlers.on_rpl_errmsg(from, to, message)
554 try_call(on_ctcp_error, from, to, message)
559 function ctcp_handlers.on_rpl_ping(from, to, timestamp)
560 local cb = table.remove(icallbacks.ctcp_ping[from], 1)
561 cb({time = os.time() - timestamp, nick = from})
562 if #icallbacks.ctcp_ping[from] > 0 then say(from, {"PING " .. os.time()})
563 else icallbacks.ctcp_ping[from] = nil
569 function ctcp_handlers.on_rpl_time(from, to, time)
570 local cb = table.remove(icallbacks.ctcp_time[from], 1)
571 cb({time = time, nick = from})
572 if #icallbacks.ctcp_time[from] > 0 then say(from, {"TIME"})
573 else icallbacks.ctcp_time[from] = nil
581 -- module functions {{{
582 -- socket handling functions {{{
583 -- _register_socket {{{
585 -- Register a socket to listen on.
586 -- @param sock LuaSocket socket object
587 -- @param mode 'r' if the socket is for reading, 'w' if for writing
588 -- @param cb Callback to call when the socket is ready for reading/writing.
589 -- It will be called with the socket as the single argument.
590 function _register_socket(sock, mode, cb)
599 base.assert(not cbs[sock], "socket already registered")
600 table.insert(socks, sock)
605 -- _unregister_socket {{{
607 -- Remove a previously registered socket.
608 -- @param sock Socket to unregister
609 -- @param mode 'r' to unregister it for reading, 'w' for writing
610 function _unregister_socket(sock, mode)
619 for i, v in base.ipairs(socks) do
620 if v == sock then table.remove(socks, i); break; end
628 -- public functions {{{
629 -- server commands {{{
632 -- Start a connection to the irc server.
633 -- @param args Table of named arguments containing connection parameters.
634 -- Defaults are the all-caps versions of these parameters given
635 -- at the top of the file, and are overridable by setting them
636 -- as well, i.e. <pre>irc.NETWORK = irc.freenode.net</pre>
637 -- Possible options are:
639 -- <li><i>network:</i> address of the irc network to connect to
640 -- (default: 'localhost')</li>
641 -- <li><i>port:</i> port to connect to
642 -- (default: '6667')</li>
643 -- <li><i>pass:</i> irc server password
644 -- (default: don't send)</li>
645 -- <li><i>nick:</i> nickname to connect as
646 -- (default: 'luabot')</li>
647 -- <li><i>username:</i> username to connect with
648 -- (default: 'LuaIRC')</li>
649 -- <li><i>realname:</i> realname to connect with
650 -- (default: 'LuaIRC')</li>
651 -- <li><i>timeout:</i> amount of time in seconds to wait before
652 -- dropping an idle connection
653 -- (default: '60')</li>
655 function connect(args)
656 local network = args.network or NETWORK
657 local port = args.port or PORT
658 local nick = args.nick or NICK
659 local username = args.username or USERNAME
660 local realname = args.realname or REALNAME
661 local timeout = args.timeout or TIMEOUT
662 serverinfo.connecting = true
663 if OUTFILE then irc_debug.set_output(OUTFILE) end
664 if DEBUG then irc_debug.enable() end
665 irc_sock = base.assert(socket.connect(network, port))
666 irc_sock:settimeout(timeout)
667 _register_socket(irc_sock, 'r', incoming_message)
668 if args.pass then send("PASS", args.pass) end
670 send("USER", username, (irc_sock:getsockname()), network, realname)
677 -- Close the connection to the irc server.
678 -- @param message Quit message (optional, defaults to 'Leaving')
679 function quit(message)
680 message = message or "Leaving"
681 send("QUIT", message)
682 serverinfo.connected = false
689 -- @param channel Channel to join
690 function join(channel)
691 if not channel then return end
692 serverinfo.channels[channel] = Channel.new(channel)
693 send("JOIN", channel)
700 -- @param channel Channel to leave
701 function part(channel)
702 if not channel then return end
703 serverinfo.channels[channel] = nil
704 send("PART", channel)
710 -- Send a message to a user or channel.
711 -- @param name User or channel to send the message to
712 -- @param message Message to send
713 function say(name, message)
714 if not name then return end
715 message = message or ""
716 send("PRIVMSG", name, message)
722 -- Send a notice to a user or channel.
723 -- @param name User or channel to send the notice to
724 -- @param message Message to send
725 function notice(name, message)
726 if not name then return end
727 message = message or ""
728 send("NOTICE", name, message)
734 -- Perform a /me action.
735 -- @param name User or channel to send the action to
736 -- @param action Action to send
737 function act(name, action)
738 if not name then return end
739 action = action or ""
740 send("PRIVMSG", name, {"ACTION", action})
745 -- information requests {{{
746 -- server_version {{{
748 -- Request the version of the IRC server you are currently connected to.
749 -- @param cb Callback to call when the information is available. The single
750 -- table parameter to this callback will contain the fields:
752 -- <li><i>server:</i> the server which responded to the request</li>
753 -- <li><i>version:</i> the server version</li>
754 -- <li><i>comments:</i> other data provided by the server</li>
756 function server_version(cb)
757 -- apparently the optional server parameter isn't supported for servers
758 -- which you are not directly connected to (freenode specific?)
759 local server = serverinfo.host
760 if not icallbacks.serverversion[server] then
761 icallbacks.serverversion[server] = {cb}
762 send("VERSION", server)
764 table.insert(icallbacks.serverversion[server], cb)
770 -- TODO: allow server parameter (to get user idle time)
772 -- Request WHOIS information about a given user.
773 -- @param cb Callback to call when the information is available. The single
774 -- table parameter to this callback may contain any or all of the
777 -- <li><i>nick:</i> the nick that was passed to this function
778 -- (this field will always be here)</li>
779 -- <li><i>user:</i> the IRC username of the user</li>
780 -- <li><i>host:</i> the user's hostname</li>
781 -- <li><i>realname:</i> the IRC realname of the user</li>
782 -- <li><i>server:</i> the IRC server the user is connected to</li>
783 -- <li><i>serverinfo:</i> arbitrary information about the above
785 -- <li><i>awaymsg:</i> set to the user's away message if they are
787 -- <li><i>is_oper:</i> true if the user is an IRCop</li>
788 -- <li><i>idle_time:</i> amount of time the user has been idle</li>
789 -- <li><i>channels:</i> array containing the channels the user has
792 -- @param nick User to request WHOIS information about
793 function whois(cb, nick)
795 requestinfo.whois[nick] = {nick = nick}
796 if not icallbacks.whois[nick] then
797 icallbacks.whois[nick] = {cb}
800 table.insert(icallbacks.whois[nick], cb)
807 -- Request the current time of the server you are connected to.
808 -- @param cb Callback to call when the information is available. The single
809 -- table parameter to this callback will contain the fields:
811 -- <li><i>server:</i> the server which responded to the request</li>
812 -- <li><i>time:</i> the time reported by the server</li>
814 function server_time(cb)
815 -- apparently the optional server parameter isn't supported for servers
816 -- which you are not directly connected to (freenode specific?)
817 local server = serverinfo.host
818 if not icallbacks.servertime[server] then
819 icallbacks.servertime[server] = {cb}
822 table.insert(icallbacks.servertime[server], cb)
831 -- Send a CTCP ping request.
832 -- @param cb Callback to call when the information is available. The single
833 -- table parameter to this callback will contain the fields:
835 -- <li><i>nick:</i> the nick which responded to the request</li>
836 -- <li><i>time:</i> the roundtrip ping time, in seconds</li>
838 -- @param nick User to ping
839 function ctcp_ping(cb, nick)
841 if not icallbacks.ctcp_ping[nick] then
842 icallbacks.ctcp_ping[nick] = {cb}
843 say(nick, {"PING " .. os.time()})
845 table.insert(icallbacks.ctcp_ping[nick], cb)
852 -- Send a localtime request.
853 -- @param cb Callback to call when the information is available. The single
854 -- table parameter to this callback will contain the fields:
856 -- <li><i>nick:</i> the nick which responded to the request</li>
857 -- <li><i>time:</i> the localtime reported by the remote client</li>
859 -- @param nick User to request the localtime from
860 function ctcp_time(cb, nick)
862 if not icallbacks.ctcp_time[nick] then
863 icallbacks.ctcp_time[nick] = {cb}
866 table.insert(icallbacks.ctcp_time[nick], cb)
873 -- Send a client version request.
874 -- @param cb Callback to call when the information is available. The single
875 -- table parameter to this callback will contain the fields:
877 -- <li><i>nick:</i> the nick which responded to the request</li>
878 -- <li><i>version:</i> the version reported by the remote client</li>
880 -- @param nick User to request the client version from
881 function ctcp_version(cb, nick)
883 if not icallbacks.ctcp_version[nick] then
884 icallbacks.ctcp_version[nick] = {cb}
885 say(nick, {"VERSION"})
887 table.insert(icallbacks.ctcp_version[nick], cb)
893 -- misc functions {{{
894 -- TODO: CTCP quoting should be explicit, this table thing is quite ugly (if
898 -- Send a raw IRC command.
899 -- @param command String containing the raw IRC command
900 -- @param ... Arguments to the command. Each argument is either a string or
901 -- an array. Strings are sent literally, arrays are CTCP quoted
902 -- as a group. The last argument (if it exists) is preceded by
903 -- a : (so it may contain spaces).
904 function send(command, ...)
905 if not serverinfo.connected and not serverinfo.connecting then return end
906 local message = command
907 for i, v in base.ipairs({...}) do
909 -- passing a table in as an argument means to treat that table as a
910 -- CTCP command, so quote it appropriately
911 if base.type(v) == "string" then
913 elseif base.type(v) == "table" then
914 arg = ctcp.ctcp_quote(table.concat(v, " "))
919 message = message .. " " .. arg
921 message = ctcp.low_quote(message)
922 -- we just truncate for now. -2 to account for the \r\n
923 message = message:sub(1, constants.IRC_MAX_MSG - 2)
924 irc_debug.message("SEND", message)
925 irc_sock:send(message .. "\r\n")
931 -- Get the local IP address for the server connection.
932 -- @return A string representation of the local IP address that the IRC server
933 -- connection is communicating on
935 return (irc_sock:getsockname())
940 -- TODO: @see doesn't currently work for files/modules
942 -- Iterate over currently joined channels.
943 -- channels() is an iterator function for use in for loops.
944 -- For example, <pre>for chan in irc.channels() do print(chan:name) end</pre>
945 -- @see src/irc/channel.lua
947 return function(state, arg)
948 return misc.value_iter(state, arg,
950 return v.join_complete