@sockethub/platform-xmpp 5.0.0-alpha.10

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.js ADDED
@@ -0,0 +1,726 @@
1
+ /**
2
+ * This is a platform for Sockethub implementing XMPP 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 { client, xml } from "@xmpp/client";
20
+
21
+ import { IncomingHandlers } from "./incoming-handlers.js";
22
+ import { PlatformSchema } from "./schema.js";
23
+ import { utils } from "./utils.js";
24
+
25
+ /**
26
+ * Handles all actions related to communication via. the XMPP protocol.
27
+ *
28
+ * Uses `xmpp.js` as a base tool for interacting with XMPP.
29
+ *
30
+ * {@link https://github.com/xmppjs/xmpp.js}
31
+ */
32
+ export default class XMPP {
33
+ /**
34
+ * Constructor called from the Sockethub `Platform` instance, passing in a
35
+ * session object.
36
+ * @param {object} session - {@link Sockethub.Platform.PlatformSession#object}
37
+ */
38
+ constructor(session) {
39
+ this.id = session.id; // actor
40
+ this.config = {
41
+ connectTimeoutMs: 10000,
42
+ persist: true,
43
+ initialized: false,
44
+ requireCredentials: ["connect"],
45
+ };
46
+ this.debug = session.debug;
47
+ this.sendToClient = session.sendToClient;
48
+ this.createClient();
49
+ this.createXml();
50
+ }
51
+
52
+ createClient() {
53
+ this.__clientConstructor = client;
54
+ }
55
+ createXml() {
56
+ this.__xml = xml;
57
+ }
58
+
59
+ /**
60
+ * Mark the platform as disconnected and uninitialized
61
+ * @param {boolean} stopReconnection - If true, stop automatic reconnection
62
+ */
63
+ __markDisconnected(stopReconnection = false) {
64
+ this.debug(`marking client as disconnected for ${this.id}`);
65
+
66
+ if (stopReconnection && this.__client) {
67
+ this.debug(`stopping automatic reconnection for ${this.id}`);
68
+ this.__client.stop();
69
+ }
70
+
71
+ this.__client = undefined;
72
+ this.config.initialized = false;
73
+ }
74
+
75
+ /**
76
+ * Classify error to determine if reconnection should be attempted
77
+ * @param {Error} err - The error from XMPP client
78
+ * @returns {string} 'RECOVERABLE' or 'NON_RECOVERABLE'
79
+ */
80
+ __classifyError(err) {
81
+ const errorString = err.toString();
82
+ const condition = err.condition;
83
+
84
+ // ONLY these errors are safe to reconnect on
85
+ const recoverableErrors = [
86
+ "ECONNRESET", // Network connection reset
87
+ "ECONNREFUSED", // Connection refused (server down)
88
+ "ETIMEDOUT", // Network timeout
89
+ "ENOTFOUND", // DNS resolution failed
90
+ "EHOSTUNREACH", // Host unreachable
91
+ "ENETUNREACH", // Network unreachable
92
+ ];
93
+
94
+ // Check if this is explicitly a recoverable network error
95
+ if (
96
+ recoverableErrors.some((pattern) => errorString.includes(pattern))
97
+ ) {
98
+ return "RECOVERABLE";
99
+ }
100
+
101
+ // Also check for specific network-level error codes
102
+ if (err.code && recoverableErrors.includes(err.code)) {
103
+ return "RECOVERABLE";
104
+ }
105
+
106
+ // DEFAULT: Everything else is non-recoverable
107
+ // This includes:
108
+ // - StreamError: conflict
109
+ // - SASLError: not-authorized
110
+ // - StreamError: policy-violation
111
+ // - Any unknown XMPP protocol errors
112
+ // - Any authentication failures
113
+ // - Any server policy violations
114
+ // - Any new error types we haven't seen before
115
+ return "NON_RECOVERABLE";
116
+ }
117
+
118
+ /**
119
+ * Check if the XMPP client is properly connected and can send messages
120
+ * @returns {boolean} true if client is connected and operational
121
+ */
122
+ __isClientConnected() {
123
+ if (!this.__client) {
124
+ return false;
125
+ }
126
+
127
+ // Check if the client has a socket and it's writable
128
+ try {
129
+ return (
130
+ this.__client.socket &&
131
+ this.__client.socket.writable !== false &&
132
+ this.__client.status === "online"
133
+ );
134
+ } catch (err) {
135
+ this.debug("Error checking client connection status:", err);
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * @description
142
+ * JSON schema defining the types this platform accepts.
143
+ *
144
+ * Actual handling of incoming 'set' commands are handled by dispatcher,
145
+ * but the dispatcher uses this defined schema to validate credentials
146
+ * received, so that when a @context type is called, it can fetch the
147
+ * credentials (`session.getConfig()`), knowing they will have already been
148
+ * validated against this schema.
149
+ *
150
+ *
151
+ * In the below example, Sockethub will validate the incoming credentials object
152
+ * against whatever is defined in the `credentials` portion of the schema
153
+ * object.
154
+ *
155
+ *
156
+ * It will also check if the incoming AS object uses a type which exists in the
157
+ * `types` portion of the schema object (should be an array of type names).
158
+ *
159
+ * **NOTE**: For more information on using the credentials object from a client,
160
+ * see [Sockethub Client](https://github.com/sockethub/sockethub/wiki/Sockethub-Client)
161
+ *
162
+ * Valid AS object for setting XMPP credentials:
163
+ *
164
+ * @example
165
+ *
166
+ * {
167
+ * type: 'credentials',
168
+ * context: 'xmpp',
169
+ * actor: {
170
+ * id: 'testuser@jabber.net',
171
+ * type: 'person',
172
+ * name: 'Mr. Test User'
173
+ * },
174
+ * object: {
175
+ * type: 'credentials',
176
+ * userAddress: 'testuser@jabber.net',
177
+ * password: 'asdasdasdasd',
178
+ * resource: 'phone'
179
+ * }
180
+ * }
181
+ **/
182
+ get schema() {
183
+ return PlatformSchema;
184
+ }
185
+
186
+ /**
187
+ * Connect to the XMPP server.
188
+ *
189
+ * @param {object} job activity streams object
190
+ * @param {object} credentials credentials object
191
+ * @param {object} done callback when job is done
192
+ *
193
+ * @example
194
+ *
195
+ * {
196
+ * context: 'xmpp',
197
+ * type: 'connect',
198
+ * actor: {
199
+ * id: 'slvrbckt@jabber.net/Home',
200
+ * type: 'person',
201
+ * name: 'Nick Jennings',
202
+ * userName: 'slvrbckt'
203
+ * }
204
+ * }
205
+ */
206
+ connect(job, credentials, done) {
207
+ if (this.__isClientConnected()) {
208
+ this.debug(`client connection already exists for ${job.actor.id}`);
209
+ this.config.initialized = true;
210
+ return done();
211
+ }
212
+ this.debug(`connect() called for ${job.actor.id}`);
213
+
214
+ // Log credential processing
215
+ const xmppCreds = utils.buildXmppCredentials(credentials);
216
+ this.debug(
217
+ `building XMPP credentials for ${job.actor.id}:`,
218
+ JSON.stringify({
219
+ service: xmppCreds.service,
220
+ username: xmppCreds.username,
221
+ resource: xmppCreds.resource,
222
+ timeout: this.config.connectTimeoutMs,
223
+ }),
224
+ );
225
+
226
+ // Log before client creation
227
+ this.debug(`creating XMPP client for ${job.actor.id}`);
228
+
229
+ try {
230
+ this.__client = this.__clientConstructor({
231
+ ...xmppCreds,
232
+ ...{ timeout: this.config.connectTimeoutMs, tls: false },
233
+ });
234
+ this.debug(`XMPP client created successfully for ${job.actor.id}`);
235
+ } catch (err) {
236
+ this.debug(`XMPP client creation failed for ${job.actor.id}:`, err);
237
+ return done(`client creation failed: ${err.message}`);
238
+ }
239
+
240
+ this.__client.on("offline", () => {
241
+ this.debug(`offline event received for ${job.actor.id}`);
242
+ this.__markDisconnected();
243
+ });
244
+
245
+ this.__client.on("error", (err) => {
246
+ this.debug(
247
+ `network error event for ${job.actor.id}:${err.toString()}`,
248
+ );
249
+
250
+ const errorType = this.__classifyError(err);
251
+
252
+ const as = {
253
+ context: "xmpp",
254
+ type: "connect",
255
+ actor: { id: job.actor.id },
256
+ };
257
+
258
+ if (errorType === "RECOVERABLE") {
259
+ // Clean up state but allow reconnection
260
+ this.__markDisconnected(false);
261
+
262
+ as.error = `Connection lost: ${err.toString()}. Attempting automatic reconnection...`;
263
+ as.object = {
264
+ type: "connect",
265
+ status: "reconnecting",
266
+ condition: err.condition || "network",
267
+ };
268
+ } else {
269
+ // Clean up state and stop reconnection
270
+ this.__markDisconnected(true);
271
+
272
+ as.error = `Connection failed: ${err.toString()}. Manual reconnection required.`;
273
+ as.object = {
274
+ type: "connect",
275
+ status: "failed",
276
+ condition: err.condition || "protocol",
277
+ };
278
+ }
279
+ this.sendToClient(as);
280
+ });
281
+
282
+ this.__client.on("online", () => {
283
+ this.debug(`online event received for ${job.actor.id}`);
284
+ });
285
+
286
+ this.debug(`starting XMPP client connection for ${job.actor.id}`);
287
+ const startTime = Date.now();
288
+
289
+ this.__client
290
+ .start()
291
+ .then(() => {
292
+ // connected
293
+ const duration = Date.now() - startTime;
294
+ this.debug(
295
+ `connection successful for ${job.actor.id} after ${duration}ms`,
296
+ );
297
+ this.config.initialized = true;
298
+ this.__registerHandlers();
299
+ return done();
300
+ })
301
+ .catch((err) => {
302
+ const duration = Date.now() - startTime;
303
+ this.debug(
304
+ `connection failed for ${job.actor.id} after ${duration}ms:`,
305
+ {
306
+ error: err,
307
+ message: err?.message,
308
+ code: err?.code,
309
+ stack: err?.stack,
310
+ },
311
+ );
312
+ this.__client = undefined;
313
+ return done(
314
+ `connection failed: ${err?.message || err}. (service: ${xmppCreds.service})`,
315
+ );
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Join a room, optionally defining a display name for that room.
321
+ *
322
+ * @param {object} job activity streams object
323
+ * @param {object} done callback when job is done
324
+ *
325
+ * @example
326
+ *
327
+ * {
328
+ * context: 'xmpp',
329
+ * type: 'join',
330
+ * actor: {
331
+ * type: 'person',
332
+ * id: 'slvrbckt@jabber.net/Home',
333
+ * name: 'Mr. Pimp'
334
+ * },
335
+ * target: {
336
+ * type: 'room',
337
+ * id: 'PartyChatRoom@muc.jabber.net'
338
+ * }
339
+ * }
340
+ */
341
+ async join(job, done) {
342
+ this.debug(
343
+ `sending join from ${job.actor.id} to ` +
344
+ `${job.target.id}/${job.actor.name}`,
345
+ );
346
+ // TODO optional passwords not handled for now
347
+ // TODO investigate implementation reserved nickname discovery
348
+ const id = job.target.id.split("/")[0];
349
+
350
+ const presence = this.__xml(
351
+ "presence",
352
+ {
353
+ from: job.actor.id,
354
+ to: `${job.target.id}/${job.actor.name || id}`,
355
+ },
356
+ this.__xml("x", { xmlns: "http://jabber.org/protocol/muc" }),
357
+ );
358
+
359
+ return this.__client.send(presence).then(done).catch(done);
360
+ }
361
+
362
+ /**
363
+ * Leave a room
364
+ *
365
+ * @param {object} job activity streams object
366
+ * @param {object} done callback when job is done
367
+ *
368
+ * @example
369
+ *
370
+ * {
371
+ * context: 'xmpp',
372
+ * type: 'leave',
373
+ * actor: {
374
+ * type: 'person',
375
+ * id: 'slvrbckt@jabber.net/Home',
376
+ * name: 'slvrbckt'
377
+ * },
378
+ * target: {
379
+ * type: 'room'
380
+ * id: 'PartyChatRoom@muc.jabber.net',
381
+ * }
382
+ * }
383
+ */
384
+ leave(job, done) {
385
+ this.debug(
386
+ `sending leave from ${job.actor.id} to ` +
387
+ `${job.target.id}/${job.actor.name}`,
388
+ );
389
+
390
+ const id = job.target.id.split("/")[0];
391
+
392
+ this.__client
393
+ .send(
394
+ this.__xml("presence", {
395
+ from: job.actor.id,
396
+ to:
397
+ job.target?.id && job.actor?.name
398
+ ? `${job.target.id}/${job.actor.name}`
399
+ : id,
400
+ type: "unavailable",
401
+ }),
402
+ )
403
+ .then(done);
404
+ }
405
+
406
+ /**
407
+ * Send a message to a room or private conversation.
408
+ *
409
+ * @param {object} job activity streams object
410
+ * @param {object} done callback when job is done
411
+ *
412
+ * @example
413
+ *
414
+ * {
415
+ * context: 'xmpp',
416
+ * type: 'send',
417
+ * actor: {
418
+ * id: 'slvrbckt@jabber.net/Home',
419
+ * type: 'person',
420
+ * name: 'Nick Jennings',
421
+ * userName: 'slvrbckt'
422
+ * },
423
+ * target: {
424
+ * id: 'homer@jabber.net/Home',
425
+ * type: 'user',
426
+ * name: 'Homer'
427
+ * },
428
+ * object: {
429
+ * type: 'message',
430
+ * content: 'Hello from Sockethub!'
431
+ * }
432
+ * }
433
+ *
434
+ * {
435
+ * context: 'xmpp',
436
+ * type: 'send',
437
+ * actor: {
438
+ * id: 'slvrbckt@jabber.net/Home',
439
+ * type: 'person',
440
+ * name: 'Nick Jennings',
441
+ * userName: 'slvrbckt'
442
+ * },
443
+ * target: {
444
+ * id: 'party-room@jabber.net',
445
+ * type: 'room'
446
+ * },
447
+ * object: {
448
+ * type: 'message',
449
+ * content: 'Hello from Sockethub!'
450
+ * }
451
+ * }
452
+ *
453
+ */
454
+ send(job, done) {
455
+ this.debug(`send() called for ${job.actor.id}`);
456
+ // send message
457
+ const message = this.__xml(
458
+ "message",
459
+ {
460
+ type: job.target.type === "room" ? "groupchat" : "chat",
461
+ to: job.target.id,
462
+ id: job.object.id,
463
+ },
464
+ this.__xml("body", {}, job.object.content),
465
+ job.object["xmpp:replace"]
466
+ ? this.__xml("replace", {
467
+ id: job.object["xmpp:replace"].id,
468
+ xmlns: "urn:xmpp:message-correct:0",
469
+ })
470
+ : undefined,
471
+ );
472
+ this.__client.send(message).then(done);
473
+ }
474
+
475
+ /**
476
+ * @description
477
+ * Indicate presence and status message.
478
+ * Valid presence values are "away", "chat", "dnd", "xa", "offline", "online".
479
+ *
480
+ * @param {object} job activity streams object
481
+ * @param {object} done callback when job is done
482
+ *
483
+ * @example
484
+ *
485
+ * {
486
+ * context: 'xmpp',
487
+ * type: 'update',
488
+ * actor: {
489
+ * id: 'user@host.org/Home'
490
+ * },
491
+ * object: {
492
+ * type: 'presence'
493
+ * presence: 'away',
494
+ * content: '...clever saying goes here...'
495
+ * }
496
+ * }
497
+ */
498
+ update(job, done) {
499
+ this.debug(`update() called for ${job.actor.id}`);
500
+ const props = {};
501
+ const show = {};
502
+ const status = {};
503
+ if (job.object.type === "presence") {
504
+ if (job.object.presence === "offline") {
505
+ props.type = "unavailable";
506
+ } else if (job.object.presence !== "online") {
507
+ show.show = job.object.presence;
508
+ }
509
+ if (job.object.content) {
510
+ status.status = job.object.content;
511
+ }
512
+ // setting presence
513
+ this.debug(`setting presence: ${job.object.presence}`);
514
+ this.__client
515
+ .send(this.__xml("presence", props, show, status))
516
+ .then(done);
517
+ } else {
518
+ done(`unknown update object type: ${job.object.type}`);
519
+ }
520
+ }
521
+
522
+ /**
523
+ * @description
524
+ * Send friend request
525
+ *
526
+ * @param {object} job activity streams object
527
+ * @param {object} done callback when job is done
528
+ *
529
+ * @example
530
+ *
531
+ * {
532
+ * context: 'xmpp',
533
+ * type: 'request-friend',
534
+ * actor: {
535
+ * id: 'user@host.org/Home'
536
+ * },
537
+ * target: {
538
+ * id: 'homer@jabber.net/Home',
539
+ * }
540
+ * }
541
+ */
542
+ "request-friend"(job, done) {
543
+ this.debug(`request-friend() called for ${job.actor.id}`);
544
+ this.__client
545
+ .send(
546
+ this.__xml("presence", {
547
+ type: "subscribe",
548
+ to: job.target.id,
549
+ }),
550
+ )
551
+ .then(done);
552
+ }
553
+
554
+ /**
555
+ * @description
556
+ * Send a remove friend request
557
+ *
558
+ * @param {object} job activity streams object
559
+ * @param {object} done callback when job is done
560
+ *
561
+ * @example
562
+ *
563
+ * {
564
+ * context: 'xmpp',
565
+ * type: 'remove-friend',
566
+ * actor: {
567
+ * id: 'user@host.org/Home'
568
+ * },
569
+ * target: {
570
+ * id: 'homer@jabber.net/Home',
571
+ * }
572
+ * }
573
+ */
574
+ "remove-friend"(job, done) {
575
+ this.debug(`remove-friend() called for ${job.actor.id}`);
576
+ this.__client
577
+ .send(
578
+ this.__xml("presence", {
579
+ type: "unsubscribe",
580
+ to: job.target.id,
581
+ }),
582
+ )
583
+ .then(done);
584
+ }
585
+
586
+ /**
587
+ * @description
588
+ * Confirm a friend request
589
+ *
590
+ * @param {object} job activity streams object
591
+ * @param {object} done callback when job is done
592
+ *
593
+ * @example
594
+ *
595
+ * {
596
+ * context: 'xmpp',
597
+ * type: 'make-friend',
598
+ * actor: {
599
+ * id: 'user@host.org/Home'
600
+ * },
601
+ * target: {
602
+ * id: 'homer@jabber.net/Home',
603
+ * }
604
+ * }
605
+ */
606
+ "make-friend"(job, done) {
607
+ this.debug(`make-friend() called for ${job.actor.id}`);
608
+ this.__client
609
+ .send(
610
+ this.__xml("presence", {
611
+ type: "subscribe",
612
+ to: job.target.id,
613
+ }),
614
+ )
615
+ .then(done);
616
+ }
617
+
618
+ /**
619
+ * Indicate an intent to query something (i.e. get a list of users in a room).
620
+ *
621
+ * @param {object} job activity streams object
622
+ * @param {object} done callback when job is done
623
+ *
624
+ * @example
625
+ *
626
+ * {
627
+ * context: 'xmpp',
628
+ * type: 'query',
629
+ * actor: {
630
+ * id: 'slvrbckt@jabber.net/Home',
631
+ * type: 'person'
632
+ * },
633
+ * target: {
634
+ * id: 'PartyChatRoom@muc.jabber.net',
635
+ * type: 'room'
636
+ * },
637
+ * object: {
638
+ * type: 'attendance'
639
+ * }
640
+ * }
641
+ *
642
+ * // The above object might return:
643
+ * {
644
+ * context: 'xmpp',
645
+ * type: 'query',
646
+ * actor: {
647
+ * id: 'PartyChatRoom@muc.jabber.net',
648
+ * type: 'room'
649
+ * },
650
+ * target: {
651
+ * id: 'slvrbckt@jabber.net/Home',
652
+ * type: 'person'
653
+ * },
654
+ * object: {
655
+ * type: 'attendance'
656
+ * members: [
657
+ * 'RyanGosling',
658
+ * 'PeeWeeHerman',
659
+ * 'Commando',
660
+ * 'Smoochie',
661
+ * 'neo'
662
+ * ]
663
+ * }
664
+ * }
665
+ */
666
+ query(job, done) {
667
+ this.debug(`sending query from ${job.actor.id} for ${job.target.id}`);
668
+ this.__client
669
+ .send(
670
+ this.__xml(
671
+ "iq",
672
+ {
673
+ id: "muc_id",
674
+ type: "get",
675
+ from: job.actor.id,
676
+ to: job.target.id,
677
+ },
678
+ this.__xml("query", {
679
+ xmlns: "http://jabber.org/protocol/disco#items",
680
+ }),
681
+ ),
682
+ )
683
+ .then(done);
684
+ }
685
+
686
+ /**
687
+ * Disconnect XMPP client
688
+ * @param {object} job activity streams object
689
+ * @param done
690
+ *
691
+ * @example
692
+ *
693
+ * {
694
+ * context: 'xmpp',
695
+ * type: 'disconnect',
696
+ * actor: {
697
+ * id: 'slvrbckt@jabber.net/Home',
698
+ * type: 'person'
699
+ * }
700
+ * }
701
+ */
702
+ disconnect(job, done) {
703
+ this.debug("disconnecting");
704
+ this.cleanup(done);
705
+ }
706
+
707
+ /**
708
+ * Called when it's time to close any connections or clean data before being wiped
709
+ * forcefully.
710
+ * @param {function} done - callback when complete
711
+ */
712
+ cleanup(done) {
713
+ this.debug("cleanup");
714
+ this.config.initialized = false;
715
+ this.__client.stop();
716
+ done();
717
+ }
718
+
719
+ __registerHandlers() {
720
+ const ih = new IncomingHandlers(this);
721
+ this.__client.on("close", ih.close.bind(ih));
722
+ this.__client.on("error", ih.error.bind(ih));
723
+ this.__client.on("online", ih.online.bind(ih));
724
+ this.__client.on("stanza", ih.stanza.bind(ih));
725
+ }
726
+ }