1 module diggler.bot;
2 
3 import std.algorithm;
4 import std.array;
5 import std.exception : enforce;
6 import std.range : assumeSorted, ElementType, insertInPlace, isInputRange;
7 import std.socket : Socket;
8 import std.uni : isWhite;
9 
10 public import diggler.attribute;
11 public import diggler.context;
12 public import diggler.command;
13 import diggler.commandqueue;
14 import diggler.defaultcommands;
15 import diggler.tracker;
16 
17 import irc.client;
18 import irc.eventloop;
19 import irc.tracker;
20 
21 package void wakeFiber(IrcEventLoop eventLoop, CommandQueue.CommandFiber fiber)
22 {
23 	eventLoop.post(() {
24 		fiber.resume();
25 	});
26 }
27 
28 /**
29  * IRC bot.
30  *
31  * A single bot can be connected to multiple networks. The bot's
32  * username and real name are shared across all networks, but
33  * the nick name can differ.
34  */
35 final class Bot
36 {
37 	private:
38 	CommandQueue _commandQueue;
39 	IrcEventLoop _eventLoop;
40 	ClientEventHandler[] eventHandlers;
41 	ICommandSet[] _commandSets;
42 
43 	string preferredNick; // Nick can differ across connections
44 	string _userName;
45 	string _realName;
46 	string _commandPrefix;
47 
48 	package:
49 	Admin[] adminList; // Sorted
50 
51 	final class ClientEventHandler : IrcClient // Rename to `Network`?
52 	{
53 		BotTracker tracker;
54 		string[] initialChannels;
55 		IrcUser[string] adminCache;
56 
57 		this(Socket socket, string[] initialChannels)
58 		{
59 			super(socket);
60 
61 			this.initialChannels = initialChannels;
62 			this.tracker = new BotTracker(this);
63 			this.tracker.start();
64 
65 			super.onConnect ~= &handleConnect;
66 			super.onMessage ~= &handleMessage;
67 		}
68 
69 		void handleConnect()
70 		{
71 			foreach(channel; initialChannels)
72 				super.join(channel);
73 		}
74 
75 		void handleMessage(IrcUser user, in char[] target, in char[] message)
76 		{
77 			import std..string : stripLeft;
78 
79 			// handle commands
80 			if(message.startsWith(commandPrefix))
81 			{
82 				const(char)[] msg = message[commandPrefix.length .. $];
83 
84 				// TODO: use isWhite
85 				auto cmdName = msg.munch("^ ");
86 
87 				// TODO: urgh, again Phobos bugs prevent std.algorithm from handling this
88 				ICommandSet cmdSet;
89 				Command* cmd;
90 				foreach(set; _commandSets)
91 				{
92 					if(auto c = set.getCommand(cmdName))
93 					{
94 						cmdSet = set;
95 						cmd = c;
96 						break;
97 					}
98 				}
99 
100 				if(cmdSet is null)
101 					return; // No such command
102 
103 				bool isPm = target == super.nickName;
104 				if(isPm && (cmd.channelOnly || !allowPMCommands))
105 					return;
106 
107 				// TODO: smarter allocation
108 				auto cmdArgs = msg.stripLeft().idup;
109 				auto immNick = user.nickName.idup;
110 				auto immUser = IrcUser(immNick, user.userName.idup, user.hostName.idup);
111 				auto replyTarget = isPm? immNick : target.idup;
112 				auto ctx = Context(this.outer, this, tracker, replyTarget, immUser, isPm);
113 
114 				commandQueue.post(cmdSet, ctx, () {
115 					if(cmd.adminOnly && !ctx.isAdmin(immNick))
116 						return;
117 
118 					try cmd.handler(cmdArgs);
119 					catch(CommandArgumentException e)
120 					{
121 						if(auto next = e.next)
122 							super.sendf(replyTarget, "error: %s (%s)", e.msg, next.msg);
123 						else
124 							super.sendf(replyTarget, "error: %s", e.msg);
125 					}
126 					/+catch(Exception e)
127 					{
128 						debug client.sendf(user.nickName, e.toString());
129 						else
130 							throw e;
131 					}+/
132 				});
133 			}
134 		}
135 	}
136 
137 	final CommandQueue commandQueue() @property @safe pure nothrow
138 	{
139 		return _commandQueue;
140 	}
141 
142 	public:
143 	/// Bot configuration.
144 	static struct Configuration
145 	{
146 		/// All fields are required.
147 		string nickName, userName, realName, commandPrefix;
148 		deprecated alias nick = nickName;
149 	}
150 
151 	enum HelpCommand
152 	{
153 		none,
154 		simple,
155 		categorical
156 	}
157 
158 	/**
159 	 * Create a new bot with the given configuration.
160 	 *
161 	 * If $(D eventLoop) is passed, connections by this bot will be handled
162 	 * by the given event loop. Otherwise, the bot shares a default
163 	 * event loop with all other bots created in the same thread.
164 	 */
165 	this(Configuration conf, HelpCommand help = HelpCommand.categorical, string file = __FILE__, size_t line = __LINE__)
166 	{
167 		import diggler.eventloop : defaultEventLoop;
168 		this(conf, defaultEventLoop, help, file, line);
169 	}
170 
171 	/// Ditto
172 	this(Configuration conf, IrcEventLoop eventLoop, HelpCommand help = HelpCommand.categorical, string file = __FILE__, size_t line = __LINE__)
173 	{
174 		this._eventLoop = eventLoop;
175 
176 		this.commandPrefix = enforce(conf.commandPrefix, "must specify command prefix", file, line);
177 		this.preferredNick = enforce(conf.nickName, "must specify nick name", file, line);
178 		this._userName = enforce(conf.userName, "must specify user name", file, line);
179 		this._realName = enforce(conf.realName, "must specify the real name field", file, line);
180 
181 		this._commandQueue = new CommandQueue();
182 
183 		if(help != HelpCommand.none)
184 			registerCommands(new DefaultCommands(this, help));
185 	}
186 
187 	/// Boolean whether or not command invocations are allowed in private messages.
188 	/// Enabled by default.
189 	bool allowPMCommands = true;
190 
191 	final:
192 	/// The event loop handling connections for this bot.
193 	IrcEventLoop eventLoop() @property pure nothrow
194 	{
195 		return _eventLoop;
196 	}
197 
198 	/// The command prefix used to invoke bot commands through chat messages.
199 	string commandPrefix() const @property pure nothrow
200 	{
201 		return _commandPrefix;
202 	}
203 
204 	/// Ditto
205 	void commandPrefix(string newPrefix) @property pure nothrow
206 	{
207 		_commandPrefix = newPrefix;
208 	}
209 
210 	/// The username of this bot.
211 	string userName() @property pure nothrow
212 	{
213 		return _userName;
214 	}
215 
216 	/// The real name of this bot.
217 	string realName() @property pure nothrow
218 	{
219 		return _realName;
220 	}
221 
222 	/// $(D InputRange) of all networks the bot is connected
223 	/// to, where each network is represented by its $(D IrcClient) connection.
224 	auto clients() @property pure nothrow
225 	{
226 		return eventHandlers.map!((IrcClient client) => client)();
227 	}
228 
229 	/// $(D InputRange) of all command sets ($(DPREF command, ICommandSet))
230 	/// registered with the bot.
231 	auto commandSets() @property pure nothrow
232 	{
233 		return _commandSets;
234 	}
235 
236 	/**
237 	 * Request a new nick name for the bot on all networks.
238 	 *
239 	 * The bot may have different nick names on different networks.
240 	 * Use the $(D nick) property on the clients in $(MREF Bot.clients)
241 	 * to get the current nick names.
242 	 */
243 	void nickName(in char[] newNick) @property
244 	{
245 		foreach(client; clients)
246 			client.nickName = newNick;
247 	}
248 
249 	/// Ditto
250 	void nickName(string newNick) @property
251 	{
252 		foreach(client; clients)
253 			client.nickName = newNick;
254 	}
255 
256 	deprecated alias nick = nickName;
257 
258 	/**
259 	 * Connect the bot to a network described in the IRC URL url.
260 	 *
261 	 * The new connection is automatically added to the event loop
262 	 * used by this bot.
263 	 * Params:
264 	 *    url = URL containing information about server, port, SSL and more
265 	 *    serverPassword = password to server, or $(D null) to specify no password
266 	 * Returns:
267 	 *    the new connection
268 	 */
269 	// TODO: link to Dirk's irc.url in docs
270 	// TODO: try all address results, not just the first
271 	IrcClient connect(string url, in char[] serverPassword)
272 	{
273 		import std.socket : getAddress, TcpSocket;
274 		import ssl.socket;
275 		import ircUrl = irc.url;
276 
277 		auto info = ircUrl.parse(url);
278 
279 		auto address = getAddress(info.address, info.port)[0];
280 		auto af = address.addressFamily;
281 
282 		auto socket = info.secure? new SslSocket(af) : new TcpSocket(af);
283 
284 		auto client = new ClientEventHandler(socket, info.channels);
285 
286 		client.nickName = preferredNick;
287 		client.userName = userName;
288 		client.realName = realName;
289 
290 		client.connect(address, serverPassword);
291 
292 		eventLoop.add(client);
293 		eventHandlers ~= client;
294 
295 		return client;
296 	}
297 
298 	/// Ditto
299 	IrcClient connect(string url)
300 	{
301 		return connect(url, null);
302 	}
303 
304 	/**
305 	 * Register a command set with the bot.
306 	 * Params:
307 	 *    cmdSet = command set to register
308 	 * See_Also:
309 	 *    $(DPMODULE command)
310 	 */
311 	void registerCommands(ICommandSet cmdSet)
312 	{
313 		_commandSets ~= cmdSet;
314 	}
315 
316 	/**
317 	 * Represents a bot administrator.
318 	 *
319 	 * Authenticated bot administrators can run commands with the $(D @admin)
320 	 * command attribute.
321 	 *
322 	 * Both the nick name and the account name need to match for a user to be
323 	 * considered an administrator.
324 	 *
325 	 * See_Also:
326 	 *   $(MREF Bot.addAdmins)
327 	 */
328 	struct Admin
329 	{
330 		/// Nick name of administrator.
331 		string nickName;
332 
333 		/**
334 		 * Account name of administrator.
335 		 *
336 		 * The account name is the name of the account the user
337 		 * has registered with the network's authentication services,
338 		 * such as $(D AuthServ) or $(D NickServ).
339 		 */
340 		string accountName;
341 
342 		int opCmp(ref const Admin other) const
343 		{
344 			import std.algorithm : cmp;
345 			auto diff = cmp(nickName, other.nickName);
346 			return diff == 0? cmp(accountName, other.accountName) : diff;
347 		}
348 	}
349 
350 	/**
351 	 * Give bot administrator rights to all the users in $(D admins).
352 	 * See_Also:
353 	 *   $(MREF Bot.Admin)
354 	 */
355 	void addAdmins(Range)(Range admins) if(isInputRange!Range && is(Unqual!(ElementType!Range) == Admin))
356 	{
357 		for(; !admins.empty; admins.popFront())
358 		{
359 			auto sortedAdminList = adminList.assumeSorted!((a, b) => a.accountName < b.accountName);
360 
361 			Admin newAdmin = admins.front;
362 			auto accountSearch = sortedAdminList.trisect(newAdmin);
363 			if(accountSearch[1].empty) // No admin with this account
364 				adminList.insertInPlace(accountSearch[0].length, newAdmin);
365 			else
366 			{
367 				auto nickSearch = accountSearch[1].release
368 					.assumeSorted!((a, b) => a.nickName < b.nickName)
369 					.trisect(newAdmin);
370 
371 				if(nickSearch[1].empty) // Admin not yet associated with this nick
372 					adminList.insertInPlace(accountSearch[0].length + nickSearch[0].length, newAdmin);
373 			}
374 		}
375 	}
376 
377 	/// Ditto
378 	void addAdmins()(in Admin[] admins...)
379 	{
380 		addAdmins!(const(Admin)[])(admins);
381 	}
382 
383 	/**
384 	 * Convenience method to start an event loop for a bot.
385 	 *
386 	 * Same as executing $(D bot.eventLoop._run()).
387 	 */
388 	void run()
389 	{
390 		eventLoop.run();
391 	}
392 }
393 
394 unittest
395 {
396 	Bot.Configuration conf;
397 	conf.nickName = "test";
398 	conf.userName = "test";
399 	conf.realName = "Test";
400 	conf.commandPrefix = "!";
401 	auto bot = new Bot(conf);
402 
403 	bot.addAdmins(Bot.Admin("bNick", "bAccount"));
404 	assert(bot.adminList == [Bot.Admin("bNick", "bAccount")]);
405 	bot.addAdmins(Bot.Admin("bNick", "bAccount"), Bot.Admin("aNick", "aAccount"), Bot.Admin("bNick", "bAccount"));
406 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("bNick", "bAccount")]);
407 	bot.addAdmins([Bot.Admin("aNick", "bAccount")]);
408 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("aNick", "bAccount"), Bot.Admin("bNick", "bAccount")]);
409 	bot.addAdmins(Bot.Admin("aNick", "aAccount"));
410 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("aNick", "bAccount"), Bot.Admin("bNick", "bAccount")]);
411 }
412