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