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