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 // SA 2007.08.12: Can't send "PASS ELSILRACLIHP " 54 // If we NEED to then the ircd is broken 55 // - make "PASS" special if and when this happens. 56 foreach(i,parameter; params) 57 { 58 message ~= " "; 59 if(parameter.indexOf(" ")!=-1) 60 { 61 assert(i == params.length-1); 62 message ~= ":"; 63 } 64 message ~= parameter; 65 } 66 67 sendRaw(message); 68 } 69 70 /// Called when a connection has been established. 71 void onConnect() 72 { 73 if (log) log("* Connected."); 74 if (password.length > 0) 75 command("PASS", password); 76 currentNickname = connectNickname; 77 command("NICK", currentNickname); 78 command("USER", 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 /// 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 struct Channel 543 { 544 bool[string] users; 545 } 546 547 struct User 548 { 549 int channelsJoined; // acts as a reference count 550 string username, hostname; 551 string realname; 552 } 553 554 string canonicalChannelName(string channel) 555 { 556 string channelLower = rfc1459toLower(channel); 557 if (channelLower in canonicalChannelNames) 558 return canonicalChannelNames[channelLower]; 559 else 560 { 561 canonicalChannelNames[channelLower] = channel; // for consistency! 562 return channel; 563 } 564 } 565 566 string canonicalUserName(string user) 567 { 568 string userLower = rfc1459toLower(user); 569 if (userLower in canonicalUserNames) 570 return canonicalUserNames[userLower]; 571 else 572 return user; 573 } 574 575 string canonicalName(string name) 576 { 577 string nameLower = rfc1459toLower(name); 578 if (name[0]=='#') 579 if (nameLower in canonicalChannelNames) 580 return canonicalChannelNames[nameLower]; 581 else 582 return name; 583 else 584 if (nameLower in canonicalUserNames) 585 return canonicalUserNames[nameLower]; 586 else 587 return name; 588 } 589 590 string[] getUserChannels(string name) 591 { 592 string[] result; 593 foreach (channelName, ref channel; channels) 594 if (name in channel.users) 595 result ~= channelName; 596 return result; 597 } 598 599 this(IConnection c) 600 { 601 conn = new IrcConnection(c); 602 conn.handleConnect = &onConnect; 603 conn.handleDisconnect = &onDisconnect; 604 conn.handleReadLine = &onReadLine; 605 conn.handleInactivity = &onSocketInactivity; 606 conn.handleTimeout = &onSocketTimeout; 607 } 608 609 /// Returns true if the connection was successfully established, 610 /// and we have authorized ourselves to the server 611 /// (and can thus join channels, send private messages, etc.) 612 @property bool connected() { return _connected; } 613 614 /// Cancel a connection. 615 void disconnect(string reason = null, DisconnectType type = DisconnectType.requested) 616 { 617 assert(conn.state == ConnectionState.connected); 618 619 if (reason) 620 command("QUIT", reason); 621 conn.disconnect(reason, type); 622 } 623 624 /// Send raw string to server. 625 void sendRaw(in char[] message) 626 { 627 debug (IRC) std.stdio.writefln("> %s", message); 628 enforce(!message.contains("\n"), "Newline in outgoing IRC line: " ~ message); 629 if (log) log("> " ~ message); 630 conn.send(encoder(message)); 631 } 632 633 /// Join a channel on the network. 634 void join(string channel, string password=null) 635 { 636 assert(connected); 637 638 canonicalChannelNames[rfc1459toLower(channel)] = channel; 639 if (password.length) 640 command("JOIN", channel, password); 641 else 642 command("JOIN", channel); 643 } 644 645 /// Get a list of channels on the server 646 void requestChannelList() 647 { 648 assert(connected); 649 650 channelList = null; // clear channel list 651 command("LIST"); 652 } 653 654 /// Get a list of logged on users 655 void who(string mask=null) 656 { 657 command("WHO", mask); 658 } 659 660 /// Send a regular message to the target. 661 void message(string name, string text) 662 { 663 command("PRIVMSG", name, text); 664 } 665 666 /// Perform an action for the target. 667 void action(string name, string text) 668 { 669 command("PRIVMSG", name, "\x01" ~ "ACTION " ~ text ~ "\x01"); 670 } 671 672 /// Send a notice to the target. 673 void notice(string name, string text) 674 { 675 command("NOTICE", name, text); 676 } 677 678 /// Get/set IRC mode 679 void mode(string[] params ...) 680 { 681 command("MODE", params); 682 } 683 684 /// Callback for received data before it's processed. 685 void delegate(ref string s) handleRaw; 686 687 /// Callback for when we have succesfully logged in. 688 void delegate() handleConnect; 689 /// Callback for when the socket was closed. 690 void delegate(string reason, DisconnectType type) handleDisconnect; 691 /// Callback for when a message has been received. 692 void delegate(string from, string to, string message, IrcMessageType type) handleMessage; 693 /// Callback for when someone has joined a channel. 694 void delegate(string channel, string nick) handleJoin; 695 /// Callback for when someone has left a channel. 696 void delegate(string channel, string nick, string reason) handlePart; 697 /// Callback for when someone was kicked from a channel. 698 void delegate(string channel, string nick, string op, string reason) handleKick; 699 /// Callback for when someone has quit from the network. 700 void delegate(string nick, string reason, string[] channels) handleQuit; 701 /// Callback for an INVITE command. 702 void delegate(string nick, string channel) handleInvite; 703 /// Callback for when the channel list was retreived 704 void delegate(string[] channelList) handleChannelList; 705 /// Callback for a WHO result line 706 void delegate(string channel, string username, string host, string server, string name, string flags, int hopcount, string realname) handleWho; 707 /// Callback for a WHO listing end 708 void delegate(string mask) handleWhoEnd; 709 710 /// Callback for when we're banned from a channel. 711 void delegate(string channel, string reason) handleBanned; 712 /// Callback for when a channel is invite only. 713 void delegate(string channel, string reason) handleInviteOnly; 714 /// Callback for when a nick/channel is unavailable. 715 void delegate(string what, string reason) handleUnavailable; 716 /// Callback for when a channel is full. 717 void delegate(string channel, string reason) handleChannelFull; 718 /// Callback for when a channel needs a key. 719 void delegate(string channel, string reason) handleChannelKey; 720 721 /// Callback for when a user enters our sight. 722 void delegate(string nick) handleEnter; 723 /// Callback for when a user leaves our sight. 724 void delegate(string nick) handleLeave; 725 }