1 ///
2 module vibeirc.client;
3 
4 import std.datetime;
5 import std.traits;
6 
7 import vibe.core.log;
8 import vibe.core.net;
9 import vibe.core.task;
10 
11 import vibeirc.constants;
12 import vibeirc.data;
13 import vibeirc.utility;
14 
15 //Thrown from lineReceived, handleNumeric or handleCommand in case of an error
16 private class GracelessDisconnect: Exception
17 {
18     this(string msg)
19     {
20         super(msg);
21     }
22 }
23 
24 /++
25     Return type for the onConnect callback.
26 +/
27 enum PerformLogin
28 {
29     /++
30         Login procedure was not handled in user code, and needs to be done by the library.
31     +/
32     yes,
33     
34     /++
35         Login procedure was handled in user code, and does not need to be done by the library.
36     +/
37     no,
38 } 
39 
40 /++
41     Represents a connection to an IRC server.
42 +/
43 final class IRCClient
44 {
45     //placed here to prevent symbols leaking into user code
46     import std.string: format, split;
47     
48     private Task protocolTask; //The task running protocolLoop
49     private TCPConnection transport; //The TCP socket
50     private string[] buffer; //Buffered messages
51     private uint bufferSent; //Number of messages sent this time period
52     private SysTime bufferNextTime; //The start of the next time period
53     private SysTime lastIncomingLineTime; //When we last received a line from the server
54     private bool sentPing; //Whether we sent a PING
55     private bool receivedPong; //Whether the server has answered our PING
56     
57     /*======================================*
58      *======================================*
59      *              Properties              *
60      *======================================*
61      *======================================*/
62     
63     private string _nickname = "vibeirc";
64     
65     /++
66         The display name this client will use.
67         
68         Defaults to vibeirc.
69     +/
70     @property string nickname()
71     {
72         return _nickname;
73     }
74     
75     /++
76         ditto
77     +/
78     @property string nickname(string newNick)
79     {
80         if(connected)
81             sendLine("NICK %s", newNick);
82         
83         return _nickname = newNick;
84     }
85     
86     private string _username = "vibeirc";
87     
88     /++
89         The username shown by the WHOIS command.
90         
91         Defaults to vibeirc.
92     +/
93     @property string username()
94     {
95         return _username;
96     }
97     
98     /++
99         ditto
100     +/
101     @property string username(string newValue)
102     {
103         return _username = newValue;
104     }
105     
106     private string _realname = "vibeirc";
107     
108     /++
109         The real name shown by the WHOIS command.
110         
111         Defaults to vibeirc.
112     +/
113     @property string realname()
114     {
115         return _realname;
116     }
117     
118     /++
119         ditto
120     +/
121     @property string realname(string newValue)
122     {
123         return _realname = newValue;
124     }
125     
126     private Duration _sleepTimeout = dur!"msecs"(10);
127     
128     /++
129         How long the protocol loop should sleep after failing to read a line.
130         
131         Defaults to 10 ms.
132     +/
133     @property Duration sleepTimeout()
134     {
135         return _sleepTimeout;
136     }
137     
138     /++
139         ditto
140     +/
141     @property Duration sleepTimeout(Duration newValue)
142     {
143         return _sleepTimeout = newValue;
144     }
145     
146     private bool _buffering = false;
147     
148     /++
149         Whether to buffer outgoing messages.
150         
151         Defaults to off (false).
152     +/
153     @property bool buffering()
154     {
155         return _buffering;
156     }
157     
158     /++
159         ditto
160     +/
161     @property bool buffering(bool newValue)
162     {
163         return _buffering = newValue;
164     }
165     
166     private uint _bufferLimit = 20;
167     
168     /++
169         Maximum number of messages to send per time period, if buffering is enabled.
170         
171         Defaults to 20.
172     +/
173     @property uint bufferLimit()
174     {
175         return _bufferLimit;
176     }
177     
178     /++
179         ditto
180     +/
181     @property uint bufferLimit(uint newValue)
182     {
183         return _bufferLimit = newValue;
184     }
185     
186     private Duration _bufferTimeout = dur!"seconds"(30);
187     
188     /++
189         Amount of time to wait before sending each batch of messages, if buffering is enabled.
190     +/
191     @property Duration bufferTimeout()
192     {
193         return _bufferTimeout;
194     }
195     
196     /++
197         ditto
198     +/
199     @property Duration bufferTimeout(Duration newValue)
200     {
201         return _bufferTimeout = newValue;
202     }
203     
204     /++
205         Returns whether this connection is active.
206     +/
207     @property bool connected()
208     {
209         return transport && transport.connected;
210     }
211     
212     private bool _loggedIn;
213     
214     /++
215         Whether or not this connection has successfully logged in to the network.
216     +/
217     @property bool loggedIn()
218     {
219         return _loggedIn;
220     }
221     
222     private @property bool loggedIn(bool newValue)
223     {
224         return _loggedIn = newValue;
225     }
226     
227     private string _serverHostname;
228     
229     /++
230         The hostname of the server this client is connected to.
231     +/
232     @property string serverHostname()
233     {
234         return _serverHostname;
235     }
236     
237     private @property string serverHostname(string newValue)
238     {
239         return _serverHostname = newValue;
240     }
241     
242     /*======================================*
243      *======================================*
244      *              Callbacks               *
245      *======================================*
246      *======================================*/
247     
248     private void delegate(string prefix, string command, string[] arguments) _onUnknownCommand;
249     
250     /++
251         Called when an unknown command is received.
252         
253         Params:
254             a = origin of the command, either a server or a user
255             b = the name of the command
256             c = the body of the command
257     +/
258     @property typeof(_onUnknownCommand) onUnknownCommand()
259     {
260         return _onUnknownCommand;
261     }
262     
263     /++
264         ditto
265     +/
266     @property typeof(_onUnknownCommand) onUnknownCommand(typeof(_onUnknownCommand) newValue)
267     {
268         return _onUnknownCommand = newValue;
269     }
270     
271     private void delegate(string prefix, int id, string[] arguments) _onUnknownNumeric;
272     
273     /++
274         Called when an unknown numeric command is received.
275         
276         Params:
277             a = origin of the command, either a server or a user
278             b = the number of the command
279             c = the body of the command
280     +/
281     @property typeof(_onUnknownNumeric) onUnknownNumeric()
282     {
283         return _onUnknownNumeric;
284     }
285     
286     /++
287         ditto
288     +/
289     @property typeof(_onUnknownNumeric) onUnknownNumeric(typeof(_onUnknownNumeric) newValue)
290     {
291         return _onUnknownNumeric = newValue;
292     }
293     
294     private PerformLogin delegate() _onConnect;
295     
296     /++
297         Called after the connection is established, before logging in to the network.
298         
299         Returns:
300             whether login procedure (sending of PASSWORD, NICK and USER commands)
301             was handled in the callback
302     +/
303     @property typeof(_onConnect) onConnect()
304     {
305         return _onConnect;
306     }
307     
308     /++
309         ditto
310     +/
311     @property typeof(_onConnect) onConnect(typeof(_onConnect) newValue)
312     {
313         return _onConnect = newValue;
314     }
315     
316     private void delegate(string reason) _onDisconnect;
317     
318     /++
319         Called after being disconnected from the network.
320         
321         Params:
322             a = the reason for quitting
323     +/
324     @property typeof(_onDisconnect) onDisconnect()
325     {
326         return _onDisconnect;
327     }
328     
329     /++
330         ditto
331     +/
332     @property typeof(_onDisconnect) onDisconnect(typeof(_onDisconnect) newValue)
333     {
334         return _onDisconnect = newValue;
335     }
336     
337     private void delegate() _onLogin;
338     
339     /++
340         Called after succesfully logging in to the network.
341     +/
342     @property typeof(_onLogin) onLogin()
343     {
344         return _onLogin;
345     }
346     
347     /++
348         ditto
349     +/
350     @property typeof(_onLogin) onLogin(typeof(_onLogin) newValue)
351     {
352         return _onLogin = newValue;
353     }
354     
355     private void delegate(Message message) _onMessage;
356     
357     /++
358         Called upon reception of an incoming message.
359         
360         Params:
361             a = information on the incoming message
362     +/
363     @property typeof(_onMessage) onMessage()
364     {
365         return _onMessage;
366     }
367     
368     /++
369         ditto
370     +/
371     @property typeof(_onMessage) onMessage(typeof(_onMessage) newValue)
372     {
373         return _onMessage = newValue;
374     }
375     
376     private void delegate(Message message) _onNotice;
377     
378     /++
379         Called upon reception of an incoming notice.
380         
381         A notice is similar to a privmsg, except it is expected to not generate automatic replies.
382         
383         Params:
384             a = information on the incoming message
385     +/
386     @property typeof(_onNotice) onNotice()
387     {
388         return _onNotice;
389     }
390     
391     /++
392         ditto
393     +/
394     @property typeof(_onNotice) onNotice(typeof(_onNotice) newValue)
395     {
396         return _onNotice = newValue;
397     }
398     
399     private void delegate(User user, string channel) _onUserJoin;
400     
401     /++
402         Called when a user joins a channel.
403         
404         Params:
405             a = the user that joined
406             b = the channel they joined
407     +/
408     @property typeof(_onUserJoin) onUserJoin()
409     {
410         return _onUserJoin;
411     }
412     
413     /++
414         ditto
415     +/
416     @property typeof(_onUserJoin) onUserJoin(typeof(_onUserJoin) newValue)
417     {
418         return _onUserJoin = newValue;
419     }
420     
421     private void delegate(User user, string channel, string reason) _onUserPart;
422     
423     /++
424         Called when a user leaves a channel.
425         
426         Params:
427             a = the user that left
428             b = the channel they left
429             c = the reason they left, if any
430     +/
431     @property typeof(_onUserPart) onUserPart()
432     {
433         return _onUserPart;
434     }
435     
436     /++
437         ditto
438     +/
439     @property typeof(_onUserPart) onUserPart(typeof(_onUserPart) newValue)
440     {
441         return _onUserPart = newValue;
442     }
443     
444     private void delegate(User user, string reason) _onUserQuit;
445     
446     /++
447         Called when a user disconnects from the network.
448         
449         Params:
450             a = the user that quit
451             b = the reason they quit, if any
452     +/
453     @property typeof(_onUserQuit) onUserQuit()
454     {
455         return _onUserQuit;
456     }
457     
458     /++
459         ditto
460     +/
461     @property typeof(_onUserQuit) onUserQuit(typeof(_onUserQuit) newValue)
462     {
463         return _onUserQuit = newValue;
464     }
465     
466     private void delegate(User kicker, string user, string channel, string reason) _onUserKick;
467     
468     /++
469         Called when a user is kicked from a channel.
470         
471         Params:
472             a = the user that performed the kick
473             b = the user that was kicked
474             c = the channel they were kicked from
475             d = the reason they were kicked
476     +/
477     @property typeof(_onUserKick) onUserKick()
478     {
479         return _onUserKick;
480     }
481     
482     /++
483         ditto
484     +/
485     @property typeof(_onUserKick) onUserKick(typeof(_onUserKick) newValue)
486     {
487         return _onUserKick = newValue;
488     }
489     
490     private void delegate(User user, string newNick) _onUserRename;
491     
492     /++
493         Called when a user changes their nickname.
494         
495         Params:
496             a = the user that changed their name
497             b = the user's new name
498     +/
499     @property typeof(_onUserRename) onUserRename()
500     {
501         return _onUserRename;
502     }
503     
504     /++
505         ditto
506     +/
507     @property typeof(_onUserRename) onUserRename(typeof(_onUserRename) newValue)
508     {
509         return _onUserRename = newValue;
510     }
511     
512     /*======================================*
513      *======================================*
514      *           Private Methods            *
515      *======================================*
516      *======================================*/
517     
518     private void protocolLoop(string password)
519     in { assert(connected); }
520     body
521     {
522         import vibe.core.log: logError;
523         import vibe.core.core: sleep;
524         
525         version(IrcDebugLogging) logDebug("irc connected");
526         
527         if(runCallback(onConnect) == PerformLogin.yes)
528         {
529             if(password != null)
530                 sendLine("PASS %s", password);
531             
532             sendLine("NICK %s", nickname);
533             sendLine("USER %s 0 * :%s", username, realname);
534         }
535         
536         while(true)
537         {
538             string line;
539             
540             if(buffering)
541                 flushMessageBuffer;
542             
543             checkPingTime;
544             transport.waitForData;
545             
546             try
547                 line = transport.tryReadLine;
548             catch(Exception err)
549             {
550                 logError(err.toString);
551                 
552                 break;
553             }
554             
555             if(line == null)
556             {
557                 if(!connected) //reading final lines
558                     break;
559                 
560                 sleep(sleepTimeout);
561                 
562                 continue;
563             }
564             
565             version(IrcDebugLogging) logDebug("irc recv: %s", line);
566             lineReceived(line);
567         }
568         
569         loggedIn = false;
570         
571         version(IrcDebugLogging) logDebug("irc disconnected");
572     }
573     
574     private void lineReceived(string line)
575     {
576         import std.conv: ConvException, to;
577         
578         lastIncomingLineTime = Clock.currTime;
579         string[] parts = line.split(" ");
580         
581         //commands of the form `CMD :data` are handled here
582         //handleCommand and handleNumeric are for `:origin CMD :data`
583         switch(parts[0])
584         {
585             case "PING":
586                 sendLine("PONG %s", parts[1]);
587                 
588                 break;
589             case "ERROR":
590                 throw new GracelessDisconnect(parts.dropFirst.join.dropFirst);
591             default:
592                 parts[0] = parts[0].dropFirst;
593                 
594                 try
595                     handleNumeric(parts[0], parts[1].to!int, parts[2 .. $]);
596                 catch(ConvException err)
597                     handleCommand(parts[0], parts[1], parts[2 .. $]);
598         }
599     }
600     
601     private void handleCommand(string prefix, string command, string[] parts)
602     {
603         version(IrcDebugLogging) logDebug("handleCommand(%s, %s, %s)", prefix, command, parts);
604         
605         switch(command)
606         {
607             case "NOTICE":
608             case "PRIVMSG":
609                 Message msg;
610                 string message = parts.dropFirst.join.dropFirst;
611                 msg.sender = prefix.splitUserinfo;
612                 msg.target = parts[0];
613                 msg.message = message;
614                 
615                 if(message.isCTCP)
616                 {
617                     auto parsedCtcp = message.parseCTCP;
618                     msg.ctcpCommand = parsedCtcp.command;
619                     msg.message = parsedCtcp.message;
620                 }
621                 
622                 if(command == "NOTICE")
623                     runCallback(onNotice, msg);
624                 else
625                     runCallback(onMessage, msg);
626                 
627                 break;
628             case "JOIN":
629                 runCallback(onUserJoin, prefix.splitUserinfo, parts[0].dropFirst);
630                 
631                 break;
632             case "PART":
633                 runCallback(onUserPart, prefix.splitUserinfo, parts[0], parts.dropFirst.join.dropFirst);
634                 
635                 break;
636             case "QUIT":
637                 runCallback(onUserQuit, prefix.splitUserinfo, parts.join.dropFirst);
638                 
639                 break;
640             case "NICK":
641                 runCallback(onUserRename, prefix.splitUserinfo, parts[0].dropFirst);
642                 
643                 break;
644             case "KICK":
645                 runCallback(onUserKick, prefix.splitUserinfo, parts[1], parts[0], parts[2 .. $].join.dropFirst);
646                 
647                 break;
648             case "PONG":
649                 receivedPong = true;
650                 
651                 break;
652             default:
653                 runCallback(onUnknownCommand, prefix, command, parts);
654         }
655     }
656     
657     private void handleNumeric(string prefix, int id, string[] parts)
658     {
659         version(IrcDebugLogging) logDebug("handleNumeric(%s, %s, %s)", prefix, id, parts);
660         
661         switch(id)
662         {
663             case Numeric.RPL_WELCOME:
664                 version(IrcDebugLogging) logDebug("irc logged in");
665                 
666                 loggedIn = true;
667                 serverHostname = prefix;
668                 
669                 runCallback(onLogin);
670                 
671                 break;
672             case Numeric.ERR_ERRONEUSNICKNAME:
673                 if(!loggedIn)
674                     throw new GracelessDisconnect("Erroneus nickname");
675                 
676                 break;
677             case Numeric.ERR_NICKNAMEINUSE:
678                 if(!loggedIn)
679                     throw new GracelessDisconnect("Nickname already in use");
680                 
681                 break;
682             default:
683                 runCallback(onUnknownNumeric, prefix, id, parts);
684         }
685     }
686     
687     private void flushMessageBuffer()
688     {
689         import std.datetime: Clock;
690         
691         version(IrcDebugLogging) uint currentSend = 0;
692         
693         void updateTime()
694         {
695             bufferNextTime = Clock.currTime + bufferTimeout + dur!"seconds"(1); //add a second just to be safe
696         }
697         
698         if(buffer.length == 0)
699             return;
700         
701         if(Clock.currTime > bufferNextTime)
702         {
703             bufferSent = 0;
704             
705             updateTime;
706         }
707         
708         if(bufferSent >= bufferLimit)
709             return;
710         
711         version(IrcDebugLogging) logDebug("irc flushMessageBuffer: about to send, %s so far this period", bufferSent);
712         
713         while(true)
714         {
715             if(buffer.length == 0)
716             {
717                 version(IrcDebugLogging) logDebug("irc flushMessageBuffer: ran out of messages");
718                 
719                 break;
720             }
721             
722             if(bufferSent >= bufferLimit)
723             {
724                 version(IrcDebugLogging) logDebug("irc flushMessageBuffer: hit buffering limit");
725                 
726                 break;
727             }
728             
729             string line = buffer[0];
730             buffer = buffer[1 .. $];
731             bufferSent++;
732             version(IrcDebugLogging) currentSend++;
733             
734             version(IrcDebugLogging) logDebug("irc send: %s", line);
735             transport.write(line ~ "\r\n");
736         }
737         
738         updateTime;
739         
740         version(IrcDebugLogging) logDebug("irc flushMessageBuffer: sent %s this loop", currentSend);
741     }
742     
743     private auto runCallback(CallbackType, Args...)(CallbackType callback, Args args)
744     {
745         static assert(
746             isCallable!callback,
747             "runCallback passed a non-delegate: " ~ CallbackType.stringof
748         );
749         static assert(
750             __traits(compiles, callback(args)),
751             "Cannot call " ~ CallbackType.stringof ~ " with types " ~ Args.stringof
752         );
753         
754         if(callback !is null)
755             return callback(args);
756         else
757         {
758             static if(is(ReturnType!callback == void))
759                 return;
760             else
761                 return ReturnType!callback.init;
762         }
763     }
764     
765     private void checkPingTime()
766     {
767         //how long to wait before sending a PING
768         static const timeUntilSendPing = 1.minutes;
769         //how long to wait after sending PING before considering the connection closed
770         static const timeUntilErroring = 15.seconds;
771         auto now = Clock.currTime;
772         auto nextPing = lastIncomingLineTime + timeUntilSendPing;
773         
774         if(receivedPong)
775         {
776             receivedPong = false;
777             sentPing = false;
778             
779             return;
780         }
781         
782         if(now < nextPing)
783             return;
784         
785         if(sentPing)
786         {
787             if(now < nextPing + timeUntilErroring)
788                 return;
789             
790             throw new GracelessDisconnect("Connection timed out");
791         }
792         
793         sendLine("PING :%s", serverHostname);
794         
795         sentPing = true;
796     }
797     
798     private void resetFields()
799     {
800         protocolTask = Task.init;
801         transport = TCPConnection.init;
802         buffer.length = 0;
803         bufferSent = 0;
804         bufferNextTime = Clock.currTime;
805         lastIncomingLineTime = Clock.currTime;
806         sentPing = false;
807         receivedPong = false;
808     }
809     
810     /*======================================*
811      *======================================*
812      *            Public Methods            *
813      *======================================*
814      *======================================*/
815     
816     /++
817         Connect to the IRC network and start the protocol loop.
818         
819         Params:
820             host = hostname/address to connect to
821             port = port to connect on
822             password = password to use when logging in to the network (optional)
823     +/
824     void connect(string host, ushort port, string password = null)
825     {
826         import vibe.core.net: connectTCP;
827         import vibe.core.core: runTask;
828         
829         if(connected)
830             throw new Exception("Already connected!");
831         
832         resetFields;
833         
834         string disconnectReason = "Connection terminated gracefully";
835         protocolTask = runTask(
836             {
837                 version(IrcDebugLogging) logDebug("Starting protocol loop");
838                 
839                 try
840                 {
841                     transport = connectTCP(host, port);
842                     
843                     protocolLoop(password);
844                 }
845                 catch(Exception err)
846                 {
847                     if(cast(GracelessDisconnect)err)
848                         disconnectReason = err.msg;
849                     else
850                         disconnectReason = "%s: %s".format(typeid(err).name, err.msg);
851                     
852                     if(connected)
853                         transport.close;
854                 }
855                 
856                 runCallback(onDisconnect, disconnectReason);
857             }
858         );
859     }
860     
861     /++
862         Disconnect from the network, giving reason as the quit message.
863     +/
864     void quit(string reason)
865     in { assert(connected); }
866     body
867     {
868         sendLine("QUIT :%s", reason);
869         
870         if(Task.getThis !is protocolTask)
871             protocolTask.join;
872         
873         transport.close;
874     }
875     
876     /++
877         Send a raw IRC command.
878         
879         Params:
880             contents = format string for the line
881             args = formatting arguments
882     +/
883     void sendLine(Args...)(string contents, Args args)
884     in { assert(connected); }
885     body
886     {
887         contents = contents.format(args);
888         
889         if(buffering)
890             buffer ~= contents;
891         else
892         {
893             version(IrcDebugLogging) logDebug("irc send: %s", contents);
894             transport.write(contents ~ "\r\n");
895         }
896     }
897     
898     /++
899         Send a message.
900         
901         Params:
902             destination = destination of the message, either a #channel or a nickname
903             message = the body of the message
904             notice = send a NOTICE instead of a PRIVMSG
905     +/
906     void send(string destination, string message, bool notice = false)
907     {
908         foreach(line; message.split("\n"))
909             sendLine("%s %s :%s", notice ? "NOTICE" : "PRIVMSG", destination, line);
910     }
911     
912     /++
913         Join a channel.
914     +/
915     void join(string name)
916     {
917         sendLine("JOIN %s", name);
918     }
919 }