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