1 /** 2 * A simple IRC client. 3 * 4 * License: 5 * This Source Code Form is subject to the terms of 6 * the Mozilla Public License, v. 2.0. If a copy of 7 * the MPL was not distributed with this file, You 8 * can obtain one at http://mozilla.org/MPL/2.0/. 9 * 10 * Authors: 11 * Stéphan Kochen <stephan@kochen.nl> 12 * Vladimir Panteleev <ae@cy.md> 13 * Vincent Povirk <madewokherd@gmail.com> 14 * Simon Arlott 15 */ 16 17 module ae.net.irc.client; 18 19 import std.conv; 20 import std.datetime; 21 import std.exception; 22 import std.random; 23 import std.string; 24 25 import ae.net.asockets; 26 import ae.sys.log; 27 import ae.utils.text; 28 29 public import ae.net.irc.common; 30 31 /// An IRC client class. 32 class IrcClient 33 { 34 private: 35 /// The socket this class wraps. 36 IrcConnection conn; 37 /// Whether the socket is connected 38 bool _connected; 39 40 /// Helper function for sending a command. 41 void command(string command, string[] params ...) 42 { 43 assert(command.length > 1); 44 string message = toUpper(command); 45 46 while ((params.length > 1) && (params[$-1]==null || params[$-1]=="")) 47 params.length = params.length-1; 48 49 assert(params.length <= 15); 50 51 foreach (i, parameter; params) 52 { 53 message ~= " "; 54 if (parameter.indexOf(" ") != -1 || !parameter.length || parameter.startsWith(":")) 55 { 56 assert(i == params.length-1, "Malformed non-terminal parameter: " ~ parameter); 57 message ~= ":"; 58 } 59 message ~= parameter; 60 } 61 62 sendRaw(message); 63 } 64 65 /// Called when a connection has been established. 66 void onConnect() 67 { 68 if (log) log("* Connected."); 69 if (password.length > 0) 70 { 71 // Use sendRaw for hacked-up finicky IRC servers (WormNET) 72 sendRaw("PASS " ~ password); 73 } 74 currentNickname = connectNickname; 75 command("NICK", currentNickname); 76 command("USER", username ? username : currentNickname, "hostname", "servername", realname); 77 } 78 79 /// Called when a connection was closed. 80 void onDisconnect(string reason, DisconnectType type) 81 { 82 if (log) log(format("* Disconnected (%s)", reason)); 83 currentNickname = null; 84 _connected = false; 85 if (handleDisconnect) 86 handleDisconnect(reason, type); 87 channels = null; 88 users = null; 89 canonicalChannelNames = canonicalUserNames = null; 90 } 91 92 /// Remove the @+ etc. prefix from a nickname 93 string removePrefix(string nick) 94 { 95 // TODO: do this properly, maybe? 96 if (nick[0]=='@' || nick[0]=='+') 97 return nick[1..$]; 98 else 99 return nick; 100 } 101 102 /// Called when a line has been received. 103 void onReadLine(string line) 104 { 105 line = decoder(line); 106 if (handleRaw) 107 { 108 handleRaw(line); 109 if (line is null) 110 return; 111 } 112 if (log) log("< " ~ line); 113 string nick, username, hostname; 114 if (line.startsWith(":")) 115 { 116 auto space = line.indexOf(' '); 117 string target = line[1 .. space]; 118 parseTarget(target, nick, username, hostname); 119 //std.stdio.writefln("%s => %s!%s@%s", target, nick, username, hostname); 120 nick = canonicalUserName(nick); 121 auto userptr = nick in users; 122 if (userptr) 123 { 124 userptr.username = username; 125 userptr.hostname = hostname; 126 } 127 line = line[space + 1 .. line.length]; 128 } 129 130 string[] params; 131 auto colon = line.indexOf(" :"); 132 if (colon == -1) 133 params = split(line); 134 else 135 { 136 params = split(line[0 .. colon]); 137 params ~= line[colon + 2 .. line.length]; 138 } 139 140 string command = toUpper(params[0]); 141 params = params[1 .. params.length]; 142 143 // Whenever D supports this, turn this into a 144 // constant associative array of anonymous functions. 145 // VP 2006.12.16: for now, moving functions inside the switch, because the code is too cumbersome to read and maintain with each handler in a separate function 146 switch (command) 147 { 148 case "001": // login successful 149 // VP 2006.12.13: changing 376 to 001, since 376 doesn't appear on all servers and it's safe to send commands after 001 anyway 150 _connected = true; 151 onEnter(currentNickname, username, hostname, realname); // add ourselves 152 153 if (handleConnect) 154 handleConnect(); 155 if (autoWho) 156 who(); // get info on all users 157 break; 158 159 case "433": // nickname in use 160 if (exactNickname) 161 disconnect("Nickname in use", DisconnectType.error); 162 else 163 { 164 currentNickname = "%s%03d".format(connectNickname, uniform(0, 1000)); 165 this.command("NICK", currentNickname); 166 } 167 break; 168 169 case "321": // LIST channel start 170 channelList = null; // clear list 171 break; 172 173 case "322": // LIST channel line 174 channelList ~= params[1]; 175 break; 176 177 case "323": // LIST channel end 178 if (handleChannelList) 179 handleChannelList(channelList); 180 break; 181 182 case "353": // NAMES line 183 string channel = canonicalChannelName(params[$-2]); 184 assert(channel in channels); 185 string[] nicks = params[$-1].split(" "); 186 foreach (fullnick; nicks) 187 if (fullnick.length>0) 188 { 189 auto nickname = removePrefix(fullnick); 190 if (!(nickname in users)) 191 onEnter(nickname, null, null); 192 else 193 users[nickname].channelsJoined++; 194 channels[channel].users[nickname] = true; 195 } 196 break; 197 198 case "366": // NAMES end 199 // VP 2007.01.07: perhaps the onJoin handler code for when we join a channel ought to be moved here... 200 break; 201 202 case "352": // WHO line 203 // 0 1 2 3 4 5 6 7 204 // :wormnet1.team17.com 352 CyberShadow * Username host-86-106-217-211.moldtelecom.md wormnet1.team17.com CyberShadow H :0 40 0 RO 205 //void delegate(string channel, string username, string host, string server, string name, string flags, string userinfo) handleWho; 206 while (params.length<8) 207 params ~= [null]; 208 209 string[] gecos = params[7].split(" "); 210 int hopcount = 0; 211 try 212 hopcount = to!int(gecos[0]); 213 catch (Exception e) 214 hopcount = 0; 215 if (gecos.length > 1) 216 gecos = gecos[1..$]; 217 else 218 gecos = null; 219 220 string nickname = params[5]; 221 username = params[2]; 222 hostname = params[3]; 223 auto userptr = nickname in users; 224 if (userptr) 225 { 226 if (userptr.username is null) 227 userptr.username = username; 228 else 229 assert(userptr.username == username, userptr.username ~ " != " ~ username); 230 if (userptr.hostname is null) 231 userptr.hostname = hostname; 232 //else 233 // assert(userptr.hostname == hostname, userptr.hostname ~ " != " ~ hostname); 234 string realname = std..string.join(gecos, " "); 235 if (userptr.realname is null) 236 userptr.realname = realname; 237 else 238 assert(userptr.realname == realname); 239 } 240 241 if (handleWho) 242 handleWho(params[1], params[2], params[3], params[4], params[5], params[6], hopcount, std..string.join(gecos, " ")); 243 break; 244 245 case "315": // WHO end 246 if (handleWhoEnd) 247 handleWhoEnd(params.length>=2 ? params[1] : null); 248 break; 249 250 case "437": // Nick/channel is temporarily unavailable 251 if (handleUnavailable) 252 handleUnavailable(params[1], params[2]); 253 break; 254 255 case "471": // Channel full 256 if (handleChannelFull) 257 handleChannelFull(params[1], params[2]); 258 break; 259 260 case "473": // Invite only 261 if (handleInviteOnly) 262 handleInviteOnly(params[1], params[2]); 263 break; 264 265 case "474": // Banned 266 if (handleBanned) 267 handleBanned(params[1], params[2]); 268 break; 269 270 case "475": // Wrong key 271 if (handleChannelKey) 272 handleChannelKey(params[1], params[2]); 273 break; 274 275 case "PING": 276 if (params.length == 1) 277 this.command("PONG", params[0]); 278 break; 279 280 case "PRIVMSG": 281 if (params.length != 2) 282 return; 283 284 string target = canonicalName(params[0]); 285 IrcMessageType type = IrcMessageType.NORMAL; 286 string text = params[1]; 287 if (text.startsWith("\x01ACTION")) 288 { 289 type = IrcMessageType.ACTION; 290 text = text[7 .. $]; 291 if (text.startsWith(" ")) 292 text = text[1..$]; 293 if (text.endsWith("\x01")) 294 text = text[0..$-1]; 295 } 296 onMessage(nick, target, text, type); 297 break; 298 299 case "NOTICE": 300 if (params.length != 2) 301 return; 302 303 string target = canonicalName(params[0]); 304 onMessage(nick, target, params[1], IrcMessageType.NOTICE); 305 break; 306 307 case "JOIN": 308 if (params.length != 1) 309 return; 310 311 string channel = canonicalChannelName(params[0]); 312 313 if (!(nick in users)) 314 { 315 onEnter(nick, username, hostname); 316 if (autoWho) 317 who(nick); 318 } 319 else 320 users[nick].channelsJoined++; 321 322 if (nick == currentNickname) 323 { 324 assert(!(channel in channels)); 325 channels[channel] = Channel(); 326 if (autoWho) 327 who(channel); 328 } 329 else 330 { 331 assert(channel in channels); 332 channels[channel].users[nick] = true; 333 } 334 335 if (handleJoin) 336 handleJoin(channel, nick); 337 338 break; 339 340 case "PART": 341 if (params.length < 1 || params.length > 2) 342 return; 343 344 string channel = canonicalChannelName(params[0]); 345 346 if (handlePart) 347 handlePart(channel, nick, params.length == 2 ? params[1] : null); 348 349 onUserParted(nick, channel); 350 break; 351 352 case "QUIT": 353 string[] oldChannels; 354 foreach (channelName, channel; channels) 355 if (nick in channel.users) 356 oldChannels ~= channelName; 357 358 if (handleQuit) 359 handleQuit(nick, params.length == 1 ? params[0] : null, oldChannels); 360 361 foreach (channel; channels) 362 if (nick in channel.users) 363 channel.users.remove(nick); 364 365 onLeave(nick); 366 break; 367 368 case "KICK": 369 if (params.length < 2 || params.length > 3) 370 return; 371 372 string channel = canonicalChannelName(params[0]); 373 374 string user = canonicalUserName(params[1]); 375 if (handleKick) 376 { 377 if (params.length == 3) 378 handleKick(channel, user, nick, params[2]); 379 else 380 handleKick(channel, user, nick, null); 381 } 382 383 onUserParted(user, channel); 384 break; 385 386 case "NICK": 387 if (params.length != 1) 388 return; 389 390 onNick(nick, params[0]); 391 break; 392 393 case "INVITE": 394 if (params.length != 2) 395 return; 396 397 if (handleInvite) 398 handleInvite(params[0], params[1]); 399 break; 400 401 default: 402 break; 403 } 404 } 405 406 void onUserParted(string nick, string channel) 407 { 408 assert(channel in channels); 409 if (nick == currentNickname) 410 { 411 foreach (user, b; channels[channel].users) 412 users[user].channelsJoined--; 413 purgeUsers(); 414 channels.remove(channel); 415 } 416 else 417 { 418 channels[channel].users.remove(nick); 419 users[nick].channelsJoined--; 420 if (users[nick].channelsJoined==0) 421 onLeave(nick); 422 } 423 } 424 425 /// Remove users that aren't in any channels 426 void purgeUsers() 427 { 428 throw new Exception("not implemented"); 429 } 430 431 void parseTarget(string target, out string nickname, out string username, out string hostname) 432 { 433 username = hostname = null; 434 auto userdelimpos = target.indexOf('!'); 435 if (userdelimpos == -1) 436 nickname = target; 437 else 438 { 439 nickname = target[0 .. userdelimpos]; 440 441 auto hostdelimpos = target.indexOf('@'); 442 if (hostdelimpos == -1) 443 assert(0); 444 else 445 { 446 //bool identified = target[userdelimpos + 1] != '~'; 447 //if (!identified) 448 // userdelimpos++; 449 450 username = target[userdelimpos + 1 .. hostdelimpos]; 451 hostname = target[hostdelimpos + 1 .. target.length]; 452 453 //if (hostname == "no.address.for.you") // WormNET hack 454 // hostname = null; 455 } 456 } 457 } 458 459 void onSocketInactivity() 460 { 461 command("PING", to!string(Clock.currTime().toUnixTime())); 462 } 463 464 void onSocketTimeout() 465 { 466 disconnect("Time-out", DisconnectType.error); 467 } 468 469 protected: // overridable methods 470 void onEnter(string nick, string username, string hostname, string realname = null) 471 { 472 users[nick] = User(1, username, hostname, realname); 473 canonicalUserNames[rfc1459toLower(nick)] = nick; 474 if (handleEnter) 475 handleEnter(nick); 476 } 477 478 void onLeave(string nick) 479 { 480 users.remove(nick); 481 canonicalUserNames.remove(rfc1459toLower(nick)); 482 if (handleLeave) 483 handleLeave(nick); 484 } 485 486 void onNick(string oldNick, string newNick) 487 { 488 users[newNick] = users[oldNick]; 489 users.remove(oldNick); 490 canonicalUserNames.remove(rfc1459toLower(oldNick)); 491 canonicalUserNames[rfc1459toLower(newNick)] = newNick; 492 493 foreach (ref channel; channels) 494 if (oldNick in channel.users) 495 { 496 channel.users[newNick] = channel.users[oldNick]; 497 channel.users.remove(oldNick); 498 } 499 } 500 501 void onMessage(string from, string to, string message, IrcMessageType type) 502 { 503 if (handleMessage) 504 handleMessage(from, to, message, type); 505 } 506 507 public: 508 /// The nickname to identify with. 509 string connectNickname; 510 /// Fail to connect if the specified nickname is taken. 511 bool exactNickname; 512 /// The nickname we are logged in with. 513 /// May be different from connectNickname if 514 /// exactNickname is false. 515 string currentNickname; 516 /// Refers to currentNickname when connected, connectNickname otherwise. 517 @property ref string nickname() { return connected ? currentNickname : connectNickname; } 518 519 /// The user's information. 520 string realname; 521 /// The password used when logging in. 522 string password; 523 /// Username field (shown before the @ in the hostmask). 524 /// If not set, defaults to the nickname. 525 string username; 526 /// A list of joined channels. 527 Channel[string] channels; 528 /// Canonical names 529 string[string] canonicalChannelNames, canonicalUserNames; 530 /// Known user info 531 User[string] users; 532 /// Channel list for LIST command 533 string[] channelList; 534 535 /// Whether to automatically send WHO requests 536 bool autoWho; 537 /// Log all input/output to this logger. 538 Logger log; 539 /// How to convert the IRC 8-bit data to and from UTF-8 (D strings must be valid UTF-8). 540 string function(in char[]) decoder = &rawToUTF8, encoder = &UTF8ToRaw; 541 542 /// An IRC channel. 543 struct Channel 544 { 545 /// Visible users in this channel. 546 bool[string] users; 547 } 548 549 /// A user that we know of. 550 struct User 551 { 552 /// Number of channels that we can see the user in. 553 int channelsJoined; // acts as a reference count 554 /// User information. 555 string username, hostname, realname; 556 } 557 558 /// Get a channel's canonical name, 559 /// using the observed name if known. 560 string canonicalChannelName(string channel) 561 { 562 string channelLower = rfc1459toLower(channel); 563 if (channelLower in canonicalChannelNames) 564 return canonicalChannelNames[channelLower]; 565 else 566 { 567 canonicalChannelNames[channelLower] = channel; // for consistency! 568 return channel; 569 } 570 } 571 572 /// Get a user's canonical name, 573 /// using the observed name if known. 574 string canonicalUserName(string user) 575 { 576 string userLower = rfc1459toLower(user); 577 if (userLower in canonicalUserNames) 578 return canonicalUserNames[userLower]; 579 else 580 return user; 581 } 582 583 /// Get a user or channel's canonical name, 584 /// using the observed name if known. 585 string canonicalName(string name) 586 { 587 string nameLower = rfc1459toLower(name); 588 if (name[0]=='#') 589 if (nameLower in canonicalChannelNames) 590 return canonicalChannelNames[nameLower]; 591 else 592 return name; 593 else 594 if (nameLower in canonicalUserNames) 595 return canonicalUserNames[nameLower]; 596 else 597 return name; 598 } 599 600 /// Get the list of channels that we can see a user in. 601 string[] getUserChannels(string name) 602 { 603 string[] result; 604 foreach (channelName, ref channel; channels) 605 if (name in channel.users) 606 result ~= channelName; 607 return result; 608 } 609 610 this(IConnection c) 611 { 612 conn = new IrcConnection(c); 613 conn.handleConnect = &onConnect; 614 conn.handleDisconnect = &onDisconnect; 615 conn.handleReadLine = &onReadLine; 616 conn.handleInactivity = &onSocketInactivity; 617 conn.handleTimeout = &onSocketTimeout; 618 } /// 619 620 /// Returns true if the connection was successfully established, 621 /// and we have authorized ourselves to the server 622 /// (and can thus join channels, send private messages, etc.) 623 @property bool connected() { return _connected; } 624 625 /// Cancel a connection. 626 void disconnect(string reason = null, DisconnectType type = DisconnectType.requested) 627 { 628 if (conn.state == ConnectionState.connected && reason) 629 command("QUIT", reason); 630 conn.disconnect(reason, type); 631 } 632 633 /// Send raw string to server. 634 void sendRaw(in char[] message) 635 { 636 enforce(!message.contains("\n"), "Newline in outgoing IRC line: " ~ message); 637 if (log) log("> " ~ message); 638 conn.send(encoder(message)); 639 } 640 641 /// Join a channel on the network. 642 void join(string channel, string password=null) 643 { 644 assert(connected); 645 646 canonicalChannelNames[rfc1459toLower(channel)] = channel; 647 if (password.length) 648 command("JOIN", channel, password); 649 else 650 command("JOIN", channel); 651 } 652 653 /// Get a list of channels on the server 654 void requestChannelList() 655 { 656 assert(connected); 657 658 channelList = null; // clear channel list 659 command("LIST"); 660 } 661 662 /// Get a list of logged on users 663 void who(string mask=null) 664 { 665 command("WHO", mask); 666 } 667 668 /// Send a regular message to the target. 669 void message(string name, string text) 670 { 671 command("PRIVMSG", name, text); 672 } 673 674 /// Perform an action for the target. 675 void action(string name, string text) 676 { 677 command("PRIVMSG", name, "\x01" ~ "ACTION " ~ text ~ "\x01"); 678 } 679 680 /// Send a notice to the target. 681 void notice(string name, string text) 682 { 683 command("NOTICE", name, text); 684 } 685 686 /// Get/set IRC mode 687 void mode(string[] params ...) 688 { 689 command("MODE", params); 690 } 691 692 /// Callback for received data before it's processed. 693 void delegate(ref string s) handleRaw; 694 695 /// Callback for when we have succesfully logged in. 696 void delegate() handleConnect; 697 /// Callback for when the socket was closed. 698 void delegate(string reason, DisconnectType type) handleDisconnect; 699 /// Callback for when a message has been received. 700 void delegate(string from, string to, string message, IrcMessageType type) handleMessage; 701 /// Callback for when someone has joined a channel. 702 void delegate(string channel, string nick) handleJoin; 703 /// Callback for when someone has left a channel. 704 void delegate(string channel, string nick, string reason) handlePart; 705 /// Callback for when someone was kicked from a channel. 706 void delegate(string channel, string nick, string op, string reason) handleKick; 707 /// Callback for when someone has quit from the network. 708 void delegate(string nick, string reason, string[] channels) handleQuit; 709 /// Callback for an INVITE command. 710 void delegate(string nick, string channel) handleInvite; 711 /// Callback for when the channel list was retreived 712 void delegate(string[] channelList) handleChannelList; 713 /// Callback for a WHO result line 714 void delegate(string channel, string username, string host, string server, string name, string flags, int hopcount, string realname) handleWho; 715 /// Callback for a WHO listing end 716 void delegate(string mask) handleWhoEnd; 717 718 /// Callback for when we're banned from a channel. 719 void delegate(string channel, string reason) handleBanned; 720 /// Callback for when a channel is invite only. 721 void delegate(string channel, string reason) handleInviteOnly; 722 /// Callback for when a nick/channel is unavailable. 723 void delegate(string what, string reason) handleUnavailable; 724 /// Callback for when a channel is full. 725 void delegate(string channel, string reason) handleChannelFull; 726 /// Callback for when a channel needs a key. 727 void delegate(string channel, string reason) handleChannelKey; 728 729 /// Callback for when a user enters our sight. 730 void delegate(string nick) handleEnter; 731 /// Callback for when a user leaves our sight. 732 void delegate(string nick) handleLeave; 733 }