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