1 /**
2  * A simple IRC client.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Stéphan Kochen <stephan@kochen.nl>
12  *   Vladimir Panteleev <vladimir@thecybershadow.net>
13  *   Vincent Povirk <madewokherd@gmail.com>
14  *   Simon Arlott
15  */
16 
17 module ae.net.irc.client;
18 
19 import std.conv;
20 import std.datetime;
21 import std.exception;
22 import std.random;
23 import std.string;
24 
25 import ae.net.asockets;
26 import ae.sys.log;
27 import ae.utils.text;
28 
29 public import ae.net.irc.common;
30 
31 debug(IRC) import std.stdio;
32 
33 /// An IRC client class.
34 class IrcClient
35 {
36 private:
37 	/// The socket this class wraps.
38 	IrcConnection conn;
39 	/// Whether the socket is connected
40 	bool _connected;
41 
42 	/// Helper function for sending a command.
43 	void command(string command, string[] params ...)
44 	{
45 		assert(command.length > 1);
46 		string message = toUpper(command);
47 
48 		while((params.length > 1) && (params[$-1]==null || params[$-1]==""))
49 			params.length = params.length-1;
50 
51 		assert(params.length <= 15);
52 
53 		// SA 2007.08.12: Can't send "PASS ELSILRACLIHP "
54 		//                If we NEED to then the ircd is broken
55 		//                - make "PASS" special if and when this happens.
56 		foreach(i,parameter; params)
57 		{
58 			message ~= " ";
59 			if(parameter.indexOf(" ")!=-1)
60 			{
61 				assert(i == params.length-1);
62 				message ~= ":";
63 			}
64 			message ~= parameter;
65 		}
66 
67 		sendRaw(message);
68 	}
69 
70 	/// Called when a connection has been established.
71 	void onConnect()
72 	{
73 		if (log) log("* Connected.");
74 		if (password.length > 0)
75 			command("PASS", password);
76 		currentNickname = connectNickname;
77 		command("NICK", currentNickname);
78 		command("USER", currentNickname, "hostname", "servername", realname);
79 	}
80 
81 	/// Called when a connection was closed.
82 	void onDisconnect(string reason, DisconnectType type)
83 	{
84 		if (log) log(format("* Disconnected (%s)", reason));
85 		currentNickname = null;
86 		_connected = false;
87 		if (handleDisconnect)
88 			handleDisconnect(reason, type);
89 		channels = null;
90 		users = null;
91 		canonicalChannelNames = canonicalUserNames = null;
92 	}
93 
94 	/// Remove the @+ etc. prefix from a nickname
95 	string removePrefix(string nick)
96 	{
97 		// TODO: do this properly, maybe?
98 		if(nick[0]=='@' || nick[0]=='+')
99 			return nick[1..$];
100 		else
101 			return nick;
102 	}
103 
104 	/// Called when a line has been received.
105 	void onReadLine(string line)
106 	{
107 		line = decoder(line);
108 		if (handleRaw)
109 		{
110 			handleRaw(line);
111 			if (line is null)
112 				return;
113 		}
114 		if (log) log("< " ~ line);
115 		string nick, username, hostname;
116 		debug (IRC) std.stdio.writefln("< %s", line);
117 		auto colon = line.indexOf(':');
118 		if (colon == 0)
119 		{
120 			auto space = line.indexOf(' ');
121 			string target = line[1 .. space];
122 			parseTarget(target, nick, username, hostname);
123 			//std.stdio.writefln("%s => %s!%s@%s", target, nick, username, hostname);
124 			nick = canonicalUserName(nick);
125 			auto userptr = nick in users;
126 			if (userptr)
127 			{
128 				userptr.username = username;
129 				userptr.hostname = hostname;
130 			}
131 			line = line[space + 1 .. line.length];
132 			colon = line.indexOf(':');
133 		}
134 
135 		string[] params;
136 		if (colon == -1)
137 			params = split(line);
138 		else
139 		{
140 			params = split(line[0 .. colon]);
141 			params.length = params.length + 1;
142 			params[$-1] = line[colon + 1 .. line.length];
143 		}
144 
145 		string command = toUpper(params[0]);
146 		params = params[1 .. params.length];
147 
148 		// Whenever D supports this, turn this into a
149 		// constant associative array of anonymous functions.
150 		// VP 2006.12.16: for now, moving functions inside the switch, because the code is too cumbersome to read and maintain with each handler in a separate function
151 		switch (command)
152 		{
153 		case "001":     // login successful
154 			// VP 2006.12.13: changing 376 to 001, since 376 doesn't appear on all servers and it's safe to send commands after 001 anyway
155 			_connected = true;
156 			onEnter(currentNickname, username, hostname, realname); // add ourselves
157 
158 			if (handleConnect)
159 				handleConnect();
160 			if (autoWho)
161 				who();  // get info on all users
162 			break;
163 
164 		case "433":     // nickname in use
165 			if (exactNickname)
166 				disconnect("Nickname in use", DisconnectType.error);
167 			else
168 			{
169 				currentNickname = "%s%03d".format(connectNickname, uniform(0, 1000));
170 				this.command("NICK", currentNickname);
171 			}
172 			break;
173 
174 		case "321":     // LIST channel start
175 			channelList = null; // clear list
176 			break;
177 
178 		case "322":     // LIST channel line
179 			channelList ~= params[1];
180 			break;
181 
182 		case "323":     // LIST channel end
183 			if (handleChannelList)
184 				handleChannelList(channelList);
185 			break;
186 
187 		case "353":     // NAMES line
188 			string channel = canonicalChannelName(params[$-2]);
189 			assert(channel in channels);
190 			string[] nicks = params[$-1].split(" ");
191 			foreach (fullnick; nicks)
192 				if (fullnick.length>0)
193 				{
194 					auto nickname = removePrefix(fullnick);
195 					if (!(nickname in users))
196 						onEnter(nickname, null, null);
197 					else
198 						users[nickname].channelsJoined++;
199 					channels[channel].users[nickname] = true;
200 				}
201 			break;
202 
203 		case "366":     // NAMES end
204 			// VP 2007.01.07: perhaps the onJoin handler code for when we join a channel ought to be moved here...
205 			break;
206 
207 		case "352":     // WHO line
208 			//                          0           1 2        3                                  4                   5           6 7
209 			// :wormnet1.team17.com 352 CyberShadow * Username host-86-106-217-211.moldtelecom.md wormnet1.team17.com CyberShadow H :0 40 0 RO
210 			//void delegate(string channel, string username, string host, string server, string name, string flags, string userinfo) handleWho;
211 			while (params.length<8)
212 				params ~= [null];
213 
214 			string[] gecos = params[7].split(" ");
215 			int hopcount = 0;
216 			try
217 				hopcount = to!int(gecos[0]);
218 			catch (Exception e)
219 				hopcount = 0;
220 			if(gecos.length > 1)
221 				gecos = gecos[1..$];
222 			else
223 				gecos = null;
224 
225 			string nickname = params[5];
226 			username = params[2];
227 			hostname = params[3];
228 			auto userptr = nickname in users;
229 			if (userptr)
230 			{
231 				if (userptr.username is null)
232 					userptr.username = username;
233 				else
234 					assert(userptr.username == username, userptr.username ~ " != " ~ username);
235 				if (userptr.hostname is null)
236 					userptr.hostname = hostname;
237 				//else
238 				//	assert(userptr.hostname == hostname, userptr.hostname ~ " != " ~ hostname);
239 				string realname = std..string.join(gecos, " ");
240 				if (userptr.realname is null)
241 					userptr.realname = realname;
242 				else
243 					assert(userptr.realname == realname);
244 			}
245 
246 			if (handleWho)
247 				handleWho(params[1],params[2],params[3],params[4],params[5],params[6],hopcount,std..string.join(gecos, " "));
248 			break;
249 
250 		case "315":     // WHO end
251 			if(handleWhoEnd)
252 				handleWhoEnd(params.length>=2 ? params[1] : null);
253 			break;
254 
255 		case "437":     // Nick/channel is temporarily unavailable
256 			if (handleUnavailable)
257 				handleUnavailable(params[1], params[2]);
258 			break;
259 
260 		case "471":     // Channel full
261 			if (handleChannelFull)
262 				handleChannelFull(params[1], params[2]);
263 			break;
264 
265 		case "473":     // Invite only
266 			if (handleInviteOnly)
267 				handleInviteOnly(params[1], params[2]);
268 			break;
269 
270 		case "474":     // Banned
271 			if (handleBanned)
272 				handleBanned(params[1], params[2]);
273 			break;
274 
275 		case "475":     // Wrong key
276 			if (handleChannelKey)
277 				handleChannelKey(params[1], params[2]);
278 			break;
279 
280 		case "PING":
281 			if (params.length == 1)
282 				this.command("PONG", params[0]);
283 			break;
284 
285 		case "PRIVMSG":
286 			if (params.length != 2)
287 				return;
288 
289 			string target = canonicalName(params[0]);
290 			IrcMessageType type = IrcMessageType.NORMAL;
291 			string text = params[1];
292 			if (text.startsWith("\x01ACTION"))
293 			{
294 				type = IrcMessageType.ACTION;
295 				text = text[7 .. $];
296 				if (text.startsWith(" "))
297 					text = text[1..$];
298 				if (text.endsWith("\x01"))
299 					text = text[0..$-1];
300 			}
301 			onMessage(nick, target, text, type);
302 			break;
303 
304 		case "NOTICE":
305 			if (params.length != 2)
306 				return;
307 
308 			string target = canonicalName(params[0]);
309 			onMessage(nick, target, params[1], IrcMessageType.NOTICE);
310 			break;
311 
312 		case "JOIN":
313 			if (params.length != 1)
314 				return;
315 
316 			string channel = canonicalChannelName(params[0]);
317 
318 			if (!(nick in users))
319 			{
320 				onEnter(nick, username, hostname);
321 				if (autoWho)
322 					who(nick);
323 			}
324 			else
325 				users[nick].channelsJoined++;
326 
327 			if (nick == currentNickname)
328 			{
329 				assert(!(channel in channels));
330 				channels[channel] = Channel();
331 			}
332 			else
333 			{
334 				assert(channel in channels);
335 				channels[channel].users[nick] = true;
336 			}
337 
338 			if (handleJoin)
339 				handleJoin(channel, nick);
340 
341 			break;
342 
343 		case "PART":
344 			if (params.length < 1 || params.length > 2)
345 				return;
346 
347 			string channel = canonicalChannelName(params[0]);
348 
349 			if (handlePart)
350 				handlePart(channel, nick, params.length == 2 ? params[1] : null);
351 
352 			onUserParted(nick, channel);
353 			break;
354 
355 		case "QUIT":
356 			string[] oldChannels;
357 			foreach (channelName,channel;channels)
358 				if (nick in channel.users)
359 					oldChannels ~= channelName;
360 
361 			if (handleQuit)
362 				handleQuit(nick, params.length == 1 ? params[0] : null, oldChannels);
363 
364 			foreach (channel;channels)
365 				if (nick in channel.users)
366 					channel.users.remove(nick);
367 
368 			onLeave(nick);
369 			break;
370 
371 		case "KICK":
372 			if (params.length < 2 || params.length > 3)
373 				return;
374 
375 			string channel = canonicalChannelName(params[0]);
376 
377 			string user = canonicalUserName(params[1]);
378 			if (handleKick)
379 			{
380 				if (params.length == 3)
381 					handleKick(channel, user, nick, params[2]);
382 				else
383 					handleKick(channel, user, nick, null);
384 			}
385 
386 			onUserParted(user, channel);
387 			break;
388 
389 		case "NICK":
390 			if (params.length != 1)
391 				return;
392 
393 			onNick(nick, params[0]);
394 			break;
395 
396 		case "INVITE":
397 			if (params.length != 2)
398 				return;
399 
400 			if (handleInvite)
401 				handleInvite(params[0], params[1]);
402 			break;
403 
404 		default:
405 			break;
406 		}
407 	}
408 
409 	void onUserParted(string nick, string channel)
410 	{
411 		assert(channel in channels);
412 		if (nick == currentNickname)
413 		{
414 			foreach(user,b;channels[channel].users)
415 				users[user].channelsJoined--;
416 			purgeUsers();
417 			channels.remove(channel);
418 		}
419 		else
420 		{
421 			channels[channel].users.remove(nick);
422 			users[nick].channelsJoined--;
423 			if (users[nick].channelsJoined==0)
424 				onLeave(nick);
425 		}
426 	}
427 
428 	/// Remove users that aren't in any channels
429 	void purgeUsers()
430 	{
431 		throw new Exception("not implemented");
432 	}
433 
434 	void parseTarget(string target, out string nickname, out string username, out string hostname)
435 	{
436 		username = hostname = null;
437 		auto userdelimpos = target.indexOf('!');
438 		if (userdelimpos == -1)
439 			nickname = target;
440 		else
441 		{
442 			nickname = target[0 .. userdelimpos];
443 
444 			auto hostdelimpos = target.indexOf('@');
445 			if (hostdelimpos == -1)
446 				assert(0);
447 			else
448 			{
449 				//bool identified = target[userdelimpos + 1] != '~';
450 				//if (!identified)
451 				//	userdelimpos++;
452 
453 				username = target[userdelimpos + 1 .. hostdelimpos];
454 				hostname = target[hostdelimpos + 1 .. target.length];
455 
456 				//if (hostname == "no.address.for.you") // WormNET hack
457 				//	hostname = null;
458 			}
459 		}
460 	}
461 
462 	void onSocketInactivity()
463 	{
464 		command("PING", to!string(Clock.currTime().toUnixTime()));
465 	}
466 
467 	void onSocketTimeout()
468 	{
469 		disconnect("Time-out", DisconnectType.error);
470 	}
471 
472 protected: // overridable methods
473 	void onEnter(string nick, string username, string hostname, string realname = null)
474 	{
475 		users[nick] = User(1, username, hostname, realname);
476 		canonicalUserNames[rfc1459toLower(nick)] = nick;
477 		if (handleEnter)
478 			handleEnter(nick);
479 	}
480 
481 	void onLeave(string nick)
482 	{
483 		users.remove(nick);
484 		canonicalUserNames.remove(rfc1459toLower(nick));
485 		if (handleLeave)
486 			handleLeave(nick);
487 	}
488 
489 	void onNick(string oldNick, string newNick)
490 	{
491 		users[newNick] = users[oldNick];
492 		users.remove(oldNick);
493 		canonicalUserNames.remove(rfc1459toLower(oldNick));
494 		canonicalUserNames[rfc1459toLower(newNick)] = newNick;
495 
496 		foreach (ref channel; channels)
497 			if (oldNick in channel.users)
498 			{
499 				channel.users[newNick] = channel.users[oldNick];
500 				channel.users.remove(oldNick);
501 			}
502 	}
503 
504 	void onMessage(string from, string to, string message, IrcMessageType type)
505 	{
506 		if (handleMessage)
507 			handleMessage(from, to, message, type);
508 	}
509 
510 public:
511 	/// The nickname to identify with.
512 	string connectNickname;
513 	/// Fail to connect if the specified nickname is taken.
514 	bool exactNickname;
515 	/// The nickname we are logged in with.
516 	/// May be different from connectNickname if
517 	/// exactNickname is false.
518 	string currentNickname;
519 	/// Refers to currentNickname when connected, connectNickname otherwise.
520 	@property ref string nickname() { return connected ? currentNickname : connectNickname; }
521 
522 	/// The user's information.
523 	string realname;
524 	/// The password used when logging in.
525 	string password;
526 	/// A list of joined channels.
527 	Channel[string] channels;
528 	/// Canonical names
529 	string[string] canonicalChannelNames, canonicalUserNames;
530 	/// Known user info
531 	User[string] users;
532 	/// Channel list for LIST command
533 	string[] channelList;
534 
535 	/// Whether to automatically send WHO requests
536 	bool autoWho;
537 	/// Log all input/output to this logger.
538 	Logger log;
539 	/// How to convert the IRC 8-bit data to and from UTF-8 (D strings must be valid UTF-8).
540 	string function(in char[]) decoder = &rawToUTF8, encoder = &UTF8ToRaw;
541 
542 	struct Channel
543 	{
544 		bool[string] users;
545 	}
546 
547 	struct User
548 	{
549 		int channelsJoined; // acts as a reference count
550 		string username, hostname;
551 		string realname;
552 	}
553 
554 	string canonicalChannelName(string channel)
555 	{
556 		string channelLower = rfc1459toLower(channel);
557 		if (channelLower in canonicalChannelNames)
558 			return canonicalChannelNames[channelLower];
559 		else
560 		{
561 			canonicalChannelNames[channelLower] = channel; // for consistency!
562 			return channel;
563 		}
564 	}
565 
566 	string canonicalUserName(string user)
567 	{
568 		string userLower = rfc1459toLower(user);
569 		if (userLower in canonicalUserNames)
570 			return canonicalUserNames[userLower];
571 		else
572 			return user;
573 	}
574 
575 	string canonicalName(string name)
576 	{
577 		string nameLower = rfc1459toLower(name);
578 		if (name[0]=='#')
579 			if (nameLower in canonicalChannelNames)
580 				return canonicalChannelNames[nameLower];
581 			else
582 				return name;
583 		else
584 			if (nameLower in canonicalUserNames)
585 				return canonicalUserNames[nameLower];
586 			else
587 				return name;
588 	}
589 
590 	string[] getUserChannels(string name)
591 	{
592 		string[] result;
593 		foreach (channelName, ref channel; channels)
594 			if (name in channel.users)
595 				result ~= channelName;
596 		return result;
597 	}
598 
599 	this(IConnection c)
600 	{
601 		conn = new IrcConnection(c);
602 		conn.handleConnect = &onConnect;
603 		conn.handleDisconnect = &onDisconnect;
604 		conn.handleReadLine = &onReadLine;
605 		conn.handleInactivity = &onSocketInactivity;
606 		conn.handleTimeout = &onSocketTimeout;
607 	}
608 
609 	/// Returns true if the connection was successfully established,
610 	/// and we have authorized ourselves to the server
611 	/// (and can thus join channels, send private messages, etc.)
612 	@property bool connected() { return _connected; }
613 
614 	/// Cancel a connection.
615 	void disconnect(string reason = null, DisconnectType type = DisconnectType.requested)
616 	{
617 		assert(conn.state == ConnectionState.connected);
618 
619 		if (reason)
620 			command("QUIT", reason);
621 		conn.disconnect(reason, type);
622 	}
623 
624 	/// Send raw string to server.
625 	void sendRaw(in char[] message)
626 	{
627 		debug (IRC) std.stdio.writefln("> %s", message);
628 		enforce(!message.contains("\n"), "Newline in outgoing IRC line: " ~ message);
629 		if (log) log("> " ~ message);
630 		conn.send(encoder(message));
631 	}
632 
633 	/// Join a channel on the network.
634 	void join(string channel, string password=null)
635 	{
636 		assert(connected);
637 
638 		canonicalChannelNames[rfc1459toLower(channel)] = channel;
639 		if (password.length)
640 			command("JOIN", channel, password);
641 		else
642 			command("JOIN", channel);
643 	}
644 
645 	/// Get a list of channels on the server
646 	void requestChannelList()
647 	{
648 		assert(connected);
649 
650 		channelList = null;  // clear channel list
651 		command("LIST");
652 	}
653 
654 	/// Get a list of logged on users
655 	void who(string mask=null)
656 	{
657 		command("WHO", mask);
658 	}
659 
660 	/// Send a regular message to the target.
661 	void message(string name, string text)
662 	{
663 		command("PRIVMSG", name, text);
664 	}
665 
666 	/// Perform an action for the target.
667 	void action(string name, string text)
668 	{
669 		command("PRIVMSG", name, "\x01" ~ "ACTION " ~ text ~ "\x01");
670 	}
671 
672 	/// Send a notice to the target.
673 	void notice(string name, string text)
674 	{
675 		command("NOTICE", name, text);
676 	}
677 
678 	/// Get/set IRC mode
679 	void mode(string[] params ...)
680 	{
681 		command("MODE", params);
682 	}
683 
684 	/// Callback for received data before it's processed.
685 	void delegate(ref string s) handleRaw;
686 
687 	/// Callback for when we have succesfully logged in.
688 	void delegate() handleConnect;
689 	/// Callback for when the socket was closed.
690 	void delegate(string reason, DisconnectType type) handleDisconnect;
691 	/// Callback for when a message has been received.
692 	void delegate(string from, string to, string message, IrcMessageType type) handleMessage;
693 	/// Callback for when someone has joined a channel.
694 	void delegate(string channel, string nick) handleJoin;
695 	/// Callback for when someone has left a channel.
696 	void delegate(string channel, string nick, string reason) handlePart;
697 	/// Callback for when someone was kicked from a channel.
698 	void delegate(string channel, string nick, string op, string reason) handleKick;
699 	/// Callback for when someone has quit from the network.
700 	void delegate(string nick, string reason, string[] channels) handleQuit;
701 	/// Callback for an INVITE command.
702 	void delegate(string nick, string channel) handleInvite;
703 	/// Callback for when the channel list was retreived
704 	void delegate(string[] channelList) handleChannelList;
705 	/// Callback for a WHO result line
706 	void delegate(string channel, string username, string host, string server, string name, string flags, int hopcount, string realname) handleWho;
707 	/// Callback for a WHO listing end
708 	void delegate(string mask) handleWhoEnd;
709 
710 	/// Callback for when we're banned from a channel.
711 	void delegate(string channel, string reason) handleBanned;
712 	/// Callback for when a channel is invite only.
713 	void delegate(string channel, string reason) handleInviteOnly;
714 	/// Callback for when a nick/channel is unavailable.
715 	void delegate(string what, string reason) handleUnavailable;
716 	/// Callback for when a channel is full.
717 	void delegate(string channel, string reason) handleChannelFull;
718 	/// Callback for when a channel needs a key.
719 	void delegate(string channel, string reason) handleChannelKey;
720 
721 	/// Callback for when a user enters our sight.
722 	void delegate(string nick) handleEnter;
723 	/// Callback for when a user leaves our sight.
724 	void delegate(string nick) handleLeave;
725 }