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