1 /++
2     Macros:
3         SYMBOL_LINK = $(LINK2 #$1, $1)
4         DDOC = <!DOCTYPE html>
5         <html lang="en">
6         <head>
7             <title>$(TITLE)</title>
8             <meta charset="utf-8" />
9             <style type="text/css">
10                 table, tr, td
11                 {
12                     /* putting these on their own line caused most of the document to be left out (dmd bug?) */
13                     border-collapse: collapse; border: 1px solid black; padding: 5px;
14                 }
15             </style>
16         </head>
17         <body>
18             <h1>$(TITLE)</h1>
19             $(BODY)
20         </body>
21         </html>
22 +/
23 module vibeirc;
24 
25 private
26 {
27     import std.string: split;
28 }
29 
30 private enum CTCP_ENCAPSULATOR = '\x01';
31 
32 /++
33     A struct containing details about a connection.
34 +/
35 struct ConnectionParameters
36 {
37     string hostname; ///The _hostname or address of the IRC server.
38     ushort port; ///The _port of the server.
39     string password = null; ///The _password for the server, if any.
40     string username = "vibeIRC"; ///The _username, later used by the server in this connection's hostmask.
41     string realname = null; ///The _realname as returned by the WHOIS command.
42 }
43 
44 /++
45     A struct containing details about a user.
46 +/
47 struct User
48 {
49     string nickname; ///The _nickname of this user.
50     string username; ///The _username portion of this user's hostmask.
51     string hostname; ///The _hostname portion of this user's hostmask.
52 }
53 
54 /++
55     A struct containing details about an incoming message.
56 +/
57 struct Message
58 {
59     User sender; ///The user who sent the message.
60     string receiver; ///The destination of the message, either a user or a channel.
61     string ctcpCommand; ///The CTCP command, if any.
62     string message; ///The _message body.
63     
64     /++
65         Returns whether this message uses CTCP.
66     +/
67     @property bool isCTCP()
68     {
69         return ctcpCommand != null;
70     }
71 }
72 
73 //Thrown from line_received, handle_numeric or handle_command in case of an error
74 private class GracelessDisconnect: Exception
75 {
76     this(string msg)
77     {
78         super(msg);
79     }
80 }
81 
82 /++
83     The base class for IRC connections.
84 +/
85 class IRCConnection
86 {
87     import vibe.core.net: TCPConnection;
88     import vibe.core.log: logDebug, logError, logInfo; //FIXME: logInfo
89     
90     ConnectionParameters connectionParameters; ///The connection parameters passed to $(SYMBOL_LINK irc_connect).
91     TCPConnection transport; ///The vibe socket underlying this connection.
92     private string _nickname;
93     
94     /++
95         Default constructor. Should not be called from user code.
96         
97         See_Also:
98             $(SYMBOL_LINK irc_connect)
99     +/
100     protected this() {}
101     
102     private void protocol_loop()
103     in { assert(transport && transport.connected); }
104     body
105     {
106         import vibe.stream.operations: readLine;
107         
108         string disconnectReason = "Connection terminated gracefully";
109         
110         logDebug("irc connected");
111         
112         if(connectionParameters.password != null)
113             send_line("PASS %s", connectionParameters.password);
114         
115         send_line("NICK %s", nickname);
116         send_line("USER %s 0 * :%s", connectionParameters.username, connectionParameters.realname);
117         
118         while(transport.connected)
119         {
120             string line;
121             
122             try
123                 line = cast(string)transport.readLine;
124             catch(Exception err)
125             {
126                 logError(err.toString);
127                 
128                 break;
129             }
130             
131             version(IrcDebugLogging) logDebug("irc recv: %s", line);
132             
133             try
134                 line_received(line);
135             catch(GracelessDisconnect err)
136             {
137                 disconnectReason = err.msg;
138                 
139                 transport.close;
140             }
141         }
142         
143         disconnected(disconnectReason);
144         logDebug("irc disconnected");
145     }
146     
147     private void line_received(string line)
148     {
149         import std.conv: ConvException, to;
150         
151         string[] parts = line.split(" ");
152         
153         switch(parts[0])
154         {
155             case "PING":
156                 send_line("PONG %s", parts[1]);
157                 
158                 break;
159             case "ERROR":
160                 throw new GracelessDisconnect(parts.drop_first.join.drop_first);
161             default:
162                 parts[0] = parts[0].drop_first;
163                 
164                 try
165                     handle_numeric(parts[0], parts[1].to!int, parts[2 .. $]);
166                 catch(ConvException err)
167                     handle_command(parts[0], parts[1], parts[2 .. $]);
168         }
169     }
170     
171     private void handle_command(string prefix, string command, string[] parts)
172     {
173         logDebug("handle_command(%s, %s, %s)", prefix, command, parts);
174         
175         switch(command)
176         {
177             case "NOTICE":
178             case "PRIVMSG":
179                 Message msg;
180                 string message = parts.drop_first.join;
181                 msg.sender = prefix.split_userinfo;
182                 msg.receiver = parts[0];
183                 msg.message = message != null ? message.drop_first : "";
184                 
185                 if(message.is_ctcp)
186                 {
187                     auto parsedCtcp = message.parse_ctcp;
188                     msg.ctcpCommand = parsedCtcp.command;
189                     msg.message = parsedCtcp.message;
190                 }
191                 
192                 if(command == "NOTICE")
193                     notice(msg);
194                 else
195                     privmsg(msg);
196                 
197                 break;
198             case "JOIN":
199                 user_joined(prefix.split_userinfo, parts[0].drop_first);
200                 
201                 break;
202             case "PART":
203                 user_left(prefix.split_userinfo, parts[0], parts.drop_first.join.drop_first);
204                 
205                 break;
206             case "QUIT":
207                 user_quit(prefix.split_userinfo, parts.join.drop_first);
208                 
209                 break;
210             case "NICK":
211                 user_renamed(prefix.split_userinfo, parts[0].drop_first);
212                 
213                 break;
214             case "KICK":
215                 user_kicked(prefix.split_userinfo, parts[1], parts[0], parts[2 .. $].join.drop_first);
216                 
217                 break;
218             default:
219                 unknown_command(prefix, command, parts);
220         }
221     }
222     
223     private void handle_numeric(string prefix, int id, string[] parts)
224     {
225         logDebug("handle_numeric(%s, %s, %s)", prefix, id, parts);
226         
227         switch(id)
228         {
229             case 1: //RPL_WELCOME (connection success)
230                 signed_on;
231                 
232                 break;
233             case 432: //ERR_ERRONEUSNICKNAME
234                 throw new GracelessDisconnect("Erroneus nickname"); //TODO: handle gracefully?
235             case 433: //ERR_NICKNAMEINUSE
236                 throw new GracelessDisconnect("Nickname already in use"); //TODO: handle gracefully?
237             default:
238                 unknown_numeric(prefix, id, parts);
239         }
240     }
241     
242     /++
243         Get this connection's _nickname.
244     +/
245     final @property string nickname()
246     {
247         return _nickname;
248     }
249     
250     /++
251         Set this connection's _nickname.
252     +/
253     final @property string nickname(string newNick)
254     {
255         if(transport && transport.connected)
256             send_line("NICK %s", newNick);
257         
258         return _nickname = newNick;
259     }
260     
261     final void connect()
262     in { assert(transport is null ? true : !transport.connected); }
263     body
264     {
265         import vibe.core.net: connectTCP;
266         import vibe.core.core: runTask;
267         
268         transport = connectTCP(connectionParameters.hostname, connectionParameters.port);
269         
270         runTask(&protocol_loop);
271     }
272     
273     final void disconnect(string reason)
274     in { assert(transport && transport.connected); }
275     body
276     {
277         send_line("QUIT :%s", reason);
278         
279         transport.close;
280     }
281     
282     /++
283         Send a formatted line.
284         
285         Params:
286             contents = format string for the line
287             args = formatting arguments
288     +/
289     final void send_line(Args...)(string contents, Args args)
290     in { assert(transport && transport.connected); }
291     body
292     {
293         import std.string: format;
294         
295         //TODO: buffering
296         contents = contents.format(args);
297         
298         version(IrcDebugLogging) logDebug("irc send: %s", contents);
299         transport.write(contents ~ "\r\n");
300     }
301     
302     /++
303         Send a _message.
304         
305         Params:
306             destination = _destination of the message, either a #channel or a nickname
307             notice = send a NOTICE instead of a PRIVMSG
308     +/
309     final void send_message(string destination, string message, bool notice = false)
310     {
311         send_line("%s %s :%s", notice ? "NOTICE" : "PRIVMSG", destination, message);
312     }
313     
314     /++
315         Join a channel.
316     +/
317     final void join_channel(string name)
318     {
319         send_line("JOIN %s", name);
320     }
321     
322     /++
323         Called when an unknown command is received.
324         
325         Params:
326             prefix = origin of the _command, either a server or a user
327             command = the name of the _command
328             arguments = the body of the _command
329     +/
330     void unknown_command(string prefix, string command, string[] arguments) {}
331     
332     /++
333         Called when an unknown numeric command is received.
334         
335         Params:
336             prefix = origin of the command, either a server or a user
337             id = the number of the command
338             arguments = the body of the command
339     +/
340     void unknown_numeric(string prefix, int id, string[] arguments) {}
341     
342     /++
343         Called after succesfully logging in to the network.
344     +/
345     void signed_on() {}
346     
347     /++
348         Called after being _disconnected from the network.
349     +/
350     void disconnected(string reason) {}
351     
352     /++
353         Called upon reception of an incoming private message.
354     +/
355     void privmsg(Message message) {}
356     
357     /++
358         Called upon reception of an incoming _notice.
359         
360         A _notice is similar to a privmsg, except it is expected to not generate automatic replies.
361     +/
362     void notice(Message message) {}
363     
364     /++
365         Called when a _user joins a _channel.
366     +/
367     void user_joined(User user, string channel) {}
368     
369     /++
370         Called when a _user leaves a _channel.
371     +/
372     void user_left(User user, string channel, string reason) {}
373     
374     /++
375         Called when a _user disconnects from the network.
376     +/
377     void user_quit(User user, string reason) {}
378     
379     /++
380         Called when a _user is kicked from a _channel.
381         
382         Params:
383             kicker = the _user that performed the kick
384             user = the _user that was kicked
385     +/
386     void user_kicked(User kicker, string user, string channel, string reason) {}
387     
388     /++
389         Called when a _user changes their nickname.
390     +/
391     void user_renamed(User user, string oldNick) {}
392 }
393 
394 /++
395     Establish a connection to a network and construct an instance of ConnectionClass
396     to handle events from that connection.
397 +/
398 ConnectionClass irc_connect(ConnectionClass)(ConnectionParameters parameters)
399 if(is(ConnectionClass: IRCConnection))
400 {
401     auto connection = new ConnectionClass;
402     connection.connectionParameters = parameters;
403     
404     connection.connect;
405     
406     return connection;
407 }
408 
409 private User split_userinfo(string info)
410 {
411     import std.regex: ctRegex, matchFirst;
412     
413     auto expression = ctRegex!(r"^(.+)!(.+)@(.+)$");
414     auto matches = info.matchFirst(expression);
415     
416     if(matches.empty)
417         throw new Exception("Invalid userinfo: " ~ info);
418     
419     return User(matches[1], matches[2], matches[3]);
420 }
421 
422 unittest
423 {
424     void assert_fails(string test)
425     {
426         try
427         {
428             test.split_userinfo;
429             assert(false, test);
430         }
431         catch(Exception) {}
432     }
433     
434     assert("abc!def@ghi".split_userinfo == User("abc", "def", "ghi"));
435     assert_fails("abc!@");
436     assert_fails("!def@");
437     assert_fails("!@ghi");
438     assert_fails("abc!def");
439     assert_fails("def@ghi");
440     assert_fails("!def@ghi");
441     assert_fails("abc!def@");
442 }
443 
444 private bool is_ctcp(string message)
445 {
446     return message[0] == CTCP_ENCAPSULATOR && message[$ - 1] == CTCP_ENCAPSULATOR;
447 }
448 
449 private auto parse_ctcp(string message)
450 {
451     struct Result
452     {
453         string command;
454         string message;
455     }
456     
457     if(!message.is_ctcp)
458         throw new Exception("Message is not CTCP");
459     
460     string command;
461     message = message.drop_first[0 .. $ - 1];
462     
463     foreach(index, character; message)
464     {
465         if(character == ' ')
466         {
467             message = message[index + 1 .. $];
468             
469             break;
470         }
471         
472         if(index == message.length - 1)
473         {
474             command ~= character;
475             message = "";
476             
477             break;
478         }
479         
480         command ~= character;
481     }
482     
483     return Result(command, message);
484 }
485 
486 unittest
487 {
488     assert(is_ctcp(CTCP_ENCAPSULATOR ~ "abc def" ~ CTCP_ENCAPSULATOR));
489     
490     auto one = (CTCP_ENCAPSULATOR ~ "abc def" ~ CTCP_ENCAPSULATOR).parse_ctcp;
491     auto two = (CTCP_ENCAPSULATOR ~ "abc" ~ CTCP_ENCAPSULATOR).parse_ctcp;
492     auto three = [CTCP_ENCAPSULATOR, CTCP_ENCAPSULATOR].parse_ctcp;
493     
494     assert(one.command == "abc");
495     assert(one.message == "def");
496     assert(two.command == "abc");
497     assert(two.message == null);
498     assert(three.command == null);
499     assert(three.message == null);
500     
501     try
502     {
503         "abc".parse_ctcp;
504         assert(false);
505     }
506     catch(Exception err) {}
507 }
508 
509 private Array drop_first(Array)(Array array)
510 {
511     return array[1 .. $];
512 }
513 
514 private auto join(Array)(Array array)
515 {
516     static import std.string;
517     
518     return std..string.join(array, " ");
519 }