1 module ircclient.net.client; 2 3 public import dlangui.core.asyncsocket; 4 import dlangui.core.logger; 5 import dlangui.core.collections; 6 import std.string : empty, format, startsWith; 7 import std.conv : to; 8 import std.utf : toUTF32; 9 10 interface IRCClientCallback { 11 void onIRCConnect(IRCClient client); 12 void onIRCDisconnect(IRCClient client); 13 void onIRCMessage(IRCClient client, IRCMessage message); 14 void onIRCPing(IRCClient client, string message); 15 void onIRCPrivmsg(IRCClient client, IRCAddress source, string target, string message); 16 void onIRCNotice(IRCClient client, IRCAddress source, string target, string message); 17 } 18 19 enum IRCCommand : int { 20 UNKNOWN, 21 CHANNEL_TOPIC = 332, 22 CHANNEL_TOPIC_SET_BY = 333, 23 CHANNEL_NAMES_LIST = 353, 24 CHANNEL_NAMES_LIST_END = 366, 25 USER = 1000, 26 PRIVMSG, // :source PRIVMSG <target> :Message 27 NOTICE, // :source NOTICE <target> :Message 28 NICK, 29 PING, // PING :message 30 PONG, // PONG :message 31 QUIT, // :source QUIT :reason 32 JOIN, // :source JOIN :#channel 33 PART, // :source PART #channel :reason 34 MODE, // 35 } 36 37 IRCCommand findCommandId(string s) { 38 if (s.empty) 39 return IRCCommand.UNKNOWN; 40 if (s[0] >= '0' && s[0] <= '9') { 41 // parse numeric command ID 42 int n = 0; 43 foreach(ch; s) 44 if (ch >= '0' && ch <= '9') 45 n = n * 10 + (ch - '0'); 46 return cast(IRCCommand)n; 47 } 48 49 switch (s) with(IRCCommand) { 50 case "USER": return USER; 51 case "PRIVMSG": return PRIVMSG; 52 case "NICK": return NICK; 53 case "QUIT": return QUIT; 54 case "PING": return PING; 55 case "PONG": return PONG; 56 case "JOIN": return JOIN; 57 case "PART": return PART; 58 case "NOTICE": return NOTICE; 59 default: 60 return UNKNOWN; 61 } 62 } 63 64 /// IRC message 65 class IRCMessage { 66 /// full message text 67 string msg; 68 /// optional first parameter of message, starting with : -- e.g. ":holmes.freenode.net" 69 string source; // source of message 70 IRCAddress sourceAddress; // parsed source 71 string command; // command text 72 IRCCommand commandId = IRCCommand.UNKNOWN; // command id 73 string[] params; // all parameters after command 74 string message; // optional message parameter, w/o starting : 75 string target; // for some command types - message target 76 /// parse message text 77 bool parse(string s) { 78 msg = s; 79 if (s.empty) 80 return false; 81 if (s[0] == ':') { 82 // parse source 83 source = parseDelimitedParameter(s); 84 if (source.length < 2) 85 return false; 86 source = source[1 .. $]; 87 sourceAddress = new IRCAddress(source); 88 } 89 command = parseDelimitedParameter(s); 90 if (command.empty) 91 return false; 92 commandId = findCommandId(command); 93 while (!s.empty) { 94 if (s[0] == ':') { 95 params ~= s; 96 message = s[1 .. $]; 97 break; 98 } else { 99 params ~= parseDelimitedParameter(s); 100 } 101 } 102 switch(commandId) with (IRCCommand) { 103 case PRIVMSG: 104 case NOTICE: 105 case JOIN: 106 case PART: 107 if (params.length > 0) 108 target = params[0]; 109 break; 110 case CHANNEL_TOPIC: 111 case CHANNEL_TOPIC_SET_BY: 112 case CHANNEL_NAMES_LIST_END: 113 if (params.length > 1) 114 target = params[1]; 115 break; 116 case CHANNEL_NAMES_LIST: 117 if (params.length > 2 && (params[1] == "=" || params[1] == "@")) 118 target = params[2]; 119 break; 120 default: 121 break; 122 } 123 return true; 124 } 125 } 126 127 class IRCAddress { 128 string full; 129 string host; 130 string channel; 131 string nick; 132 string username; 133 this() { 134 } 135 this(string s) { 136 full = s; 137 string s1 = parseDelimitedParameter(s, '!'); 138 if (!s.empty) { 139 // VadimLopatin!~Buggins@149.62.27.44 140 nick = s1; 141 username = s; 142 } else { 143 host = s1; 144 } 145 } 146 @property string longName() { 147 if (!nick.empty) { 148 return nick ~ " (" ~ username ~ ")"; 149 } else { 150 return full; 151 } 152 } 153 } 154 155 class IRCUser { 156 string nick; 157 int flags; 158 this(string s) { 159 nick = s; 160 } 161 } 162 163 class UserList { 164 Collection!IRCUser _users; 165 @property size_t length() { return _users.length; } 166 @property IRCUser opIndex(size_t index) { return _users[index]; } 167 void fromList(string userList) { 168 _users.clear(); 169 while(true) { 170 string s = parseDelimitedParameter(userList); 171 if (s.empty) 172 break; 173 IRCUser u = new IRCUser(s); 174 _users.add(u); 175 } 176 } 177 import std.typecons : Tuple; 178 Tuple!(bool, "found", size_t, "index") findUser(string name) { 179 foreach(i; 0.._users.length) { 180 if (_users[i].nick == name) 181 return typeof(return)(true, i); 182 } 183 return typeof(return)(false, 0); 184 } 185 void addUser(string name) { 186 if (findUser(name).found == false) 187 _users.add(new IRCUser(name)); 188 } 189 void removeUser(string name) { 190 auto user = findUser(name); 191 if (user.found) { 192 _users.remove(user.index); 193 } 194 } 195 } 196 197 class IRCChannel { 198 string name; 199 string topic; 200 string topicSetBy; 201 long topicSetWhen; 202 UserList users; 203 this(string name) { 204 this.name = name; 205 users = new UserList(); 206 } 207 char[] userListBuffer; 208 void setUserList(string userList) { 209 users.fromList(userList); 210 } 211 @property dstring[] userNames() { 212 dstring[] res; 213 for(int i = 0; i < users.length; i++) { 214 res ~= toUTF32(users[i].nick); 215 } 216 return res; 217 } 218 void handleMessage(IRCMessage msg) { 219 switch (msg.commandId) with (IRCCommand) { 220 case CHANNEL_TOPIC: 221 topic = msg.message; 222 break; 223 case CHANNEL_TOPIC_SET_BY: 224 topicSetBy = msg.params.length == 3 ? msg.params[1] : null; 225 break; 226 case CHANNEL_NAMES_LIST: 227 if (userListBuffer.length > 0) 228 userListBuffer ~= " "; 229 userListBuffer ~= msg.message; 230 break; 231 case CHANNEL_NAMES_LIST_END: 232 setUserList(userListBuffer.dup); 233 Log.d("user list for " ~ name ~ " : " ~ userListBuffer); 234 userListBuffer = null; 235 break; 236 case JOIN: 237 users.addUser(msg.sourceAddress.nick); 238 break; 239 case PART: 240 users.removeUser(msg.sourceAddress.nick); 241 break; 242 default: 243 break; 244 } 245 } 246 } 247 248 /// IRC Client connection implementation 249 class IRCClient : AsyncSocketCallback { 250 protected: 251 AsyncSocket _socket; 252 IRCClientCallback _callback; 253 char[] _readbuf; 254 string _host; 255 ushort _port; 256 string _nick; 257 IRCChannel[string] _channels; 258 void onDataReceived(AsyncSocket socket, ubyte[] data) { 259 _readbuf ~= cast(char[])data; 260 // split by lines 261 int start = 0; 262 for (int i = 0; i + 1 < _readbuf.length; i++) { 263 if (_readbuf[i] == '\r' && _readbuf[i + 1] == '\n') { 264 if (i > start) 265 onMessageText(_readbuf[start .. i].dup); 266 start = i + 2; 267 } 268 } 269 if (start < _readbuf.length) { 270 // has unfinished text 271 _readbuf = _readbuf[start .. $].dup; 272 } else { 273 // end of buffer 274 _readbuf.length = 0; 275 } 276 } 277 void onMessageText(string msgText) { 278 IRCMessage msg = new IRCMessage(); 279 if (msg.parse(msgText)) { 280 onMessage(msg); 281 } else { 282 Log.e("cannot parse IRC message " ~ msgText); 283 } 284 } 285 void onMessage(IRCMessage msg) { 286 Log.d("MSG: " ~ msg.msg); 287 switch (msg.commandId) with (IRCCommand) { 288 case PING: 289 _callback.onIRCPing(this, msg.message); 290 break; 291 case PRIVMSG: 292 _callback.onIRCPrivmsg(this, msg.sourceAddress, msg.target, msg.message); 293 break; 294 case NOTICE: 295 _callback.onIRCNotice(this, msg.sourceAddress, msg.target, msg.message); 296 break; 297 case CHANNEL_TOPIC: 298 case CHANNEL_TOPIC_SET_BY: 299 case CHANNEL_NAMES_LIST: 300 case CHANNEL_NAMES_LIST_END: 301 case JOIN: 302 case PART: 303 if (msg.target.startsWith("#")) { 304 auto channel = channelByName(msg.target, true); 305 channel.handleMessage(msg); 306 _callback.onIRCMessage(this, msg); 307 } 308 break; 309 default: 310 _callback.onIRCMessage(this, msg); 311 break; 312 } 313 } 314 void onConnect(AsyncSocket socket) { 315 Log.e("onConnect"); 316 _readbuf.length = 0; 317 _callback.onIRCConnect(this); 318 } 319 void onDisconnect(AsyncSocket socket) { 320 Log.e("onDisconnect"); 321 _readbuf.length = 0; 322 _callback.onIRCDisconnect(this); 323 } 324 void onError(AsyncSocket socket, SocketError error, string msg) { 325 } 326 public: 327 this() { 328 } 329 ~this() { 330 if (_socket) 331 destroy(_socket); 332 } 333 @property SocketState state() { 334 return _socket.state; 335 } 336 IRCChannel removeChannel(string name) { 337 if (auto p = name in _channels) { 338 _channels.remove(name); 339 return *p; 340 } 341 return null; 342 } 343 IRCChannel channelByName(string name, bool createIfNotExist = false) { 344 if (auto p = name in _channels) { 345 return *p; 346 } 347 if (!createIfNotExist) 348 return null; 349 IRCChannel res = new IRCChannel(name); 350 _channels[name] = res; 351 return res; 352 } 353 @property string host() { return _host; } 354 @property ushort port() { return _port; } 355 @property string hostPort() { return "%s:%d".format(_host, _port); } 356 /// set socket to use 357 @property void socket(AsyncSocket sock) { 358 _socket = sock; 359 } 360 @property void callback(IRCClientCallback callback) { 361 _callback = callback; 362 } 363 void connect(string host, ushort port) { 364 _host = host; 365 _port = port; 366 _socket.connect(host, port); 367 } 368 void sendMessage(string msg) { 369 Log.d("CMD: " ~ msg); 370 _socket.send(cast(ubyte[])(msg ~ "\r\n")); 371 } 372 void pong(string msg) { 373 sendMessage("PONG :" ~ msg); 374 } 375 void privMsg(string destination, string message) { 376 sendMessage("PRIVMSG " ~ destination ~ " :" ~ message); 377 _callback.onIRCPrivmsg(this, new IRCAddress(_nick ~ "!~username@host"), destination, message); 378 } 379 void disconnect() { 380 _socket.disconnect(); 381 } 382 @property string nick() { 383 return _nick; 384 } 385 @property void nick(string nickName) { 386 if (_nick.empty) 387 _nick = nickName; 388 sendMessage("NICK " ~ nickName); 389 } 390 void join(string channel) { 391 sendMessage("JOIN " ~ channel); 392 } 393 void part(string channel, string message) { 394 sendMessage("PART " ~ channel ~ (message.empty ? "" : ":" ~ message)); 395 } 396 397 void quit(string message) { 398 sendMessage("QUIT " ~ (message.empty ? "" : ":" ~ message)); 399 } 400 } 401 402 /// utility function to get first space delimited parameter from string 403 string parseDelimitedParameter(ref string s, char delimiter = ' ') { 404 string res; 405 int i = 0; 406 // parse source 407 for (; i < s.length; i++) { 408 if (s[i] == delimiter) 409 break; 410 } 411 if (i > 0) { 412 res = s[0 .. i]; 413 } 414 i++; 415 s = i < s.length ? s[i .. $] : null; 416 return res; 417 } 418