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 		foreach (i, parameter; params)
54 		{
55 			message ~= " ";
56 			if (parameter.indexOf(" ") != -1 || !parameter.length || parameter.startsWith(":"))
57 			{
58 				assert(i == params.length-1, "Malformed non-terminal parameter: " ~ parameter);
59 				message ~= ":";
60 			}
61 			message ~= parameter;
62 		}
63 
64 		sendRaw(message);
65 	}
66 
67 	/// Called when a connection has been established.
68 	void onConnect()
69 	{
70 		if (log) log("* Connected.");
71 		if (password.length > 0)
72 		{
73 			// Use sendRaw for hacked-up finicky IRC servers (WormNET)
74 			sendRaw("PASS " ~ password);
75 		}
76 		currentNickname = connectNickname;
77 		command("NICK", currentNickname);
78 		command("USER", username ? username : 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 	/// Username field (shown before the @ in the hostmask).
527 	/// If not set, defaults to the nickname.
528 	string username;
529 	/// A list of joined channels.
530 	Channel[string] channels;
531 	/// Canonical names
532 	string[string] canonicalChannelNames, canonicalUserNames;
533 	/// Known user info
534 	User[string] users;
535 	/// Channel list for LIST command
536 	string[] channelList;
537 
538 	/// Whether to automatically send WHO requests
539 	bool autoWho;
540 	/// Log all input/output to this logger.
541 	Logger log;
542 	/// How to convert the IRC 8-bit data to and from UTF-8 (D strings must be valid UTF-8).
543 	string function(in char[]) decoder = &rawToUTF8, encoder = &UTF8ToRaw;
544 
545 	struct Channel
546 	{
547 		bool[string] users;
548 	}
549 
550 	struct User
551 	{
552 		int channelsJoined; // acts as a reference count
553 		string username, hostname;
554 		string realname;
555 	}
556 
557 	string canonicalChannelName(string channel)
558 	{
559 		string channelLower = rfc1459toLower(channel);
560 		if (channelLower in canonicalChannelNames)
561 			return canonicalChannelNames[channelLower];
562 		else
563 		{
564 			canonicalChannelNames[channelLower] = channel; // for consistency!
565 			return channel;
566 		}
567 	}
568 
569 	string canonicalUserName(string user)
570 	{
571 		string userLower = rfc1459toLower(user);
572 		if (userLower in canonicalUserNames)
573 			return canonicalUserNames[userLower];
574 		else
575 			return user;
576 	}
577 
578 	string canonicalName(string name)
579 	{
580 		string nameLower = rfc1459toLower(name);
581 		if (name[0]=='#')
582 			if (nameLower in canonicalChannelNames)
583 				return canonicalChannelNames[nameLower];
584 			else
585 				return name;
586 		else
587 			if (nameLower in canonicalUserNames)
588 				return canonicalUserNames[nameLower];
589 			else
590 				return name;
591 	}
592 
593 	string[] getUserChannels(string name)
594 	{
595 		string[] result;
596 		foreach (channelName, ref channel; channels)
597 			if (name in channel.users)
598 				result ~= channelName;
599 		return result;
600 	}
601 
602 	this(IConnection c)
603 	{
604 		conn = new IrcConnection(c);
605 		conn.handleConnect = &onConnect;
606 		conn.handleDisconnect = &onDisconnect;
607 		conn.handleReadLine = &onReadLine;
608 		conn.handleInactivity = &onSocketInactivity;
609 		conn.handleTimeout = &onSocketTimeout;
610 	}
611 
612 	/// Returns true if the connection was successfully established,
613 	/// and we have authorized ourselves to the server
614 	/// (and can thus join channels, send private messages, etc.)
615 	@property bool connected() { return _connected; }
616 
617 	/// Cancel a connection.
618 	void disconnect(string reason = null, DisconnectType type = DisconnectType.requested)
619 	{
620 		assert(conn.state == ConnectionState.connected);
621 
622 		if (reason)
623 			command("QUIT", reason);
624 		conn.disconnect(reason, type);
625 	}
626 
627 	/// Send raw string to server.
628 	void sendRaw(in char[] message)
629 	{
630 		debug (IRC) std.stdio.writefln("> %s", message);
631 		enforce(!message.contains("\n"), "Newline in outgoing IRC line: " ~ message);
632 		if (log) log("> " ~ message);
633 		conn.send(encoder(message));
634 	}
635 
636 	/// Join a channel on the network.
637 	void join(string channel, string password=null)
638 	{
639 		assert(connected);
640 
641 		canonicalChannelNames[rfc1459toLower(channel)] = channel;
642 		if (password.length)
643 			command("JOIN", channel, password);
644 		else
645 			command("JOIN", channel);
646 	}
647 
648 	/// Get a list of channels on the server
649 	void requestChannelList()
650 	{
651 		assert(connected);
652 
653 		channelList = null;  // clear channel list
654 		command("LIST");
655 	}
656 
657 	/// Get a list of logged on users
658 	void who(string mask=null)
659 	{
660 		command("WHO", mask);
661 	}
662 
663 	/// Send a regular message to the target.
664 	void message(string name, string text)
665 	{
666 		command("PRIVMSG", name, text);
667 	}
668 
669 	/// Perform an action for the target.
670 	void action(string name, string text)
671 	{
672 		command("PRIVMSG", name, "\x01" ~ "ACTION " ~ text ~ "\x01");
673 	}
674 
675 	/// Send a notice to the target.
676 	void notice(string name, string text)
677 	{
678 		command("NOTICE", name, text);
679 	}
680 
681 	/// Get/set IRC mode
682 	void mode(string[] params ...)
683 	{
684 		command("MODE", params);
685 	}
686 
687 	/// Callback for received data before it's processed.
688 	void delegate(ref string s) handleRaw;
689 
690 	/// Callback for when we have succesfully logged in.
691 	void delegate() handleConnect;
692 	/// Callback for when the socket was closed.
693 	void delegate(string reason, DisconnectType type) handleDisconnect;
694 	/// Callback for when a message has been received.
695 	void delegate(string from, string to, string message, IrcMessageType type) handleMessage;
696 	/// Callback for when someone has joined a channel.
697 	void delegate(string channel, string nick) handleJoin;
698 	/// Callback for when someone has left a channel.
699 	void delegate(string channel, string nick, string reason) handlePart;
700 	/// Callback for when someone was kicked from a channel.
701 	void delegate(string channel, string nick, string op, string reason) handleKick;
702 	/// Callback for when someone has quit from the network.
703 	void delegate(string nick, string reason, string[] channels) handleQuit;
704 	/// Callback for an INVITE command.
705 	void delegate(string nick, string channel) handleInvite;
706 	/// Callback for when the channel list was retreived
707 	void delegate(string[] channelList) handleChannelList;
708 	/// Callback for a WHO result line
709 	void delegate(string channel, string username, string host, string server, string name, string flags, int hopcount, string realname) handleWho;
710 	/// Callback for a WHO listing end
711 	void delegate(string mask) handleWhoEnd;
712 
713 	/// Callback for when we're banned from a channel.
714 	void delegate(string channel, string reason) handleBanned;
715 	/// Callback for when a channel is invite only.
716 	void delegate(string channel, string reason) handleInviteOnly;
717 	/// Callback for when a nick/channel is unavailable.
718 	void delegate(string what, string reason) handleUnavailable;
719 	/// Callback for when a channel is full.
720 	void delegate(string channel, string reason) handleChannelFull;
721 	/// Callback for when a channel needs a key.
722 	void delegate(string channel, string reason) handleChannelKey;
723 
724 	/// Callback for when a user enters our sight.
725 	void delegate(string nick) handleEnter;
726 	/// Callback for when a user leaves our sight.
727 	void delegate(string nick) handleLeave;
728 }