@sockethub/platform-irc 4.0.0-alpha.9 → 4.0.0-beta.0

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,764 +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
- debug: Logger;
81
- credentialsHash: string;
82
- config: PersistentPlatformConfig = {
83
- persist: true,
84
- requireCredentials: ["connect", "update"],
85
- initialized: false,
86
- connectTimeoutMs: 30000,
87
- };
88
- private readonly updateActor: PlatformUpdateActor;
89
- private readonly sendToClient: PlatformSendToClient;
90
- private irc2as: typeof IrcToActivityStreams;
91
- private forceDisconnect = false;
92
- private clientConnecting = 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.debug = session.debug;
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
- * Function: connect
151
- *
152
- * Connect to an IRC server.
153
- *
154
- * @param {object} job activity streams object
155
- * @param {object} credentials credentials object
156
- * @param {object} done callback when job is done
157
- */
158
- connect(
159
- job: ActivityStream,
160
- credentials: PlatformIrcCredentialsObject,
161
- done: PlatformCallback,
162
- ) {
163
- this.getClient(job.actor.id, credentials, (err) => {
164
- if (err) {
165
- return done(err);
166
- }
167
- return done();
168
- });
169
- }
170
-
171
- /**
172
- * Function: join
173
- *
174
- * Join a room or private conversation.
175
- *
176
- * @param {object} job activity streams object
177
- * @param {object} done callback when job is done
178
- *
179
- * @example
180
- *
181
- * {
182
- * context: 'irc',
183
- * type: 'join',
184
- * actor: {
185
- * id: 'slvrbckt@irc.freenode.net',
186
- * type: 'person',
187
- * name: 'slvrbckt'
188
- * },
189
- * target: {
190
- * id: 'irc.freenode.net/a-room',
191
- * type: 'room',
192
- * name: '#a-room'
193
- * },
194
- * object: {}
195
- * }
196
- *
197
- */
198
- join(job: ActivityStream, done: PlatformCallback) {
199
- this.debug(`join() called for ${job.actor.id}`);
200
- this.getClient(job.actor.id, false, (err, client) => {
201
- if (err) {
202
- return done(err);
203
- }
204
- if (this.channels.has(job.target.name)) {
205
- this.debug(`channel ${job.target.name} already joined`);
206
- return done();
207
- }
208
- // join channel
209
- this.jobQueue.push(() => {
210
- this.hasJoined(job.target.name);
211
- done();
212
- });
213
- this.debug(`sending join ${job.target.name}`);
214
- client.raw(["JOIN", job.target.name]);
215
- client.raw(`PING ${job.actor.name}`);
216
- });
217
- }
218
-
219
- /**
220
- * Function leave
221
- *
222
- * Leave a room or private conversation.
223
- *
224
- * @param {object} job activity streams object
225
- * @param {object} done callback when job is done
226
- *
227
- * @example
228
- * {
229
- * context: 'irc',
230
- * type: 'leave',
231
- * actor: {
232
- * id: 'slvrbckt@irc.freenode.net',
233
- * type: 'person',
234
- * name: 'slvrbckt'
235
- * },
236
- * target: {
237
- * id: 'irc.freenode.net/remotestorage',
238
- * type: 'room',
239
- * name: '#remotestorage'
240
- * },
241
- * object: {}
242
- * }
243
- *
244
- */
245
- leave(job: ActivityStream, done: PlatformCallback) {
246
- this.debug(`leave() called for ${job.actor.name}`);
247
- this.getClient(job.actor.id, false, (err, client) => {
248
- if (err) {
249
- return done(err);
250
- }
251
- // leave channel
252
- this.hasLeft(job.target.name);
253
- client.raw(["PART", job.target.name]);
254
- done();
255
- });
256
- }
257
-
258
- /**
259
- * Function: send
260
- *
261
- * Send a message to a room or private conversation.
262
- *
263
- * @param {object} job activity streams object
264
- * @param {object} done callback when job is done
265
- *
266
- * @example
267
- *
268
- * {
269
- * context: 'irc',
270
- * type: 'send',
271
- * actor: {
272
- * id: 'slvrbckt@irc.freenode.net',
273
- * type: 'person',
274
- * name: 'Nick Jennings',
275
- * userName: 'slvrbckt'
276
- * },
277
- * target: {
278
- * id: 'irc.freenode.net/remotestorage',
279
- * type: 'room',
280
- * name: '#remotestorage'
281
- * },
282
- * object: {
283
- * type: 'message',
284
- * content: 'Hello from Sockethub!'
285
- * }
286
- * }
287
- *
288
- */
289
- send(job: ActivityStream, done: PlatformCallback) {
290
- this.debug(
291
- `send() called for ${job.actor.id} target: ${job.target.id}`,
292
- );
293
- this.getClient(job.actor.id, false, async (err, client) => {
294
- if (err) {
295
- return done(err);
296
- }
297
-
298
- if (typeof job.object.content !== "string") {
299
- return done("cannot send message with no object.content");
300
- }
301
-
302
- const match = /(\/\w+)\s*([\s\S]*)/.exec(job.object.content);
303
- if (match) {
304
- const cmd = match[1].substring(1).toUpperCase(); // get command
305
- const msg = match[2].trim(); // remove leading/trailing whitespace from remaining text
306
- if (cmd === "ME") {
307
- // handle /me messages uniquely
308
- job.object.type = "me";
309
- job.object.content = msg;
310
- } else if (cmd === "NOTICE") {
311
- // attempt to send as raw command
312
- job.object.type = "notice";
313
- job.object.content = msg;
314
- }
315
- } else {
316
- job.object.content = job.object.content.trim();
317
- }
318
-
319
- if (job.object.type === "me") {
320
- // message intended as command
321
- // jsdoc does not like this octal escape sequence, but it's needed for proper behavior in IRC
322
- // so the following line needs to be commented out when the API doc is built.
323
- // investigate:
324
- // https://github.com/jsdoc2md/jsdoc-to-markdown/issues/197#issuecomment-976851915
325
- const buildCommand = await import("./octal-hack.js");
326
- const message = buildCommand(job.object.content);
327
- // biome-ignore lint/style/useTemplate: <explanation>
328
- client.raw("PRIVMSG " + job.target.name + " :" + message);
329
- return done("IRC commands temporarily disabled");
330
- }
331
- if (job.object.type === "notice") {
332
- // attempt to send as raw command
333
- client.raw(`NOTICE ${job.target.name} :${job.object.content}`);
334
- } else if (this.isJoined(job.target.name)) {
335
- client.raw(`PRIVMSG ${job.target.name} :${job.object.content}`);
336
- } else {
337
- return done(
338
- "cannot send message to a channel of which you've not first joined.",
339
- );
340
- }
341
- this.jobQueue.push(done);
342
- client.raw(`PING ${job.actor.name}`);
343
- });
344
- }
345
-
346
- /**
347
- * Function: update
348
- *
349
- * Indicate a change (i.e. room topic update, or nickname change).
350
- *
351
- * @param {object} job activity streams object
352
- * @param {object} credentials credentials to verify this user is the right one
353
- * @param {object} done callback when job is done
354
- *
355
- * @example change topic
356
- *
357
- * {
358
- * context: 'irc',
359
- * type: 'update',
360
- * actor: {
361
- * id: 'slvrbckt@irc.freenode.net',
362
- * type: 'person',
363
- * name: 'Nick Jennings',
364
- * userName: 'slvrbckt'
365
- * },
366
- * target: {
367
- * id: 'irc.freenode.net/a-room',
368
- * type: 'room',
369
- * name: '#a-room'
370
- * },
371
- * object: {
372
- * type: 'topic',
373
- * content: 'New version of Sockethub released!'
374
- * }
375
- * }
376
- *
377
- * @example change nickname
378
- * {
379
- * context: 'irc'
380
- * type: 'update',
381
- * actor: {
382
- * id: 'slvrbckt@irc.freenode.net',
383
- * type: 'person',
384
- * name: 'slvrbckt'
385
- * },
386
- * object: {
387
- * type: "address",
388
- * },
389
- * target: {
390
- * id: 'cooldude@irc.freenode.net',
391
- * type: 'person',
392
- * name: 'cooldude'
393
- * }
394
- * }
395
- */
396
- update(
397
- job: ActivityStream,
398
- credentials: PlatformIrcCredentialsObject,
399
- done: PlatformCallback,
400
- ) {
401
- this.debug(`update() called for ${job.actor.id}`);
402
- this.getClient(job.actor.id, false, (err, client) => {
403
- if (err) {
404
- return done(err);
405
- }
406
- if (job.object.type === "address") {
407
- this.debug(
408
- `changing nick from ${job.actor.name} to ${job.target.name}`,
409
- );
410
- this.handledActors.add(job.target.id);
411
- this.jobQueue.push(async (err: Error) => {
412
- if (err) {
413
- this.handledActors.delete(job.target.id);
414
- return done(err);
415
- }
416
- credentials.object.nick = job.target.name;
417
- credentials.actor.id = `${job.target.name}@${credentials.object.server}`;
418
- credentials.actor.name = job.target.name;
419
- await this.updateActor(credentials);
420
- done();
421
- });
422
- // send nick change command
423
- client.raw(["NICK", job.target.name]);
424
- } else if (job.object.type === "topic") {
425
- // update topic
426
- this.debug(`changing topic in channel ${job.target.name}`);
427
- this.jobQueue.push(done);
428
- client.raw(["topic", job.target.name, job.object.content]);
429
- } else {
430
- return done(`unknown update action: ${job.object.type}`);
431
- }
432
- client.raw(`PING ${job.actor.name}`);
433
- });
434
- }
435
-
436
- /**
437
- * Function: query
438
- *
439
- * Indicate an intent to query something (e.g. get a list of users in a room).
440
- *
441
- * @param {object} job activity streams object
442
- * @param {object} done callback when job is done
443
- *
444
- * @example
445
- *
446
- * {
447
- * context: 'irc',
448
- * type: 'query',
449
- * actor: {
450
- * id: 'slvrbckt@irc.freenode.net',
451
- * type: 'person',
452
- * name: 'Nick Jennings',
453
- * userName: 'slvrbckt'
454
- * },
455
- * target: {
456
- * id: 'irc.freenode.net/a-room',
457
- * type: 'room',
458
- * name: '#a-room'
459
- * },
460
- * object: {
461
- * type: 'attendance'
462
- * }
463
- * }
464
- *
465
- *
466
- * // The above object might return:
467
- * {
468
- * context: 'irc',
469
- * type: 'query',
470
- * actor: {
471
- * id: 'irc.freenode.net/a-room',
472
- * type: 'room',
473
- * name: '#a-room'
474
- * },
475
- * target: {},
476
- * object: {
477
- * type: 'attendance'
478
- * members: [
479
- * 'RyanGosling',
480
- * 'PeeWeeHerman',
481
- * 'Commando',
482
- * 'Smoochie',
483
- * 'neo'
484
- * ]
485
- * }
486
- * }
487
- *
488
- */
489
- query(job: ActivityStream, done: PlatformCallback) {
490
- this.debug(`query() called for ${job.actor.id}`);
491
- this.getClient(job.actor.id, false, (err, client) => {
492
- if (err) {
493
- return done(err);
494
- }
495
-
496
- if (job.object.type === "attendance") {
497
- this.debug(`query() - sending NAMES for ${job.target.name}`);
498
- client.raw(["NAMES", job.target.name]);
499
- done();
500
- } else {
501
- done(`unknown 'type' '${job.object.type}'`);
502
- }
503
- });
504
- }
505
-
506
- /**
507
- * Disconnect IRC client
508
- * @param {object} job activity streams object
509
- * @param done
510
- *
511
- * @example
512
- *
513
- * {
514
- * context: 'irc',
515
- * type: 'disconnect',
516
- * actor: {
517
- * id: 'slvrbckt@irc.freenode.net',
518
- * type: 'person'
519
- * }
520
- * }
521
- */
522
- disconnect(job: ActivityStream, done: PlatformCallback) {
523
- this.debug(`disconnect called for ${job.actor.id}`);
524
- this.cleanup(done);
525
- }
526
-
527
- cleanup(done: PlatformCallback) {
528
- this.debug("cleanup() called");
529
- this.config.initialized = false;
530
- this.forceDisconnect = true;
531
- if (typeof this.client === "object") {
532
- if (typeof this.client.end === "function") {
533
- this.client.end();
534
- }
535
- }
536
- this.client = undefined;
537
- return done();
538
- }
539
-
540
- //
541
- // Private methods
542
- //
543
- private isJoined(channel: string) {
544
- if (channel.indexOf("#") === 0) {
545
- // valid channel name
546
- return this.channels.has(channel);
547
- }
548
-
549
- // usernames are always OK to send to
550
- return true;
551
- }
552
-
553
- private hasJoined(channel: string) {
554
- this.debug(`joined ${channel}`);
555
- // keep track of channels joined
556
- if (!this.channels.has(channel)) {
557
- this.channels.add(channel);
558
- }
559
- }
560
-
561
- private hasLeft(channel: string) {
562
- this.debug(`left ${channel}`);
563
- // keep track of channels left
564
- if (this.channels.has(channel)) {
565
- this.channels.delete(channel);
566
- }
567
- }
568
-
569
- private getClient(
570
- key: string,
571
- credentials: PlatformIrcCredentialsObject | false,
572
- cb: GetClientCallback,
573
- ) {
574
- this.debug(`getClient called, connecting: ${this.clientConnecting}`);
575
- if (this.client) {
576
- this.handledActors.add(key);
577
- return cb(null, this.client);
578
- }
579
-
580
- if (this.clientConnecting) {
581
- // client is in the process of connecting, wait
582
- setTimeout(() => {
583
- if (this.client) {
584
- this.debug(`resolving delayed getClient call for ${key}`);
585
- this.handledActors.add(key);
586
- return cb(null, this.client);
587
- }
588
- return cb("failed to get irc client, please try again.");
589
- }, this.config.connectTimeoutMs);
590
- return;
591
- }
592
-
593
- if (!credentials) {
594
- return cb(
595
- "no client found, and no credentials specified. you must connect first",
596
- );
597
- }
598
-
599
- this.ircConnect(credentials, (err, client) => {
600
- if (err) {
601
- this.config.initialized = false;
602
- return cb(err);
603
- }
604
- this.handledActors.add(key);
605
- this.client = client;
606
- this.registerListeners(credentials.object.server);
607
- this.config.initialized = true;
608
- return cb(null, client);
609
- });
610
- }
611
-
612
- private ircConnect(
613
- credentials: PlatformIrcCredentialsObject,
614
- cb: GetClientCallback,
615
- ) {
616
- this.clientConnecting = true;
617
- const is_secure =
618
- typeof credentials.object.secure === "boolean"
619
- ? credentials.object.secure
620
- : true;
621
- const is_sasl =
622
- typeof credentials.object.sasl === "boolean"
623
- ? credentials.object.sasl
624
- : !!credentials.object.password;
625
-
626
- const module_options: IrcSocketOptions = {
627
- username: credentials.object.username || credentials.object.nick,
628
- nicknames: [credentials.object.nick],
629
- server: credentials.object.server || "irc.libera.chat",
630
- realname: credentials.actor.name || credentials.object.nick,
631
- port: credentials.object.port
632
- ? typeof credentials.object.port === "string"
633
- ? Number.parseInt(credentials.object.port, 10)
634
- : credentials.object.port
635
- : is_secure
636
- ? 6697
637
- : 6667,
638
- debug: console.log,
639
- };
640
- if (is_secure) {
641
- module_options.connectOptions = { rejectUnauthorized: false };
642
- }
643
- if (is_sasl) {
644
- module_options.saslPassword = credentials.object.password;
645
- module_options.capabilities = { requires: ["sasl"] };
646
- }
647
-
648
- this.debug(
649
- `attempting to connect to ${module_options.server}:${module_options.port} transport: ${
650
- is_secure ? "secure" : "clear"
651
- } sasl: ${is_sasl}`,
652
- );
653
-
654
- const client: IrcSocket = new IrcSocket(
655
- module_options,
656
- is_secure ? tls : net,
657
- );
658
-
659
- const forceDisconnect = (err: string) => {
660
- this.forceDisconnect = true;
661
- this.clientConnecting = false;
662
- if (client && typeof client.end === "function") {
663
- client.end();
664
- }
665
- if (this.client && typeof this.client.end === "function") {
666
- this.client.end();
667
- }
668
- cb(err);
669
- };
670
-
671
- client.once("error", (err: string) => {
672
- this.debug(`irc client 'error' occurred. `, err);
673
- forceDisconnect("error connecting to server.");
674
- });
675
-
676
- client.once("close", () => {
677
- this.debug(`irc client 'close' event fired.`);
678
- forceDisconnect("connection to server closed.");
679
- });
680
-
681
- client.once("timeout", () => {
682
- this.debug("timeout occurred, force-disconnect");
683
- forceDisconnect("connection timeout to server.");
684
- });
685
-
686
- client.connect().then((res: IrcSocketConnectResponse) => {
687
- if (res.isFail()) {
688
- return cb(`unable to connect to server: ${res.fail()}`);
689
- }
690
- const capabilities = res.ok();
691
- this.clientConnecting = false;
692
- if (this.forceDisconnect) {
693
- client.end();
694
- return cb("force disconnect active, aborting connect.");
695
- }
696
-
697
- this.debug(
698
- `connected to ${module_options.server} capabilities: `,
699
- capabilities,
700
- );
701
- return cb(null, client);
702
- });
703
- }
704
-
705
- private completeJob(err?: string) {
706
- this.debug(`completing job, queue count: ${this.jobQueue.length}`);
707
- const done = this.jobQueue.shift();
708
- if (typeof done === "function") {
709
- done(err);
710
- } else if (this.jobQueue.length === 0) {
711
- this.debug(
712
- "WARNING: job completion event received with an empty job queue.",
713
- );
714
- } else {
715
- this.debug(
716
- `WARNING: job completion found non-function in queue (${typeof done}), ${this.jobQueue.length} items remain.`,
717
- );
718
- }
719
- }
720
-
721
- private registerListeners(server: string) {
722
- this.irc2as = new IrcToActivityStreams({ server: server });
723
- this.client.on("data", (data: never) => {
724
- this.irc2as.input(data);
725
- });
726
-
727
- this.irc2as.events.on("incoming", (asObject: ActivityStream) => {
728
- if (
729
- typeof asObject.actor === "object" &&
730
- typeof asObject.actor.name === "string" &&
731
- this.handledActors.has(asObject.actor.id)
732
- ) {
733
- this.completeJob();
734
- } else {
735
- this.debug(`calling sendToClient for ${asObject.actor.id}`, [
736
- ...this.handledActors.keys(),
737
- ]);
738
- this.sendToClient(asObject);
739
- }
740
- });
741
-
742
- this.irc2as.events.on("unprocessed", (s: string) => {
743
- this.debug(`unprocessed irc message:> ${s}`);
744
- });
745
-
746
- // The generated eslint error expects that the `error` event is propagating an Error object
747
- // however for irc2as this event delivers an AS object of type `error`.
748
-
749
- this.irc2as.events.on("error", (asObject: ActivityStream) => {
750
- this.debug(`message error response ${asObject.object.content}`);
751
- this.completeJob(asObject.object.content);
752
- });
753
-
754
- this.irc2as.events.on("pong", (timestamp: string) => {
755
- this.debug(`received PONG at ${timestamp}`);
756
- this.completeJob();
757
- });
758
-
759
- this.irc2as.events.on("ping", (timestamp: string) => {
760
- this.debug(`received PING at ${timestamp}`);
761
- this.client.raw("PONG");
762
- });
763
- }
764
- }