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