@sockethub/platform-irc 4.0.0-alpha.11 → 4.0.0-alpha.13

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/src/index.ts DELETED
@@ -1,779 +0,0 @@
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
- import net from "node:net";
20
- import tls from "node:tls";
21
- import IrcSocket from "irc-socket-sasl";
22
-
23
- import { IrcToActivityStreams } from "@sockethub/irc2as";
24
- import type {
25
- ActivityStream,
26
- Logger,
27
- PersistentPlatformConfig,
28
- PersistentPlatformInterface,
29
- PlatformCallback,
30
- PlatformSchemaStruct,
31
- PlatformSendToClient,
32
- PlatformSession,
33
- PlatformUpdateActor,
34
- } from "@sockethub/schemas";
35
-
36
- import { PlatformIrcSchema } from "./schema.js";
37
- import type { PlatformIrcCredentialsObject } from "./types.js";
38
-
39
- export type GetClientCallback = (
40
- err: string | null,
41
- client?: typeof IrcSocket,
42
- ) => void;
43
-
44
- interface IrcSocketOptionsCapabilities {
45
- requires: string[];
46
- }
47
-
48
- interface IrcSocketConnectResponse {
49
- isFail(): boolean;
50
- fail(): string;
51
- ok(): boolean;
52
- end(): void;
53
- }
54
-
55
- interface IrcSocketOptionsConnect {
56
- rejectUnauthorized: boolean;
57
- }
58
- interface IrcSocketOptions {
59
- username: string;
60
- nicknames: string[];
61
- server: string;
62
- realname: string;
63
- port: number;
64
- debug: typeof console.log;
65
- saslPassword?: string;
66
- capabilities?: IrcSocketOptionsCapabilities;
67
- connectOptions?: IrcSocketOptionsConnect;
68
- }
69
-
70
- /**
71
- * @class IRC
72
- * @constructor
73
- *
74
- * @description
75
- * Handles all actions related to communication via. the IRC protocol.
76
- *
77
- * @param {object} cfg a unique config object for this instance
78
- */
79
- export default class IRC implements PersistentPlatformInterface {
80
- private readonly log: Logger;
81
- credentialsHash: string;
82
- config: PersistentPlatformConfig = {
83
- persist: true,
84
- requireCredentials: ["connect", "update"],
85
- connectTimeoutMs: 30000,
86
- };
87
- private readonly updateActor: PlatformUpdateActor;
88
- private readonly sendToClient: PlatformSendToClient;
89
- private irc2as: typeof IrcToActivityStreams;
90
- private forceDisconnect = false;
91
- private clientConnecting = false;
92
- private initialized = false;
93
- private client: typeof IrcSocket;
94
- private jobQueue = []; // list of handlers to confirm when message delivery confirmed
95
- private channels = new Set();
96
- private handledActors = new Set();
97
-
98
- constructor(session: PlatformSession) {
99
- this.log = session.log;
100
- this.sendToClient = session.sendToClient;
101
- this.updateActor = session.updateActor;
102
- }
103
-
104
- /**
105
- * Property: schema
106
- *
107
- * @description
108
- * JSON schema defining the types this platform accepts.
109
- *
110
- *
111
- * In the below example, Sockethub will validate the incoming credentials object
112
- * against whatever is defined in the `credentials` portion of the schema
113
- * object.
114
- *
115
- *
116
- * It will also check if the incoming AS object uses a type which exists in the
117
- * `types` portion of the schema object (should be an array of type names).
118
- *
119
- * * **NOTE**: For more information on using the credentials object from a client,
120
- * see [Sockethub Client](https://github.com/sockethub/sockethub/wiki/Sockethub-Client)
121
- *
122
- * Valid AS object for setting IRC credentials:
123
- * @example
124
- *
125
- * {
126
- * type: 'credentials',
127
- * context: 'irc',
128
- * actor: {
129
- * id: 'testuser@irc.host.net',
130
- * type: 'person',
131
- * name: 'Mr. Test User',
132
- * userName: 'testuser'
133
- * },
134
- * object: {
135
- * type: 'credentials',
136
- * server: 'irc.host.net',
137
- * nick: 'testuser',
138
- * password: 'secret',
139
- * port: 6697,
140
- * secure: true,
141
- * sasl: true
142
- * }
143
- * }
144
- */
145
- get schema(): PlatformSchemaStruct {
146
- return PlatformIrcSchema;
147
- }
148
-
149
- /**
150
- * Returns whether the platform is ready to handle jobs.
151
- * For IRC, this means we have successfully connected to the server.
152
- */
153
- isInitialized(): boolean {
154
- return this.initialized;
155
- }
156
-
157
- /**
158
- * Function: connect
159
- *
160
- * Connect to an IRC server.
161
- *
162
- * @param {object} job activity streams object
163
- * @param {object} credentials credentials object
164
- * @param {object} done callback when job is done
165
- */
166
- connect(
167
- job: ActivityStream,
168
- credentials: PlatformIrcCredentialsObject,
169
- done: PlatformCallback,
170
- ) {
171
- this.getClient(job.actor.id, credentials, (err) => {
172
- if (err) {
173
- return done(err);
174
- }
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
185
- * @param {object} done callback when job is done
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
- join(job: ActivityStream, done: PlatformCallback) {
207
- this.log.debug(`join() called for ${job.actor.id}`);
208
- this.getClient(job.actor.id, false, (err, client) => {
209
- if (err) {
210
- return done(err);
211
- }
212
- if (this.channels.has(job.target.name)) {
213
- this.log.debug(`channel ${job.target.name} already joined`);
214
- return done();
215
- }
216
- // join channel
217
- this.jobQueue.push(() => {
218
- this.hasJoined(job.target.name);
219
- done();
220
- });
221
- this.log.debug(`sending join ${job.target.name}`);
222
- client.raw(["JOIN", job.target.name]);
223
- client.raw(`PING ${job.actor.name}`);
224
- });
225
- }
226
-
227
- /**
228
- * Function leave
229
- *
230
- * Leave a room or private conversation.
231
- *
232
- * @param {object} job activity streams object
233
- * @param {object} done callback when job is done
234
- *
235
- * @example
236
- * {
237
- * context: 'irc',
238
- * type: 'leave',
239
- * actor: {
240
- * id: 'slvrbckt@irc.freenode.net',
241
- * type: 'person',
242
- * name: 'slvrbckt'
243
- * },
244
- * target: {
245
- * id: 'irc.freenode.net/remotestorage',
246
- * type: 'room',
247
- * name: '#remotestorage'
248
- * },
249
- * object: {}
250
- * }
251
- *
252
- */
253
- leave(job: ActivityStream, done: PlatformCallback) {
254
- this.log.debug(`leave() called for ${job.actor.name}`);
255
- this.getClient(job.actor.id, false, (err, client) => {
256
- if (err) {
257
- return done(err);
258
- }
259
- // leave channel
260
- this.hasLeft(job.target.name);
261
- client.raw(["PART", job.target.name]);
262
- done();
263
- });
264
- }
265
-
266
- /**
267
- * Function: send
268
- *
269
- * Send a message to a room or private conversation.
270
- *
271
- * @param {object} job activity streams object
272
- * @param {object} done callback when job is done
273
- *
274
- * @example
275
- *
276
- * {
277
- * context: 'irc',
278
- * type: 'send',
279
- * actor: {
280
- * id: 'slvrbckt@irc.freenode.net',
281
- * type: 'person',
282
- * name: 'Nick Jennings',
283
- * userName: 'slvrbckt'
284
- * },
285
- * target: {
286
- * id: 'irc.freenode.net/remotestorage',
287
- * type: 'room',
288
- * name: '#remotestorage'
289
- * },
290
- * object: {
291
- * type: 'message',
292
- * content: 'Hello from Sockethub!'
293
- * }
294
- * }
295
- *
296
- */
297
- send(job: ActivityStream, done: PlatformCallback) {
298
- this.log.debug(
299
- `send() called for ${job.actor.id} target: ${job.target.id}`,
300
- );
301
- this.getClient(job.actor.id, false, async (err, client) => {
302
- if (err) {
303
- return done(err);
304
- }
305
-
306
- if (typeof job.object.content !== "string") {
307
- return done("cannot send message with no object.content");
308
- }
309
-
310
- const match = /(\/\w+)\s*([\s\S]*)/.exec(job.object.content);
311
- if (match) {
312
- const cmd = match[1].substring(1).toUpperCase(); // get command
313
- const msg = match[2].trim(); // remove leading/trailing whitespace from remaining text
314
- if (cmd === "ME") {
315
- // handle /me messages uniquely
316
- job.object.type = "me";
317
- job.object.content = msg;
318
- } else if (cmd === "NOTICE") {
319
- // attempt to send as raw command
320
- job.object.type = "notice";
321
- job.object.content = msg;
322
- }
323
- } else {
324
- job.object.content = job.object.content.trim();
325
- }
326
-
327
- if (job.object.type === "me") {
328
- // message intended as command
329
- // jsdoc does not like this octal escape sequence, but it's needed for proper behavior in IRC
330
- // so the following line needs to be commented out when the API doc is built.
331
- // investigate:
332
- // https://github.com/jsdoc2md/jsdoc-to-markdown/issues/197#issuecomment-976851915
333
- const buildCommand = await import("./octal-hack.js");
334
- const message = buildCommand(job.object.content);
335
- // biome-ignore lint/style/useTemplate: <explanation>
336
- client.raw("PRIVMSG " + job.target.name + " :" + message);
337
- return done("IRC commands temporarily disabled");
338
- }
339
- if (job.object.type === "notice") {
340
- // attempt to send as raw command
341
- client.raw(`NOTICE ${job.target.name} :${job.object.content}`);
342
- } else if (this.isJoined(job.target.name)) {
343
- client.raw(`PRIVMSG ${job.target.name} :${job.object.content}`);
344
- } else {
345
- return done(
346
- "cannot send message to a channel of which you've not first joined.",
347
- );
348
- }
349
- this.jobQueue.push(done);
350
- client.raw(`PING ${job.actor.name}`);
351
- });
352
- }
353
-
354
- /**
355
- * Function: update
356
- *
357
- * Indicate a change (i.e. room topic update, or nickname change).
358
- *
359
- * @param {object} job activity streams object
360
- * @param {object} credentials credentials to verify this user is the right one
361
- * @param {object} done callback when job is done
362
- *
363
- * @example change topic
364
- *
365
- * {
366
- * context: 'irc',
367
- * type: 'update',
368
- * actor: {
369
- * id: 'slvrbckt@irc.freenode.net',
370
- * type: 'person',
371
- * name: 'Nick Jennings',
372
- * userName: 'slvrbckt'
373
- * },
374
- * target: {
375
- * id: 'irc.freenode.net/a-room',
376
- * type: 'room',
377
- * name: '#a-room'
378
- * },
379
- * object: {
380
- * type: 'topic',
381
- * content: 'New version of Sockethub released!'
382
- * }
383
- * }
384
- *
385
- * @example change nickname
386
- * {
387
- * context: 'irc'
388
- * type: 'update',
389
- * actor: {
390
- * id: 'slvrbckt@irc.freenode.net',
391
- * type: 'person',
392
- * name: 'slvrbckt'
393
- * },
394
- * object: {
395
- * type: "address",
396
- * },
397
- * target: {
398
- * id: 'cooldude@irc.freenode.net',
399
- * type: 'person',
400
- * name: 'cooldude'
401
- * }
402
- * }
403
- */
404
- update(
405
- job: ActivityStream,
406
- credentials: PlatformIrcCredentialsObject,
407
- done: PlatformCallback,
408
- ) {
409
- this.log.debug(`update() called for ${job.actor.id}`);
410
- this.getClient(job.actor.id, false, (err, client) => {
411
- if (err) {
412
- return done(err);
413
- }
414
- if (job.object.type === "address") {
415
- this.log.debug(
416
- `changing nick from ${job.actor.name} to ${job.target.name}`,
417
- );
418
- this.handledActors.add(job.target.id);
419
- this.jobQueue.push(async (err: Error) => {
420
- if (err) {
421
- this.handledActors.delete(job.target.id);
422
- return done(err);
423
- }
424
- credentials.object.nick = job.target.name;
425
- credentials.actor.id = `${job.target.name}@${credentials.object.server}`;
426
- credentials.actor.name = job.target.name;
427
- await this.updateActor(credentials);
428
- done();
429
- });
430
- // send nick change command
431
- client.raw(["NICK", job.target.name]);
432
- } else if (job.object.type === "topic") {
433
- // update topic
434
- this.log.debug(`changing topic in channel ${job.target.name}`);
435
- this.jobQueue.push(done);
436
- client.raw(["topic", job.target.name, job.object.content]);
437
- } else {
438
- return done(`unknown update action: ${job.object.type}`);
439
- }
440
- client.raw(`PING ${job.actor.name}`);
441
- });
442
- }
443
-
444
- /**
445
- * Function: query
446
- *
447
- * Indicate an intent to query something (e.g. get a list of users in a room).
448
- *
449
- * @param {object} job activity streams object
450
- * @param {object} done callback when job is done
451
- *
452
- * @example
453
- *
454
- * {
455
- * context: 'irc',
456
- * type: 'query',
457
- * actor: {
458
- * id: 'slvrbckt@irc.freenode.net',
459
- * type: 'person',
460
- * name: 'Nick Jennings',
461
- * userName: 'slvrbckt'
462
- * },
463
- * target: {
464
- * id: 'irc.freenode.net/a-room',
465
- * type: 'room',
466
- * name: '#a-room'
467
- * },
468
- * object: {
469
- * type: 'attendance'
470
- * }
471
- * }
472
- *
473
- *
474
- * // The above object might return:
475
- * {
476
- * context: 'irc',
477
- * type: 'query',
478
- * actor: {
479
- * id: 'irc.freenode.net/a-room',
480
- * type: 'room',
481
- * name: '#a-room'
482
- * },
483
- * target: {},
484
- * object: {
485
- * type: 'attendance'
486
- * members: [
487
- * 'RyanGosling',
488
- * 'PeeWeeHerman',
489
- * 'Commando',
490
- * 'Smoochie',
491
- * 'neo'
492
- * ]
493
- * }
494
- * }
495
- *
496
- */
497
- query(job: ActivityStream, done: PlatformCallback) {
498
- this.log.debug(`query() called for ${job.actor.id}`);
499
- this.getClient(job.actor.id, false, (err, client) => {
500
- if (err) {
501
- return done(err);
502
- }
503
-
504
- if (job.object.type === "attendance") {
505
- this.log.debug(
506
- `query() - sending NAMES for ${job.target.name}`,
507
- );
508
- client.raw(["NAMES", job.target.name]);
509
- done();
510
- } else {
511
- done(`unknown 'type' '${job.object.type}'`);
512
- }
513
- });
514
- }
515
-
516
- /**
517
- * Disconnect IRC client
518
- * @param {object} job activity streams object
519
- * @param done
520
- *
521
- * @example
522
- *
523
- * {
524
- * context: 'irc',
525
- * type: 'disconnect',
526
- * actor: {
527
- * id: 'slvrbckt@irc.freenode.net',
528
- * type: 'person'
529
- * }
530
- * }
531
- */
532
- disconnect(job: ActivityStream, done: PlatformCallback) {
533
- this.log.debug(`disconnect called for ${job.actor.id}`);
534
- this.cleanup(done);
535
- }
536
-
537
- cleanup(done: PlatformCallback) {
538
- this.log.debug("cleanup() called");
539
- this.initialized = false;
540
- this.forceDisconnect = true;
541
- if (typeof this.client === "object") {
542
- if (typeof this.client.end === "function") {
543
- this.client.end();
544
- }
545
- }
546
- this.client = undefined;
547
- return done();
548
- }
549
-
550
- //
551
- // Private methods
552
- //
553
- private isJoined(channel: string) {
554
- if (channel.indexOf("#") === 0) {
555
- // valid channel name
556
- return this.channels.has(channel);
557
- }
558
-
559
- // usernames are always OK to send to
560
- return true;
561
- }
562
-
563
- private hasJoined(channel: string) {
564
- this.log.debug(`joined ${channel}`);
565
- // keep track of channels joined
566
- if (!this.channels.has(channel)) {
567
- this.channels.add(channel);
568
- }
569
- }
570
-
571
- private hasLeft(channel: string) {
572
- this.log.debug(`left ${channel}`);
573
- // keep track of channels left
574
- if (this.channels.has(channel)) {
575
- this.channels.delete(channel);
576
- }
577
- }
578
-
579
- private getClient(
580
- key: string,
581
- credentials: PlatformIrcCredentialsObject | false,
582
- cb: GetClientCallback,
583
- ) {
584
- this.log.debug(
585
- `getClient called, connecting: ${this.clientConnecting}`,
586
- );
587
- if (this.client) {
588
- this.handledActors.add(key);
589
- return cb(null, this.client);
590
- }
591
-
592
- if (this.clientConnecting) {
593
- // client is in the process of connecting, wait
594
- setTimeout(() => {
595
- if (this.client) {
596
- this.log.debug(
597
- `resolving delayed getClient call for ${key}`,
598
- );
599
- this.handledActors.add(key);
600
- return cb(null, this.client);
601
- }
602
- return cb("failed to get irc client, please try again.");
603
- }, this.config.connectTimeoutMs);
604
- return;
605
- }
606
-
607
- if (!credentials) {
608
- return cb(
609
- "no client found, and no credentials specified. you must connect first",
610
- );
611
- }
612
-
613
- this.ircConnect(credentials, (err, client) => {
614
- if (err) {
615
- this.initialized = false;
616
- return cb(err);
617
- }
618
- this.handledActors.add(key);
619
- this.client = client;
620
- this.registerListeners(credentials.object.server);
621
- this.initialized = true;
622
- return cb(null, client);
623
- });
624
- }
625
-
626
- private ircConnect(
627
- credentials: PlatformIrcCredentialsObject,
628
- cb: GetClientCallback,
629
- ) {
630
- this.clientConnecting = true;
631
- const is_secure =
632
- typeof credentials.object.secure === "boolean"
633
- ? credentials.object.secure
634
- : true;
635
- const is_sasl =
636
- typeof credentials.object.sasl === "boolean"
637
- ? credentials.object.sasl
638
- : !!credentials.object.password;
639
-
640
- const module_options: IrcSocketOptions = {
641
- username: credentials.object.username || credentials.object.nick,
642
- nicknames: [credentials.object.nick],
643
- server: credentials.object.server || "irc.libera.chat",
644
- realname: credentials.actor.name || credentials.object.nick,
645
- port: credentials.object.port
646
- ? typeof credentials.object.port === "string"
647
- ? Number.parseInt(credentials.object.port, 10)
648
- : credentials.object.port
649
- : is_secure
650
- ? 6697
651
- : 6667,
652
- debug: console.log,
653
- };
654
- if (is_secure) {
655
- module_options.connectOptions = { rejectUnauthorized: false };
656
- }
657
- if (is_sasl) {
658
- module_options.saslPassword = credentials.object.password;
659
- module_options.capabilities = { requires: ["sasl"] };
660
- }
661
-
662
- this.log.debug(
663
- `attempting to connect to ${module_options.server}:${module_options.port} transport: ${
664
- is_secure ? "secure" : "clear"
665
- } sasl: ${is_sasl}`,
666
- );
667
-
668
- const client: IrcSocket = new IrcSocket(
669
- module_options,
670
- is_secure ? tls : net,
671
- );
672
-
673
- const forceDisconnect = (err: string) => {
674
- this.forceDisconnect = true;
675
- this.clientConnecting = false;
676
- if (client && typeof client.end === "function") {
677
- client.end();
678
- }
679
- if (this.client && typeof this.client.end === "function") {
680
- this.client.end();
681
- }
682
- cb(err);
683
- };
684
-
685
- client.once("error", (err: string) => {
686
- this.log.debug(`irc client 'error' occurred. `, err);
687
- forceDisconnect("error connecting to server.");
688
- });
689
-
690
- client.once("close", () => {
691
- this.log.debug(`irc client 'close' event fired.`);
692
- forceDisconnect("connection to server closed.");
693
- });
694
-
695
- client.once("timeout", () => {
696
- this.log.debug("timeout occurred, force-disconnect");
697
- forceDisconnect("connection timeout to server.");
698
- });
699
-
700
- client.connect().then((res: IrcSocketConnectResponse) => {
701
- if (res.isFail()) {
702
- return cb(`unable to connect to server: ${res.fail()}`);
703
- }
704
- const capabilities = res.ok();
705
- this.clientConnecting = false;
706
- if (this.forceDisconnect) {
707
- client.end();
708
- return cb("force disconnect active, aborting connect.");
709
- }
710
-
711
- this.log.debug(
712
- `connected to ${module_options.server} capabilities: `,
713
- capabilities,
714
- );
715
- return cb(null, client);
716
- });
717
- }
718
-
719
- private completeJob(err?: string) {
720
- this.log.debug(`completing job, queue count: ${this.jobQueue.length}`);
721
- const done = this.jobQueue.shift();
722
- if (typeof done === "function") {
723
- done(err);
724
- } else if (this.jobQueue.length === 0) {
725
- this.log.debug(
726
- "WARNING: job completion event received with an empty job queue.",
727
- );
728
- } else {
729
- this.log.debug(
730
- `WARNING: job completion found non-function in queue (${typeof done}), ${this.jobQueue.length} items remain.`,
731
- );
732
- }
733
- }
734
-
735
- private registerListeners(server: string) {
736
- this.irc2as = new IrcToActivityStreams({ server: server });
737
- this.client.on("data", (data: never) => {
738
- this.irc2as.input(data);
739
- });
740
-
741
- this.irc2as.events.on("incoming", (asObject: ActivityStream) => {
742
- if (
743
- typeof asObject.actor === "object" &&
744
- typeof asObject.actor.name === "string" &&
745
- this.handledActors.has(asObject.actor.id)
746
- ) {
747
- this.completeJob();
748
- } else {
749
- this.log.debug(
750
- `calling sendToClient for ${asObject.actor.id}`,
751
- [...this.handledActors.keys()],
752
- );
753
- this.sendToClient(asObject);
754
- }
755
- });
756
-
757
- this.irc2as.events.on("unprocessed", (s: string) => {
758
- this.log.debug(`unprocessed irc message:> ${s}`);
759
- });
760
-
761
- // The generated eslint error expects that the `error` event is propagating an Error object
762
- // however for irc2as this event delivers an AS object of type `error`.
763
-
764
- this.irc2as.events.on("error", (asObject: ActivityStream) => {
765
- this.log.debug(`message error response ${asObject.object.content}`);
766
- this.completeJob(asObject.object.content);
767
- });
768
-
769
- this.irc2as.events.on("pong", (timestamp: string) => {
770
- this.log.debug(`received PONG at ${timestamp}`);
771
- this.completeJob();
772
- });
773
-
774
- this.irc2as.events.on("ping", (timestamp: string) => {
775
- this.log.debug(`received PING at ${timestamp}`);
776
- this.client.raw("PONG");
777
- });
778
- }
779
- }