1 // TODO: more accurate/atomic tracking starting procedure
2 module irc.tracker;
3 
4 import irc.client;
5 import irc.util : ExceptionConstructor;
6 
7 import std.exception : enforceEx;
8 import std.traits : Unqual;
9 import std.typetuple : TypeTuple;
10 
11 ///
12 class IrcTrackingException : Exception
13 {
14 	mixin ExceptionConstructor!();
15 }
16 
17 // TODO: Add example
18 /**
19  * Create a new channel and user tracking object for the given
20  * $(DPREF _client, IrcClient). Tracking for the new object
21  * is initially disabled; use $(MREF IrcTracker.start) to commence tracking.
22  *
23  * Params:
24  *   Payload = type of extra storage per $(MREF TrackedUser) object
25  * See_Also:
26  *    $(MREF IrcTracker), $(MREF TrackedUser.payload)
27  */
28 CustomIrcTracker!Payload track(Payload = void)(IrcClient client)
29 {
30 	return new typeof(return)(client);
31 }
32 
33 /**
34  * Keeps track of all channels and channel members
35  * visible to the associated $(DPREF client, IrcClient) connection.
36  *
37  * Params:
38  *   Payload = type of extra storage per $(MREF TrackedUser) object
39  * See_Also:
40  *   $(MREF CustomTrackedUser.payload)
41  */
42 class CustomIrcTracker(Payload = void)
43 {
44 	// TODO: mode tracking
45 	private:
46 	IrcClient _client;
47 	CustomTrackedChannel!Payload[string] _channels;
48 	CustomTrackedUser!Payload*[string] _users;
49 	CustomTrackedUser!Payload* thisUser;
50 
51 	enum State { disabled, starting, enabled }
52 	auto _isTracking = State.disabled;
53 
54 	debug(IrcTracker) import std.stdio;
55 
56 	final:
57 	debug(IrcTracker) void checkIntegrity()
58 	{
59 		import std.algorithm;
60 
61 		if(!isTracking)
62 		{
63 			assert(channels.empty);
64 			assert(_channels is null);
65 			assert(_users is null);
66 			return;
67 		}
68 
69 		foreach(channel; channels)
70 		{
71 			assert(channel.name.length != 0);
72 			assert(channel.users.length != 0);
73 			foreach(member; channel.users)
74 			{
75 				auto user = findUser(member.nickName);
76 				assert(user);
77 				assert(user == member);
78 			}
79 		}
80 
81 		assert(thisUser == _users[thisUser.nickName]);
82 
83 		foreach(user; users)
84 			if(user != thisUser)
85 				assert(channels.map!(chan => chan.users).joiner().canFind(user), "unable to find " ~ user.nickName ~ " in any channels");
86 	}
87 
88 	void onConnect()
89 	{
90 		thisUser.nickName = _client.nickName;
91 		thisUser.userName = _client.userName;
92 		thisUser.realName = _client.realName;
93 
94 		debug(IrcTracker) writeln("tracker connected; thisUser = ", *thisUser);
95 	}
96 
97 	void onSuccessfulJoin(in char[] channelName)
98 	{
99 		debug(IrcTracker)
100 		{
101 			writeln("onmejoin: ", channelName);
102 			checkIntegrity();
103 		}
104 
105 		auto channel = CustomTrackedChannel!Payload(channelName.idup);
106 		channel._users = [_client.nickName: thisUser];
107 		_channels[channel.name] = channel;
108 
109 		debug(IrcTracker)
110 		{
111 			write("checking... ");
112 			checkIntegrity();
113 			writeln("done.");
114 		}
115 	}
116 
117 	void onNameList(in char[] channelName, in char[][] nickNames)
118 	{
119 		debug(IrcTracker)
120 		{
121 			writefln("names %s: %(%s%|, %)", channelName, nickNames);
122 			checkIntegrity();
123 		}
124 
125 		auto channel = _channels[channelName];
126 
127 		foreach(nickName; nickNames)
128 		{
129 			if(auto pUser = nickName in _users)
130 			{
131 				auto user = *pUser;
132 				user.channels ~= channel.name;
133 				channel._users[cast(immutable)nickName] = user;
134 			}
135 			else
136 			{
137 				auto immNick = nickName.idup;
138 
139 				auto user = new CustomTrackedUser!Payload(immNick);
140 				user.channels = [channel.name];
141 
142 				channel._users[immNick] = user;
143 				_users[immNick] = user;
144 			}
145 		}
146 
147 		debug(IrcTracker)
148 		{
149 			import std.algorithm : map;
150 			writeln(channel._users.values.map!(user => *user));
151 			write("checking... ");
152 			checkIntegrity();
153 			writeln("done.");
154 		}
155 	}
156 
157 	void onJoin(IrcUser user, in char[] channelName)
158 	{
159 		debug(IrcTracker)
160 		{
161 			writefln("%s joined %s", user.nickName, channelName);
162 			checkIntegrity();
163 		}
164 
165 		auto channel = _channels[channelName];
166 
167 		if(auto pUser = user.nickName in _users)
168 		{
169 			auto storedUser = *pUser;
170 			if(!storedUser.userName)
171 				storedUser.userName = user.userName.idup;
172 			if(!storedUser.hostName)
173 				storedUser.hostName = user.hostName.idup;
174 
175 			storedUser.channels ~= channel.name;
176 			channel._users[user.nickName] = storedUser;
177 		}
178 		else
179 		{
180 			auto immNick = user.nickName.idup;
181 
182 			auto newUser = new CustomTrackedUser!Payload(immNick);
183 			newUser.userName = user.userName.idup;
184 			newUser.hostName = user.hostName.idup;
185 			newUser.channels = [channel.name];
186 
187 			_users[immNick] = newUser;
188 			channel._users[immNick] = newUser;
189 		}
190 
191 		debug(IrcTracker)
192 		{
193 			write("checking... ");
194 			checkIntegrity();
195 			writeln("done.");
196 		}
197 	}
198 
199 	// Utility function
200 	void onMeLeave(in char[] channelName)
201 	{
202 		import std.algorithm : countUntil, remove, SwapStrategy;
203 
204 		debug(IrcTracker)
205 		{
206 			writeln("onmeleave: ", channelName);
207 			checkIntegrity();
208 		}
209 
210 		auto channel = _channels[channelName];
211 
212 		foreach(ref user; channel._users)
213 		{
214 			auto channelIndex = user.channels.countUntil(channelName);
215 			assert(channelIndex != -1);
216 			user.channels = user.channels.remove!(SwapStrategy.unstable)(channelIndex);
217 			if(user.channels.length == 0 && user.nickName != client.nickName)
218 				_users.remove(cast(immutable)user.nickName);
219 		}
220 
221 		_channels.remove(channel.name);
222 
223 		debug(IrcTracker)
224 		{
225 			write("checking... ");
226 			checkIntegrity();
227 			writeln("done.");
228 		}
229 	}
230 
231 	// Utility function
232 	void onLeave(in char[] channelName, in char[] nick)
233 	{
234 		import std.algorithm : countUntil, remove, SwapStrategy;
235 
236 		debug(IrcTracker)
237 		{
238 			writefln("%s left %s", nick, channelName);
239 			checkIntegrity();
240 		}
241 
242 		_channels[channelName]._users.remove(cast(immutable)nick);
243 
244 		auto pUser = nick in _users;
245 		auto user = *pUser;
246 		auto channelIndex = user.channels.countUntil(channelName);
247 		assert(channelIndex != -1);
248 		user.channels = user.channels.remove!(SwapStrategy.unstable)(channelIndex);
249 		if(user.channels.length == 0)
250 			_users.remove(cast(immutable)nick);
251 
252 		debug(IrcTracker)
253 		{
254 			write("checking... ");
255 			checkIntegrity();
256 			writeln("done.");
257 		}
258 	}
259 
260 	void onPart(IrcUser user, in char[] channelName)
261 	{
262 		if(user.nickName == client.nickName)
263 			onMeLeave(channelName);
264 		else
265 			onLeave(channelName, user.nickName);
266 	}
267 
268 	void onKick(IrcUser kicker, in char[] channelName, in char[] nick, in char[] comment)
269 	{
270 		debug(IrcTracker) writefln(`%s kicked %s: %s`, kicker.nickName, nick, comment);
271 		if(nick == client.nickName)
272 			onMeLeave(channelName);
273 		else
274 			onLeave(channelName, nick);
275 	}
276 
277 	void onQuit(IrcUser user, in char[] comment)
278 	{
279 		debug(IrcTracker)
280 		{
281 			writefln("%s quit", user.nickName);
282 			checkIntegrity();
283 		}
284 
285 		foreach(channelName; _users[user.nickName].channels)
286 		{
287 			debug(IrcTracker) writefln("%s left %s by quitting", user.nickName, channelName);
288 			_channels[channelName]._users.remove(cast(immutable)user.nickName);
289 		}
290 
291 		_users.remove(cast(immutable)user.nickName);
292 
293 		debug(IrcTracker)
294 		{
295 			write("checking... ");
296 			checkIntegrity();
297 			writeln("done.");
298 		}
299 	}
300 
301 	void onNickChange(IrcUser user, in char[] newNick)
302 	{
303 		debug(IrcTracker)
304 		{
305 			writefln("%s changed nick to %s", user.nickName, newNick);
306 			checkIntegrity();
307 		}
308 
309 		auto userInSet = _users[user.nickName];
310 		_users.remove(cast(immutable)user.nickName);
311 
312 		auto immNewNick = newNick.idup;
313 		userInSet.nickName = immNewNick;
314 		_users[immNewNick] = userInSet;
315 
316 		debug(IrcTracker)
317 		{
318 			write("checking... ");
319 			checkIntegrity();
320 			writeln("done.");
321 		}
322 	}
323 
324 	alias eventHandlers = TypeTuple!(
325 		onConnect, onSuccessfulJoin, onNameList, onJoin, onPart, onKick, onQuit, onNickChange
326 	);
327 
328 	// Start tracking functions
329 	void onMyChannelsReply(in char[] nick, in char[][] channels)
330 	{
331 		if(nick != client.nickName)
332 			return;
333 
334 		_client.onWhoisChannelsReply.unsubscribeHandler(&onMyChannelsReply);
335 		_client.onWhoisEnd.unsubscribeHandler(&onWhoisEnd);
336 
337 		if(_isTracking != State.starting)
338 			return;
339 
340 		startNow();
341 
342 		foreach(channel; channels)
343 			onSuccessfulJoin(channel);
344 
345 		_client.queryNames(channels);
346 	}
347 
348 	void onWhoisEnd(in char[] nick)
349 	{
350 		if(nick != client.nickName)
351 			return;
352 
353 		// Weren't in any channels.
354 
355 		_client.onWhoisChannelsReply.unsubscribeHandler(&onMyChannelsReply);
356 		_client.onWhoisEnd.unsubscribeHandler(&onWhoisEnd);
357 
358 		if(_isTracking != State.starting)
359 			return;
360 
361 		startNow();
362 	}
363 
364 	private void startNow()
365 	{
366 		assert(_isTracking != State.enabled);
367 
368 		foreach(handler; eventHandlers)
369 			mixin("client." ~ __traits(identifier, handler)) ~= &handler;
370 
371 		auto thisNick = _client.nickName;
372 		thisUser = new CustomTrackedUser!Payload(thisNick);
373 		thisUser.userName = _client.userName;
374 		thisUser.realName = _client.realName;
375 		_users[thisNick] = thisUser;
376 
377 		_isTracking = State.enabled;
378 	}
379 
380 	public:
381 	this(IrcClient client)
382 	{
383 		this._client = client;
384 	}
385 
386 	~this()
387 	{
388 		stop();
389 	}
390 
391 	/**
392 	 * Initiate or restart tracking, or do nothing if the tracker is already tracking.
393 	 *
394 	 * If the associated client is unconnected, tracking starts immediately.
395 	 * If it is connected, information about the client's current channels will be queried,
396 	 * and tracking starts as soon as the information has been received.
397 	 */
398 	void start()
399 	{
400 		if(_isTracking != State.disabled)
401 			return;
402 
403 		if(_client.connected)
404 		{
405 			_client.onWhoisChannelsReply ~= &onMyChannelsReply;
406 			_client.onWhoisEnd ~= &onWhoisEnd;
407 			_client.queryWhois(_client.nickName);
408 			_isTracking = State.starting;
409 		}
410 		else
411 			startNow();
412 	}
413 
414 	/**
415 	 * Stop tracking, or do nothing if the tracker is not currently tracking.
416 	 */
417 	void stop()
418 	{
419 		final switch(_isTracking)
420 		{
421 			case State.enabled:
422 				_users = null;
423 				thisUser = null;
424 				_channels = null;
425 				foreach(handler; eventHandlers)
426 					mixin("client." ~ __traits(identifier, handler)).unsubscribeHandler(&handler);
427 				break;
428 			case State.starting:
429 				_client.onWhoisChannelsReply.unsubscribeHandler(&onMyChannelsReply);
430 				_client.onWhoisEnd.unsubscribeHandler(&onWhoisEnd);
431 				break;
432 			case State.disabled:
433 				return;
434 		}
435 
436 		_isTracking = State.disabled;
437 	}
438 
439 	/// Boolean whether or not the tracker is currently tracking.
440 	bool isTracking() const @property @safe pure nothrow
441 	{
442 		return _isTracking == State.enabled;
443 	}
444 
445 	/// $(DPREF _client, IrcClient) that this tracker is tracking for.
446 	inout(IrcClient) client() inout @property @safe pure nothrow
447 	{
448 		return _client;
449 	}
450 
451 	/**
452 	 * $(D InputRange) (with $(D length)) of all _channels the associated client is currently
453 	 * a member of.
454 	 * Throws:
455 	 *    $(MREF IrcTrackingException) if the tracker is disabled or not yet ready
456 	 */
457 	auto channels() @property
458 	{
459 		import std.range : takeExactly;
460 		enforceEx!IrcTrackingException(_isTracking, "not currently tracking");
461 		return _channels.byValue.takeExactly(_channels.length);
462 	}
463 
464 	unittest
465 	{
466 		import std.range;
467 		static assert(isInputRange!(typeof(CustomIrcTracker.init.channels)));
468 		static assert(is(ElementType!(typeof(CustomIrcTracker.init.channels)) : CustomTrackedChannel!Payload));
469 		static assert(hasLength!(typeof(CustomIrcTracker.init.channels)));
470 	}
471 
472 	/**
473 	 * $(D InputRange) (with $(D length)) of all _users currently seen by the associated client.
474 	 *
475 	 * The range includes the user for the associated client. Users that are not a member of any
476 	 * of the channels the associated client is a member of, but have sent a private message to
477 	 * the associated client, are $(I not) included.
478 	 * Throws:
479 	 *    $(MREF IrcTrackingException) if the tracker is disabled or not yet ready
480 	 */
481 	auto users() @property
482 	{
483 		import std.algorithm : map;
484 		import std.range : takeExactly;
485 		enforceEx!IrcTrackingException(_isTracking, "not currently tracking");
486 		return _users.byValue.takeExactly(_users.length);
487 	}
488 
489 	unittest
490 	{
491 		import std.range;
492 		static assert(isInputRange!(typeof(CustomIrcTracker.init.users)));
493 		static assert(is(ElementType!(typeof(CustomIrcTracker.init.users)) == CustomTrackedUser!Payload*));
494 		static assert(hasLength!(typeof(CustomIrcTracker.init.users)));
495 	}
496 
497 	/**
498 	 * Lookup a channel on this tracker by name.
499 	 *
500 	 * The channel name must include the channel name prefix. Returns $(D null)
501 	 * if the associated client is not currently a member of the given channel.
502 	 * Params:
503 	 *    channelName = name of channel to lookup
504 	 * Throws:
505 	 *    $(MREF IrcTrackingException) if the tracker is disabled or not yet ready
506 	 * See_Also:
507 	 *    $(MREF TrackedChannel)
508 	 */
509 	CustomTrackedChannel!Payload* findChannel(in char[] channelName)
510 	{
511 		enforceEx!IrcTrackingException(_isTracking, "not currently tracking");
512 		return channelName in _channels;
513 	}
514 
515 	/**
516 	 * Lookup a user on this tracker by nick name.
517 	 *
518 	 * Users are searched among the members of all channels the associated
519 	 * client is currently a member of. The set includes the user for the
520 	 * associated client.
521 	 * Params:
522 	 *    nickName = nick name of user to lookup
523 	 * Throws:
524 	 *    $(MREF IrcTrackingException) if the tracker is disabled or not yet ready
525 	 * See_Also:
526 	 *    $(MREF TrackedUser)
527 	 */
528 	CustomTrackedUser!Payload* findUser(in char[] nickName)
529 	{
530 		enforceEx!IrcTrackingException(_isTracking, "not currently tracking");
531 		if(auto user = nickName in _users)
532 			return *user;
533 		else
534 			return null;
535 	}
536 }
537 
538 /// Ditto
539 alias IrcTracker = CustomIrcTracker!void;
540 
541 /**
542  * Represents an IRC channel and its member users for use by $(MREF IrcTracker).
543  *
544  * The list of members includes the user associated with the tracking object.
545  * If the $(D IrcTracker) used to access an instance of this type
546  * was since stopped, the channel presents the list of members as it were
547  * at the time of the tracker being stopped.
548  *
549  * Params:
550  *   Payload = type of extra storage per $(MREF TrackedUser) object
551  * See_Also:
552  *   $(MREF CustomTrackedUser.payload)
553  */
554 struct CustomTrackedChannel(Payload = void)
555 {
556 	private:
557 	string _name;
558 	CustomTrackedUser!Payload*[string] _users;
559 
560 	this(string name, CustomTrackedUser!Payload*[string] users = null)
561 	{
562 		_name = name;
563 		_users = users;
564 	}
565 
566 	public:
567 	@disable this();
568 
569 	/// Name of the channel, including the channel prefix.
570 	string name() @property
571 	{
572 		return _name;
573 	}
574 
575 	/// $(D InputRange) of all member _users of this channel,
576 	/// where each user is given as a $(D (MREF TrackedUser)*).
577 	auto users() @property
578 	{
579 		import std.range : takeExactly;
580 		return _users.byValue.takeExactly(_users.length);
581 	}
582 
583 	/**
584 	 * Lookup a member of this channel by nick name.
585 	 * $(D null) is returned if the given nick is not a member
586 	 * of this channel.
587 	 * Params:
588 	 *   nick = nick name of member to lookup
589 	 */
590 	CustomTrackedUser!Payload* opBinary(string op : "in")(in char[] nick)
591 	{
592 		enforceEx!IrcTrackingException(cast(bool)this, "the TrackedChannel is invalid");
593 		if(auto pUser = nick in _users)
594 			return *pUser;
595 		else
596 			return null;
597 	}
598 
599 	static if(!is(Payload == void))
600 	{
601 		TrackedChannel erasePayload() @property
602 		{
603 			return TrackedChannel(_name, cast(TrackedUser*[string])_users);
604 		}
605 
606 		alias erasePayload this;
607 	}
608 }
609 
610 /// Ditto
611 alias TrackedChannel = CustomTrackedChannel!void;
612 
613 /**
614  * Represents an IRC user for use by $(MREF IrcTracker).
615  */
616 struct TrackedUser
617 {
618 	private:
619 	this(string nickName)
620 	{
621 		this.nickName = nickName;
622 	}
623 
624 	public:
625 	@disable this();
626 
627 	/**
628 	 * Nick name, user name and host name of the _user.
629 	 *
630 	 * $(D TrackedUser) is a super-type of $(DPREF protocol, IrcUser).
631 	 *
632 	 * Only the nick name is guaranteed to be non-null.
633 	 * See_Also:
634 	 *   $(DPREF protocol, IrcUser)
635 	 */
636 	IrcUser user;
637 
638 	/// Ditto
639 	alias user this;
640 
641     /**
642      * Real name of the user. Is $(D null) unless a whois-query
643      * has been successfully issued for the user.
644      *
645      * See_Also:
646      *   $(DPREF client, IrcClient.queryWhois)
647      */
648 	string realName;
649 
650 	/**
651 	 * Channels in which both the current user and the tracked
652 	 * user share membership.
653 	 *
654 	 * See_Also:
655 	 * $(DPREF client, IrcClient.queryWhois) to query channels
656 	 * a user is in, regardless of shared membership with the current user.
657 	 */
658 	string[] channels;
659 
660 	void toString(scope void delegate(const(char)[]) sink) const
661 	{
662 		import std.format;
663 		user.toString(sink);
664 
665 		if(realName)
666 		{
667 			sink("$");
668 			sink(realName);
669 		}
670 
671 		formattedWrite(sink, "(%(%s%|,%))", channels);
672 	}
673 
674 	unittest
675 	{
676 		import std.string : format;
677 		auto user = TrackedUser("nick");
678 		user.userName = "user";
679 		user.realName = "Foo Bar";
680 		user.channels = ["#a", "#b"];
681 		assert(format("%s", user) == `nick!user$Foo Bar("#a","#b")`);
682 	}
683 }
684 
685 /**
686  * Represents an IRC user for use by $(MREF CustomIrcTracker).
687  * Params:
688  *   Payload = type of extra data per user.
689  */
690 align(1) struct CustomTrackedUser(Payload)
691 {
692 	/// $(D CustomTrackedUser) is a super-type of $(MREF TrackedUser).
693 	TrackedUser user;
694 
695 	/// Ditto
696 	alias user this;
697 
698 	/**
699 	 * Extra data attached to this user for per-application data.
700      */
701 	Payload payload;
702 
703 	///
704 	this(string nickName)
705 	{
706 		user = TrackedUser(nickName);
707 	}
708 }
709 
710 ///
711 alias CustomTrackedUser(Payload : void) = TrackedUser;