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