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