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 	/**
152 	 * Create a new bot with the given configuration.
153 	 *
154 	 * If $(D eventLoop) is passed, connections by this bot will be handled
155 	 * by the given event loop. Otherwise, the bot shares a default
156 	 * event loop with all other bots created in the same thread.
157 	 */
158 	this(Configuration conf, string file = __FILE__, size_t line = __LINE__)
159 	{
160 		import diggler.eventloop : defaultEventLoop;
161 		this(conf, defaultEventLoop, file, line);
162 	}
163 
164 	/// Ditto
165 	this(Configuration conf, IrcEventLoop eventLoop, string file = __FILE__, size_t line = __LINE__)
166 	{
167 		this._eventLoop = eventLoop;
168 
169 		this.commandPrefix = enforce(conf.commandPrefix, "must specify command prefix", file, line);
170 		this.preferredNick = enforce(conf.nickName, "must specify nick name", file, line);
171 		this._userName = enforce(conf.userName, "must specify user name", file, line);
172 		this._realName = enforce(conf.realName, "must specify the real name field", file, line);
173 
174 		this._commandQueue = new CommandQueue();
175 
176 		registerCommands(new DefaultCommands(this));
177 	}
178 
179 	/// Boolean whether or not command invocations are allowed in private messages.
180 	/// Enabled by default.
181 	bool allowPMCommands = true;
182 
183 	final:
184 	/// The event loop handling connections for this bot.
185 	IrcEventLoop eventLoop() @property pure nothrow
186 	{
187 		return _eventLoop;
188 	}
189 
190 	/// The command prefix used to invoke bot commands through chat messages.
191 	string commandPrefix() const @property pure nothrow
192 	{
193 		return _commandPrefix;
194 	}
195 
196 	/// Ditto
197 	void commandPrefix(string newPrefix) @property pure nothrow
198 	{
199 		_commandPrefix = newPrefix;
200 	}
201 
202 	/// The username of this bot.
203 	string userName() @property pure nothrow
204 	{
205 		return _userName;
206 	}
207 
208 	/// The real name of this bot.
209 	string realName() @property pure nothrow
210 	{
211 		return _realName;
212 	}
213 
214 	/// $(D InputRange) of all networks the bot is connected
215 	/// to, where each network is represented by its $(D IrcClient) connection.
216 	auto clients() @property pure nothrow
217 	{
218 		return eventHandlers.map!((IrcClient client) => client)();
219 	}
220 
221 	/// $(D InputRange) of all command sets ($(DPREF command, ICommandSet))
222 	/// registered with the bot.
223 	auto commandSets() @property pure nothrow
224 	{
225 		return _commandSets;
226 	}
227 
228 	/**
229 	 * Request a new nick name for the bot on all networks.
230 	 *
231 	 * The bot may have different nick names on different networks.
232 	 * Use the $(D nick) property on the clients in $(MREF Bot.clients)
233 	 * to get the current nick names.
234 	 */
235 	void nickName(in char[] newNick) @property
236 	{
237 		foreach(client; clients)
238 			client.nickName = newNick;
239 	}
240 
241 	/// Ditto
242 	void nickName(string newNick) @property
243 	{
244 		foreach(client; clients)
245 			client.nickName = newNick;
246 	}
247 
248 	deprecated alias nick = nickName;
249 
250 	/**
251 	 * Connect the bot to a network described in the IRC URL url.
252 	 *
253 	 * The new connection is automatically added to the event loop
254 	 * used by this bot.
255 	 * Params:
256 	 *    url = URL containing information about server, port, SSL and more
257 	 *    serverPassword = password to server, or $(D null) to specify no password
258 	 * Returns:
259 	 *    the new connection
260 	 */
261 	// TODO: link to Dirk's irc.url in docs
262 	// TODO: try all address results, not just the first
263 	IrcClient connect(string url, in char[] serverPassword)
264 	{
265 		import std.socket : getAddress, TcpSocket;
266 		import ssl.socket;
267 		import ircUrl = irc.url;
268 
269 		auto info = ircUrl.parse(url);
270 
271 		auto address = getAddress(info.address, info.port)[0];
272 		auto af = address.addressFamily;
273 
274 		auto socket = info.secure? new SslSocket(af) : new TcpSocket(af);
275 
276 		auto client = new ClientEventHandler(socket, info.channels);
277 
278 		client.nickName = preferredNick;
279 		client.userName = userName;
280 		client.realName = realName;
281 
282 		client.connect(address, serverPassword);
283 
284 		eventLoop.add(client);
285 		eventHandlers ~= client;
286 
287 		return client;
288 	}
289 
290 	/// Ditto
291 	IrcClient connect(string url)
292 	{
293 		return connect(url, null);
294 	}
295 
296 	/**
297 	 * Register a command set with the bot.
298 	 * Params:
299 	 *    cmdSet = command set to register
300 	 * See_Also:
301 	 *    $(DPMODULE command)
302 	 */
303 	void registerCommands(ICommandSet cmdSet)
304 	{
305 		_commandSets ~= cmdSet;
306 	}
307 
308 	/**
309 	 * Represents a bot administrator.
310 	 *
311 	 * Authenticated bot administrators can run commands with the $(D @admin)
312 	 * command attribute.
313 	 *
314 	 * Both the nick name and the account name need to match for a user to be
315 	 * considered an administrator.
316 	 *
317 	 * See_Also:
318 	 *   $(MREF Bot.addAdmins)
319 	 */
320 	struct Admin
321 	{
322 		/// Nick name of administrator.
323 		string nickName;
324 
325 		/**
326 		 * Account name of administrator.
327 		 *
328 		 * The account name is the name of the account the user
329 		 * has registered with the network's authentication services,
330 		 * such as $(D AuthServ) or $(D NickServ).
331 		 */
332 		string accountName;
333 
334 		int opCmp(ref const Admin other) const
335 		{
336 			import std.algorithm : cmp;
337 			auto diff = cmp(nickName, other.nickName);
338 			return diff == 0? cmp(accountName, other.accountName) : diff;
339 		}
340 	}
341 
342 	/**
343 	 * Give bot administrator rights to all the users in $(D admins).
344 	 * See_Also:
345 	 *   $(MREF Bot.Admin)
346 	 */
347 	void addAdmins(Range)(Range admins) if(isInputRange!Range && is(Unqual!(ElementType!Range) == Admin))
348 	{
349 		for(; !admins.empty; admins.popFront())
350 		{
351 			auto sortedAdminList = adminList.assumeSorted!((a, b) => a.accountName < b.accountName);
352 
353 			Admin newAdmin = admins.front;
354 			auto accountSearch = sortedAdminList.trisect(newAdmin);
355 			if(accountSearch[1].empty) // No admin with this account
356 				adminList.insertInPlace(accountSearch[0].length, newAdmin);
357 			else
358 			{
359 				auto nickSearch = accountSearch[1].release
360 					.assumeSorted!((a, b) => a.nickName < b.nickName)
361 					.trisect(newAdmin);
362 
363 				if(nickSearch[1].empty) // Admin not yet associated with this nick
364 					adminList.insertInPlace(accountSearch[0].length + nickSearch[0].length, newAdmin);
365 			}
366 		}
367 	}
368 
369 	/// Ditto
370 	void addAdmins()(in Admin[] admins...)
371 	{
372 		addAdmins!(const(Admin)[])(admins);
373 	}
374 
375 	/**
376 	 * Convenience method to start an event loop for a bot.
377 	 *
378 	 * Same as executing $(D bot.eventLoop._run()).
379 	 */
380 	void run()
381 	{
382 		eventLoop.run();
383 	}
384 }
385 
386 unittest
387 {
388 	Bot.Configuration conf;
389 	conf.nickName = "test";
390 	conf.userName = "test";
391 	conf.realName = "Test";
392 	conf.commandPrefix = "!";
393 	auto bot = new Bot(conf);
394 
395 	bot.addAdmins(Bot.Admin("bNick", "bAccount"));
396 	assert(bot.adminList == [Bot.Admin("bNick", "bAccount")]);
397 	bot.addAdmins(Bot.Admin("bNick", "bAccount"), Bot.Admin("aNick", "aAccount"), Bot.Admin("bNick", "bAccount"));
398 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("bNick", "bAccount")]);
399 	bot.addAdmins([Bot.Admin("aNick", "bAccount")]);
400 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("aNick", "bAccount"), Bot.Admin("bNick", "bAccount")]);
401 	bot.addAdmins(Bot.Admin("aNick", "aAccount"));
402 	assert(bot.adminList == [Bot.Admin("aNick", "aAccount"), Bot.Admin("aNick", "bAccount"), Bot.Admin("bNick", "bAccount")]);
403 }
404