@sockethub/platform-irc 4.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,701 @@
1
+ /**
2
+ * This is a platform for Sockethub implementing IRC functionality.
3
+ *
4
+ * Developed by Nick Jennings (https://github.com/silverbucket)
5
+ *
6
+ * Sockethub is licensed under the LGPLv3.
7
+ * See the LICENSE file for details.
8
+ *
9
+ * The latest version of this module can be found here:
10
+ * git://github.com/sockethub/sockethub.git
11
+ *
12
+ * For more information about Sockethub visit http://sockethub.org/.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17
+ */
18
+
19
+ if (typeof (NetSocket) !== 'object') {
20
+ net = require('net');
21
+ }
22
+ if (typeof (TlsSocket) !== 'object') {
23
+ tls = require('tls');
24
+ }
25
+ if (typeof (IrcSocket) !== 'object') {
26
+ IrcSocket = require('irc-socket-sasl');
27
+ }
28
+ if (typeof (IRC2AS) !== 'object') {
29
+ IRC2AS = require('@sockethub/irc2as');
30
+ }
31
+
32
+
33
+ /**
34
+ * @class IRC
35
+ * @constructor
36
+ *
37
+ * @description
38
+ * Handles all actions related to communication via. the IRC protocol.
39
+ *
40
+ * Uses the `irc-factory` node module as a base tool for interacting with IRC.
41
+ *
42
+ * {@link https://github.com/ircanywhere/irc-factory}
43
+ *
44
+ * @param {object} cfg a unique config object for this instance // TODO LINK
45
+ */
46
+ function IRC(cfg) {
47
+ cfg = (typeof cfg === 'object') ? cfg : {};
48
+ this.debug = cfg.debug;
49
+ this.sendToClient = cfg.sendToClient;
50
+ this.updateActor = cfg.updateActor;
51
+ this.__forceDisconnect = false;
52
+ this.__clientConnecting = false;
53
+ this.__client = undefined;
54
+ this.__jobQueue = []; // list of handlers to confirm when message delivery confirmed
55
+ this.__channels = new Set();
56
+ this.__handledActors = new Set();
57
+ }
58
+
59
+ /**
60
+ * Property: schema
61
+ *
62
+ * @description
63
+ * JSON schema defining the types this platform accepts.
64
+ *
65
+ *
66
+ * In the below example, Sockethub will validate the incoming credentials object
67
+ * against whatever is defined in the `credentials` portion of the schema
68
+ * object.
69
+ *
70
+ *
71
+ * It will also check if the incoming AS object uses a type which exists in the
72
+ * `types` portion of the schema object (should be an array of type names).
73
+ *
74
+ * * **NOTE**: For more information on using the credentials object from a client,
75
+ * see [Sockethub Client](https://github.com/sockethub/sockethub/wiki/Sockethub-Client)
76
+ *
77
+ * Valid AS object for setting IRC credentials:
78
+ * @example
79
+ *
80
+ * {
81
+ * type: 'credentials',
82
+ * context: 'irc',
83
+ * actor: {
84
+ * id: 'testuser@irc.host.net',
85
+ * type: 'person',
86
+ * name: 'Mr. Test User',
87
+ * userName: 'testuser'
88
+ * },
89
+ * object: {
90
+ * type: 'credentials',
91
+ * server: 'irc.host.net',
92
+ * nick: 'testuser',
93
+ * password: 'asdasdasdasd',
94
+ * port: 6697,
95
+ * secure: true,
96
+ * sasl: true
97
+ * }
98
+ * }
99
+ */
100
+ IRC.prototype.schema = {
101
+ "name": "irc",
102
+ "version": require('./package.json').version,
103
+ "messages" : {
104
+ "required": [ 'type' ],
105
+ "properties": {
106
+ "type": {
107
+ "enum": [ 'connect', 'update', 'join', 'leave', 'send', 'query', 'announce' ]
108
+ }
109
+ }
110
+ },
111
+ "credentials" : {
112
+ "required": [ 'object' ],
113
+ "properties": {
114
+ // TODO platforms shouldn't have to define the actor property
115
+ // if they don't want to, just credential specifics
116
+ "actor": {
117
+ "type": "object",
118
+ "required": [ "id" ]
119
+ },
120
+ "object": {
121
+ "type": "object",
122
+ "required": [ 'type', 'nick', 'server' ],
123
+ "additionalProperties": false,
124
+ "properties" : {
125
+ "type": {
126
+ "type": "string"
127
+ },
128
+ "nick" : {
129
+ "type": "string"
130
+ },
131
+ "username" : {
132
+ "type": "string"
133
+ },
134
+ "password" : {
135
+ "type": "string"
136
+ },
137
+ "server" : {
138
+ "type": "string"
139
+ },
140
+ "port" : {
141
+ "type": "number"
142
+ },
143
+ "secure": {
144
+ "type": "boolean"
145
+ },
146
+ "sasl": {
147
+ "type": "boolean"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ };
154
+
155
+
156
+ IRC.prototype.config = {
157
+ persist: true,
158
+ requireCredentials: [ 'connect', 'update' ],
159
+ initialized: false
160
+ };
161
+
162
+ /**
163
+ * Function: connect
164
+ *
165
+ * Conenct to an IRC server.
166
+ *
167
+ * @param {object} job activity streams object
168
+ * @param {object} credentials credentials object
169
+ * @param {object} done callback when job is done
170
+ */
171
+
172
+ IRC.prototype.connect = function (job, credentials, done) {
173
+ this.__getClient(job.actor.id, credentials, (err, client) => {
174
+ if (err) { return done(err); }
175
+ return done();
176
+ });
177
+ };
178
+
179
+ /**
180
+ * Function: join
181
+ *
182
+ * Join a room or private conversation.
183
+ *
184
+ * @param {object} job activity streams object // TODO LINK
185
+ * @param {object} done callback when job is done // TODO LINK
186
+ *
187
+ * @example
188
+ *
189
+ * {
190
+ * context: 'irc',
191
+ * type: 'join',
192
+ * actor: {
193
+ * id: 'slvrbckt@irc.freenode.net',
194
+ * type: 'person',
195
+ * name: 'slvrbckt'
196
+ * },
197
+ * target: {
198
+ * id: 'irc.freenode.net/a-room',
199
+ * type: 'room',
200
+ * name: '#a-room'
201
+ * },
202
+ * object: {}
203
+ * }
204
+ *
205
+ */
206
+ IRC.prototype.join = function (job, done) {
207
+ this.debug('join() called for ' + job.actor.id);
208
+ this.__getClient(job.actor.id, false, (err, client) => {
209
+ if (err) { return done(err); }
210
+ if (this.__channels.has(job.target.name)) {
211
+ this.debug(`channel ${job.target.name} already joined`);
212
+ return done();
213
+ }
214
+ // join channel
215
+ this.__jobQueue.push(() => {
216
+ this.__hasJoined(job.target.name);
217
+ done();
218
+ });
219
+ this.debug('sending join ' + job.target.name);
220
+ client.raw(['JOIN', job.target.name]);
221
+ client.raw('PING ' + job.actor.name);
222
+ });
223
+ };
224
+
225
+ /**
226
+ * Function leave
227
+ *
228
+ * Leave a room or private conversation.
229
+ *
230
+ * @param {object} job activity streams object // TODO LINK
231
+ * @param {object} done callback when job is done // TODO LINK
232
+ *
233
+ * @example
234
+ * {
235
+ * context: 'irc',
236
+ * type: 'leave',
237
+ * actor: {
238
+ * id: 'slvrbckt@irc.freenode.net',
239
+ * type: 'person',
240
+ * name: 'slvrbckt'
241
+ * },
242
+ * target: {
243
+ * id: 'irc.freenode.net/remotestorage',
244
+ * type: 'room',
245
+ * name: '#remotestorage'
246
+ * },
247
+ * object: {}
248
+ * }
249
+ *
250
+ */
251
+ IRC.prototype.leave = function (job, done) {
252
+ this.debug('leave() called for ' + job.actor.name);
253
+ this.__getClient(job.actor.id, false, (err, client) => {
254
+ if (err) { return done(err); }
255
+ // leave channel
256
+ this.__hasLeft(job.target.name);
257
+ client.raw(['PART', job.target.name]);
258
+ done();
259
+ });
260
+ };
261
+
262
+ /**
263
+ * Function: send
264
+ *
265
+ * Send a message to a room or private conversation.
266
+ *
267
+ * @param {object} job activity streams object // TODO LINK
268
+ * @param {object} done callback when job is done // TODO LINK
269
+ *
270
+ * @example
271
+ *
272
+ * {
273
+ * context: 'irc',
274
+ * type: 'send',
275
+ * actor: {
276
+ * id: 'slvrbckt@irc.freenode.net',
277
+ * type: 'person',
278
+ * name: 'Nick Jennings',
279
+ * userName: 'slvrbckt'
280
+ * },
281
+ * target: {
282
+ * id: 'irc.freenode.net/remotestorage',
283
+ * type: 'room',
284
+ * name: '#remotestorage'
285
+ * },
286
+ * object: {
287
+ * type: 'message',
288
+ * content: 'Hello from Sockethub!'
289
+ * }
290
+ * }
291
+ *
292
+ */
293
+ IRC.prototype.send = function (job, done) {
294
+ this.debug('send() called for ' + job.actor.id + ' target: ' + job.target.id);
295
+ this.__getClient(job.actor.id, false, (err, client) => {
296
+ if (err) { return done(err); }
297
+
298
+ if (typeof job.object.content !== 'string') {
299
+ return done('cannot send message with no object.content');
300
+ }
301
+
302
+ let msg = job.object.content.trim();
303
+ if (msg.indexOf('/') === 0) {
304
+ // message intented as command
305
+ msg += ' ';
306
+ const cmd = msg.substr(0, msg.indexOf(' ')).substr(1).toUpperCase(); // get command
307
+ msg = msg.substr(msg.indexOf(' ') + 1).replace(/\s\s*$/, ''); // remove command from message
308
+ if (cmd === 'ME') {
309
+ // handle /me messages uniquely
310
+ job.object.type = 'me';
311
+ job.object.content = msg;
312
+ } else if (cmd === 'NOTICE') {
313
+ // attempt to send as raw command
314
+ job.object.type = 'notice';
315
+ job.object.content = msg;
316
+ }
317
+ } else {
318
+ job.object.content = msg;
319
+ }
320
+
321
+ if (job.object.type === 'me') {
322
+ // message intended as command
323
+ // jsdoc does not like this octal escape sequence but it's needed for proper behavior in IRC
324
+ // so the following line needs to be commented out when the API doc is built.
325
+ // investigate:
326
+ // https://github.com/jsdoc2md/jsdoc-to-markdown/issues/197#issuecomment-976851915
327
+ const message = '\001ACTION ' + job.object.content + '\001';
328
+ client.raw('PRIVMSG ' + job.target.name + ' :' + message);
329
+ } else if (job.object.type === 'notice') {
330
+ // attempt to send as raw command
331
+ client.raw('NOTICE ' + job.target.name + ' :' + job.object.content);
332
+ } else if (this.__isJoined(job.target.name)) {
333
+ client.raw('PRIVMSG ' + job.target.name + ' :' + job.object.content);
334
+ } else {
335
+ return done("cannot send message to a channel of which you've not first joined.");
336
+ }
337
+ this.__jobQueue.push(done);
338
+ client.raw('PING ' + job.actor.name);
339
+ });
340
+ };
341
+
342
+ /**
343
+ * Function: update
344
+ *
345
+ * Indicate a change (ie. room topic update, or nickname change).
346
+ *
347
+ * @param {object} job activity streams object // TODO LINK
348
+ * @param {object} done callback when job is done // TODO LINK
349
+ *
350
+ * @example change topic
351
+ *
352
+ * {
353
+ * context: 'irc',
354
+ * type: 'update',
355
+ * actor: {
356
+ * id: 'slvrbckt@irc.freenode.net',
357
+ * type: 'person',
358
+ * name: 'Nick Jennings',
359
+ * userName: 'slvrbckt'
360
+ * },
361
+ * target: {
362
+ * id: 'irc.freenode.net/a-room',
363
+ * type: 'room',
364
+ * name: '#a-room'
365
+ * },
366
+ * object: {
367
+ * type: 'topic',
368
+ * content: 'New version of Socekthub released!'
369
+ * }
370
+ * }
371
+ *
372
+ * @example change nickname
373
+ * {
374
+ * context: 'irc'
375
+ * type: 'udpate',
376
+ * actor: {
377
+ * id: 'slvrbckt@irc.freenode.net',
378
+ * type: 'person',
379
+ * name: 'slvrbckt'
380
+ * },
381
+ * object: {
382
+ * type: "address",
383
+ * },
384
+ * target: {
385
+ * id: 'cooldude@irc.freenode.net',
386
+ * type: 'person',
387
+ * name: cooldude
388
+ * }
389
+ * }
390
+ */
391
+ IRC.prototype.update = function (job, credentials, done) {
392
+ this.debug('update() called for ' + job.actor.id);
393
+ this.__getClient(job.actor.id, false, (err, client) => {
394
+ if (err) { return done(err); }
395
+ if (job.object.type === 'address') {
396
+ this.debug('changing nick from ' + job.actor.name + ' to ' + job.target.name);
397
+ this.__handledActors.add(job.target.id);
398
+ this.__jobQueue.push((err) => {
399
+ if (err) {
400
+ this.__handledActors.delete(job.target.id);
401
+ return done(err);
402
+ }
403
+ credentials.object.nick = job.target.name;
404
+ credentials.actor.id = `${job.target.name}@${credentials.object.server}`;
405
+ credentials.actor.name = job.target.name;
406
+ this.updateActor(credentials);
407
+ done();
408
+ });
409
+ // send nick change command
410
+ client.raw(['NICK', job.target.name]);
411
+ } else if (job.object.type === 'topic') {
412
+ // update topic
413
+ this.debug('changing topic in channel ' + job.target.name);
414
+ this.__jobQueue.push(done);
415
+ client.raw(['topic', job.target.name, job.object.content]);
416
+ } else {
417
+ return done(`unknown update action.`);
418
+ }
419
+ client.raw('PING ' + job.actor.name);
420
+ });
421
+ };
422
+
423
+ /**
424
+ * Function: query
425
+ *
426
+ * Indicate an intent to query something (e.g. get a list of users in a room).
427
+ *
428
+ * @param {object} job activity streams object // TODO LINK
429
+ * @param {object} done callback when job is done // TODO LINK
430
+ *
431
+ * @example
432
+ *
433
+ * {
434
+ * context: 'irc',
435
+ * type: 'query',
436
+ * actor: {
437
+ * id: 'slvrbckt@irc.freenode.net',
438
+ * type: 'person',
439
+ * name: 'Nick Jennings',
440
+ * userName: 'slvrbckt'
441
+ * },
442
+ * target: {
443
+ * id: 'irc.freenode.net/a-room',
444
+ * type: 'room',
445
+ * name: '#a-room'
446
+ * },
447
+ * object: {
448
+ * type: 'attendance'
449
+ * }
450
+ * }
451
+ *
452
+ *
453
+ * // The above object might return:
454
+ * {
455
+ * context: 'irc',
456
+ * type: 'query',
457
+ * actor: {
458
+ * id: 'irc.freenode.net/a-room',
459
+ * type: 'room',
460
+ * name: '#a-room'
461
+ * },
462
+ * target: {},
463
+ * object: {
464
+ * type: 'attendance'
465
+ * members: [
466
+ * 'RyanGosling',
467
+ * 'PeeWeeHerman',
468
+ * 'Commando',
469
+ * 'Smoochie',
470
+ * 'neo'
471
+ * ]
472
+ * }
473
+ * }
474
+ *
475
+ */
476
+ IRC.prototype.query = function (job, done) {
477
+ this.debug('query() called for ' + job.actor.id);
478
+ this.__getClient(job.actor.id, false, (err, client) => {
479
+ if (err) { return done(err); }
480
+
481
+ if (job.object.type === 'attendance') {
482
+ this.debug('query() - sending NAMES for ' + job.target.name);
483
+ client.raw(['NAMES', job.target.name]);
484
+ done();
485
+ } else {
486
+ done("unknown 'type' '" + job.object.type + "'");
487
+ }
488
+ });
489
+ };
490
+
491
+
492
+ IRC.prototype.cleanup = function (done) {
493
+ this.debug('cleanup() called');
494
+ this.__forceDisconnect = true;
495
+ if (typeof this.__client === 'object') {
496
+ if (typeof this.__client.end === 'function') {
497
+ this.__client.end();
498
+ }
499
+ }
500
+ this.initialized = false;
501
+ delete this.__client;
502
+ return done();
503
+ };
504
+
505
+
506
+
507
+ //
508
+ // Private methods
509
+ //
510
+
511
+
512
+ IRC.prototype.__isJoined = function (channel) {
513
+ if (channel.indexOf('#') === 0) {
514
+ // valid channel name
515
+ return this.__channels.has(channel);
516
+ } else {
517
+ // usernames are always OK to send to
518
+ return true;
519
+ }
520
+ };
521
+
522
+
523
+ IRC.prototype.__hasJoined = function (channel) {
524
+ this.debug('joined ' + channel);
525
+ // keep track of channels joined
526
+ if (! this.__channels.has(channel)) {
527
+ this.__channels.add(channel);
528
+ }
529
+ };
530
+
531
+
532
+ IRC.prototype.__hasLeft = function (channel) {
533
+ this.debug('left ' + channel);
534
+ // keep track of channels left
535
+ if (this.__channels.has(channel)) {
536
+ this.__channels.delete(channel);
537
+ }
538
+ };
539
+
540
+
541
+ IRC.prototype.__getClient = function (key, credentials, cb) {
542
+ this.debug('getClient called, connecting: ' + this.__clientConnecting);
543
+ if (this.__client) {
544
+ this.__handledActors.add(key);
545
+ return cb(null, this.__client);
546
+ } else if (this.__clientConnecting) {
547
+ // client is in the process of connecting, wait
548
+ setTimeout(function (_cb) {
549
+ if (this.__client) {
550
+ this.debug(`resolving delayed getClient call for ${key}`);
551
+ this.__handledActors.add(key);
552
+ return _cb(null, this.__client);
553
+ } else {
554
+ return cb('failed to get irc client, please try again.');
555
+ }
556
+ }.bind(this, cb), 30000);
557
+ return;
558
+ }
559
+
560
+ if (! credentials) {
561
+ return cb('no client found, and no credentials specified. you must connect first');
562
+ } else {
563
+ this.__connect(key, credentials, (err, client) => {
564
+ if (err) {
565
+ this.initialized = false;
566
+ return cb(err);
567
+ }
568
+ this.__handledActors.add(key);
569
+ this.__client = client;
570
+ this.__registerListeners(credentials.object.server);
571
+ this.initialized = true;
572
+ return cb(null, client);
573
+ });
574
+ }
575
+ };
576
+
577
+
578
+ IRC.prototype.__connect = function (key, credentials, cb) {
579
+ this.__clientConnecting = true;
580
+ const is_secure = (typeof credentials.object.secure === 'boolean') ?
581
+ credentials.object.secure : true;
582
+
583
+ const module_options = {
584
+ username: credentials.object.username || credentials.object.nick,
585
+ nicknames: [ credentials.object.nick ],
586
+ server: credentials.object.server || 'irc.libera.chat',
587
+ realname: credentials.actor.name || credentials.object.nick,
588
+ port: (credentials.object.port) ?
589
+ parseInt(credentials.object.port, 10) : (is_secure) ? 6697 : 6667,
590
+ debug: console.log
591
+ };
592
+ if (is_secure) {
593
+ module_options.connectOptions = { rejectUnauthorized: false };
594
+ }
595
+ if (credentials.object.sasl) {
596
+ module_options.saslPassword = credentials.object.password;
597
+ module_options.capabilities = { requires: [ "sasl" ] };
598
+ }
599
+
600
+ this.debug('attempting to connect to ' + module_options.server + ':' + module_options.port +
601
+ ` transport: ${is_secure?'secure':'clear'} sasl: ${credentials.object.sasl}`);
602
+
603
+ const client = new IrcSocket(module_options, is_secure ? tls : net);
604
+
605
+ const __forceDisconnect = (err) => {
606
+ this.__forceDisconnect = true;
607
+ this.__clientConnecting = false;
608
+ if ((client) && (typeof client.end === 'function')) {
609
+ client.end();
610
+ }
611
+ if ((this.__client) && (typeof this.__client.end === 'function')) {
612
+ this.__client.end();
613
+ }
614
+ cb(err);
615
+ };
616
+
617
+ client.once('error', (err) => {
618
+ this.debug(`irc client 'error' occurred. `, err);
619
+ __forceDisconnect('error connecting to server.');
620
+ });
621
+
622
+ client.once('close', () => {
623
+ this.debug(`irc client 'close' event fired.`);
624
+ __forceDisconnect('connection to server closed.');
625
+ });
626
+
627
+ client.once('timeout', () => {
628
+ this.debug('timeout occurred, force-disconnect');
629
+ __forceDisconnect('connection timeout to server.');
630
+ });
631
+
632
+ client.connect().then((res) => {
633
+ if (res.isFail()) {
634
+ return cb(`unable to connect to server: ${res.fail()}`);
635
+ }
636
+ const capabilities = res.ok();
637
+ this.__clientConnecting = false;
638
+ if (this.__forceDisconnect) {
639
+ client.end();
640
+ return cb('force disconnect active, aborting connect.');
641
+ } else {
642
+ this.debug(`connected to ${module_options.server} capabilities: `, capabilities);
643
+ return cb(null, client);
644
+ }
645
+ });
646
+ };
647
+
648
+
649
+ IRC.prototype.__completeJob = function (err) {
650
+ this.debug(`completing job, queue count: ${this.__jobQueue.length}`);
651
+ const done = this.__jobQueue.shift();
652
+ if (typeof done === 'function') {
653
+ done(err);
654
+ } else {
655
+ this.debug('WARNING: job completion event received with an empty job queue.');
656
+ }
657
+ };
658
+
659
+
660
+ IRC.prototype.__registerListeners = function (server) {
661
+ this.irc2as = new IRC2AS({ server: server });
662
+ this.__client.on('data', (data) => {
663
+ this.irc2as.input(data);
664
+ });
665
+
666
+ this.irc2as.events.on('incoming', (asObject) => {
667
+ if ((typeof asObject.actor === 'object') &&
668
+ (typeof asObject.actor.name === 'string') &&
669
+ (this.__handledActors.has(asObject.actor.id))) {
670
+ this.__completeJob();
671
+ } else {
672
+ this.debug(
673
+ 'calling sendToClient for ' + asObject.actor.id, [...this.__handledActors.keys()]);
674
+ this.sendToClient(asObject);
675
+ }
676
+ });
677
+
678
+ this.irc2as.events.on('unprocessed', (string) => {
679
+ this.debug('unprocessed irc message:> ' + string);
680
+ });
681
+
682
+ // The generated eslint error expects that the `error` event is propagating an Error object
683
+ // however for irc2as this event delivers an AS object of type `error`.
684
+ // eslint-disable-next-line security-node/detect-unhandled-event-errors
685
+ this.irc2as.events.on('error', (asObject) => {
686
+ this.debug('message error response ' + asObject.object.content);
687
+ this.__completeJob(asObject.object.content);
688
+ });
689
+
690
+ this.irc2as.events.on('pong', (timestamp) => {
691
+ this.debug('received PONG at ' + timestamp);
692
+ this.__completeJob();
693
+ });
694
+
695
+ this.irc2as.events.on('ping', (timestamp) => {
696
+ this.debug('received PING at ' + timestamp);
697
+ this.__client.raw('PONG');
698
+ });
699
+ };
700
+
701
+ module.exports = IRC;