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