@sockethub/platform-xmpp 5.0.0-alpha.11 → 5.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.js DELETED
@@ -1,774 +0,0 @@
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
- requireCredentials: ["connect"],
44
- };
45
- this.__initialized = false; // Private state for initialization tracking
46
- this.log = session.log;
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.log.debug(`marking client as disconnected for ${this.id}`);
65
-
66
- if (stopReconnection && this.__client) {
67
- this.log.debug(`stopping automatic reconnection for ${this.id}`);
68
- this.__client.stop();
69
- }
70
-
71
- this.__client = undefined;
72
- this.__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.log.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
- * Returns whether the platform is ready to handle jobs.
188
- * For XMPP, this means we have successfully connected to the server.
189
- * During temporary network interruptions with automatic reconnection,
190
- * remains true to allow queued jobs to retry rather than fail.
191
- * @returns {boolean} true if ready to handle jobs
192
- */
193
- isInitialized() {
194
- return this.__initialized;
195
- }
196
-
197
- /**
198
- * Connect to the XMPP server.
199
- *
200
- * @param {object} job activity streams object
201
- * @param {object} credentials credentials object
202
- * @param {object} done callback when job is done
203
- *
204
- * @example
205
- *
206
- * {
207
- * context: 'xmpp',
208
- * type: 'connect',
209
- * actor: {
210
- * id: 'slvrbckt@jabber.net/Home',
211
- * type: 'person',
212
- * name: 'Nick Jennings',
213
- * userName: 'slvrbckt'
214
- * }
215
- * }
216
- */
217
- connect(job, credentials, done) {
218
- if (this.__isClientConnected()) {
219
- this.log.debug(
220
- `client connection already exists for ${job.actor.id}`,
221
- );
222
- this.__initialized = true;
223
- return done();
224
- }
225
- this.log.debug(`connect() called for ${job.actor.id}`);
226
-
227
- // Log credential processing
228
- const xmppCreds = utils.buildXmppCredentials(credentials);
229
- this.log.debug(
230
- `building XMPP credentials for ${job.actor.id}:`,
231
- JSON.stringify({
232
- service: xmppCreds.service,
233
- username: xmppCreds.username,
234
- resource: xmppCreds.resource,
235
- timeout: this.config.connectTimeoutMs,
236
- }),
237
- );
238
-
239
- // Log before client creation
240
- this.log.debug(`creating XMPP client for ${job.actor.id}`);
241
-
242
- try {
243
- this.__client = this.__clientConstructor({
244
- ...xmppCreds,
245
- ...{ timeout: this.config.connectTimeoutMs, tls: false },
246
- });
247
- this.log.debug(
248
- `XMPP client created successfully for ${job.actor.id}`,
249
- );
250
- } catch (err) {
251
- this.log.debug(
252
- `XMPP client creation failed for ${job.actor.id}:`,
253
- err,
254
- );
255
- return done(`client creation failed: ${err.message}`);
256
- }
257
-
258
- this.__client.on("offline", () => {
259
- this.log.debug(`offline event received for ${job.actor.id}`);
260
- // If we were never initialized, mark as disconnected (connection failed)
261
- // If we were previously initialized, keep state (will auto-reconnect)
262
- // This preserves initialized state during brief network interruptions
263
- // while properly handling initial connection failures
264
- if (!this.__initialized) {
265
- this.log.debug(
266
- `offline during initial connection for ${job.actor.id}`,
267
- );
268
- this.__markDisconnected();
269
- } else {
270
- this.log.debug(
271
- `offline after successful connection for ${job.actor.id}, will auto-reconnect`,
272
- );
273
- }
274
- });
275
-
276
- this.__client.on("error", (err) => {
277
- // Internal code errors (TypeError, ReferenceError, etc.) indicate bugs
278
- // in our code. These should crash the platform process immediately
279
- // as we can't trust the state after such errors.
280
- if (
281
- err instanceof TypeError ||
282
- err instanceof ReferenceError ||
283
- err instanceof SyntaxError
284
- ) {
285
- this.log.error(
286
- `FATAL: Internal code error in XMPP platform: ${err.toString()}`,
287
- );
288
- this.log.error(err.stack);
289
- process.exit(1);
290
- }
291
-
292
- this.log.debug(
293
- `network error event for ${job.actor.id}:${err.toString()}`,
294
- );
295
-
296
- const errorType = this.__classifyError(err);
297
-
298
- const as = {
299
- context: "xmpp",
300
- type: "connect",
301
- actor: { id: job.actor.id },
302
- };
303
-
304
- if (errorType === "RECOVERABLE") {
305
- // For recoverable errors, keep initialized=true and let the client
306
- // auto-reconnect. Don't call stop() or clear the client reference.
307
- // This allows queued jobs to wait for reconnection instead of failing.
308
- as.error = `Connection lost: ${err.toString()}. Attempting automatic reconnection...`;
309
- as.object = {
310
- type: "connect",
311
- status: "reconnecting",
312
- condition: err.condition || "network",
313
- };
314
- } else {
315
- // On unrecoverable errors, mark as uninitialized and stop reconnection
316
- this.__markDisconnected(true);
317
-
318
- as.error = `Connection failed: ${err.toString()}. Manual reconnection required.`;
319
- as.object = {
320
- type: "connect",
321
- status: "failed",
322
- condition: err.condition || "protocol",
323
- };
324
- }
325
- this.sendToClient(as);
326
- });
327
-
328
- this.__client.on("online", () => {
329
- this.log.debug(`online event received for ${job.actor.id}`);
330
- });
331
-
332
- this.log.debug(`starting XMPP client connection for ${job.actor.id}`);
333
- const startTime = Date.now();
334
-
335
- this.__client
336
- .start()
337
- .then(() => {
338
- // connected
339
- const duration = Date.now() - startTime;
340
- this.log.debug(
341
- `connection successful for ${job.actor.id} after ${duration}ms`,
342
- );
343
- this.__initialized = true;
344
- this.__registerHandlers();
345
- return done();
346
- })
347
- .catch((err) => {
348
- const duration = Date.now() - startTime;
349
- this.log.debug(
350
- `connection failed for ${job.actor.id} after ${duration}ms:`,
351
- {
352
- error: err,
353
- message: err?.message,
354
- code: err?.code,
355
- stack: err?.stack,
356
- },
357
- );
358
- this.__client = undefined;
359
- return done(
360
- `connection failed: ${err?.message || err}. (service: ${xmppCreds.service})`,
361
- );
362
- });
363
- }
364
-
365
- /**
366
- * Join a room, optionally defining a display name for that room.
367
- *
368
- * @param {object} job activity streams object
369
- * @param {object} done callback when job is done
370
- *
371
- * @example
372
- *
373
- * {
374
- * context: 'xmpp',
375
- * type: 'join',
376
- * actor: {
377
- * type: 'person',
378
- * id: 'slvrbckt@jabber.net/Home',
379
- * name: 'Mr. Pimp'
380
- * },
381
- * target: {
382
- * type: 'room',
383
- * id: 'PartyChatRoom@muc.jabber.net'
384
- * }
385
- * }
386
- */
387
- async join(job, done) {
388
- this.log.debug(
389
- `sending join from ${job.actor.id} to ` +
390
- `${job.target.id}/${job.actor.name}`,
391
- );
392
- // TODO optional passwords not handled for now
393
- // TODO investigate implementation reserved nickname discovery
394
- const id = job.target.id.split("/")[0];
395
-
396
- const presence = this.__xml(
397
- "presence",
398
- {
399
- from: job.actor.id,
400
- to: `${job.target.id}/${job.actor.name || id}`,
401
- },
402
- this.__xml("x", { xmlns: "http://jabber.org/protocol/muc" }),
403
- );
404
-
405
- return this.__client.send(presence).then(done).catch(done);
406
- }
407
-
408
- /**
409
- * Leave a room
410
- *
411
- * @param {object} job activity streams object
412
- * @param {object} done callback when job is done
413
- *
414
- * @example
415
- *
416
- * {
417
- * context: 'xmpp',
418
- * type: 'leave',
419
- * actor: {
420
- * type: 'person',
421
- * id: 'slvrbckt@jabber.net/Home',
422
- * name: 'slvrbckt'
423
- * },
424
- * target: {
425
- * type: 'room'
426
- * id: 'PartyChatRoom@muc.jabber.net',
427
- * }
428
- * }
429
- */
430
- leave(job, done) {
431
- this.log.debug(
432
- `sending leave from ${job.actor.id} to ` +
433
- `${job.target.id}/${job.actor.name}`,
434
- );
435
-
436
- const id = job.target.id.split("/")[0];
437
-
438
- this.__client
439
- .send(
440
- this.__xml("presence", {
441
- from: job.actor.id,
442
- to:
443
- job.target?.id && job.actor?.name
444
- ? `${job.target.id}/${job.actor.name}`
445
- : id,
446
- type: "unavailable",
447
- }),
448
- )
449
- .then(done);
450
- }
451
-
452
- /**
453
- * Send a message to a room or private conversation.
454
- *
455
- * @param {object} job activity streams object
456
- * @param {object} done callback when job is done
457
- *
458
- * @example
459
- *
460
- * {
461
- * context: 'xmpp',
462
- * type: 'send',
463
- * actor: {
464
- * id: 'slvrbckt@jabber.net/Home',
465
- * type: 'person',
466
- * name: 'Nick Jennings',
467
- * userName: 'slvrbckt'
468
- * },
469
- * target: {
470
- * id: 'homer@jabber.net/Home',
471
- * type: 'user',
472
- * name: 'Homer'
473
- * },
474
- * object: {
475
- * type: 'message',
476
- * content: 'Hello from Sockethub!'
477
- * }
478
- * }
479
- *
480
- * {
481
- * context: 'xmpp',
482
- * type: 'send',
483
- * actor: {
484
- * id: 'slvrbckt@jabber.net/Home',
485
- * type: 'person',
486
- * name: 'Nick Jennings',
487
- * userName: 'slvrbckt'
488
- * },
489
- * target: {
490
- * id: 'party-room@jabber.net',
491
- * type: 'room'
492
- * },
493
- * object: {
494
- * type: 'message',
495
- * content: 'Hello from Sockethub!'
496
- * }
497
- * }
498
- *
499
- */
500
- send(job, done) {
501
- this.log.debug(`send() called for ${job.actor.id}`);
502
- // send message
503
- const message = this.__xml(
504
- "message",
505
- {
506
- type: job.target.type === "room" ? "groupchat" : "chat",
507
- to: job.target.id,
508
- id: job.object.id,
509
- },
510
- this.__xml("body", {}, job.object.content),
511
- job.object["xmpp:replace"]
512
- ? this.__xml("replace", {
513
- id: job.object["xmpp:replace"].id,
514
- xmlns: "urn:xmpp:message-correct:0",
515
- })
516
- : undefined,
517
- );
518
- this.__client.send(message).then(done);
519
- }
520
-
521
- /**
522
- * @description
523
- * Indicate presence and status message.
524
- * Valid presence values are "away", "chat", "dnd", "xa", "offline", "online".
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: 'update',
534
- * actor: {
535
- * id: 'user@host.org/Home'
536
- * },
537
- * object: {
538
- * type: 'presence'
539
- * presence: 'away',
540
- * content: '...clever saying goes here...'
541
- * }
542
- * }
543
- */
544
- update(job, done) {
545
- this.log.debug(`update() called for ${job.actor.id}`);
546
- const props = {};
547
- const show = {};
548
- const status = {};
549
- if (job.object.type === "presence") {
550
- if (job.object.presence === "offline") {
551
- props.type = "unavailable";
552
- } else if (job.object.presence !== "online") {
553
- show.show = job.object.presence;
554
- }
555
- if (job.object.content) {
556
- status.status = job.object.content;
557
- }
558
- // setting presence
559
- this.log.debug(`setting presence: ${job.object.presence}`);
560
- this.__client
561
- .send(this.__xml("presence", props, show, status))
562
- .then(done);
563
- } else {
564
- done(`unknown update object type: ${job.object.type}`);
565
- }
566
- }
567
-
568
- /**
569
- * @description
570
- * Send friend request
571
- *
572
- * @param {object} job activity streams object
573
- * @param {object} done callback when job is done
574
- *
575
- * @example
576
- *
577
- * {
578
- * context: 'xmpp',
579
- * type: 'request-friend',
580
- * actor: {
581
- * id: 'user@host.org/Home'
582
- * },
583
- * target: {
584
- * id: 'homer@jabber.net/Home',
585
- * }
586
- * }
587
- */
588
- "request-friend"(job, done) {
589
- this.log.debug(`request-friend() called for ${job.actor.id}`);
590
- this.__client
591
- .send(
592
- this.__xml("presence", {
593
- type: "subscribe",
594
- to: job.target.id,
595
- }),
596
- )
597
- .then(done);
598
- }
599
-
600
- /**
601
- * @description
602
- * Send a remove friend request
603
- *
604
- * @param {object} job activity streams object
605
- * @param {object} done callback when job is done
606
- *
607
- * @example
608
- *
609
- * {
610
- * context: 'xmpp',
611
- * type: 'remove-friend',
612
- * actor: {
613
- * id: 'user@host.org/Home'
614
- * },
615
- * target: {
616
- * id: 'homer@jabber.net/Home',
617
- * }
618
- * }
619
- */
620
- "remove-friend"(job, done) {
621
- this.log.debug(`remove-friend() called for ${job.actor.id}`);
622
- this.__client
623
- .send(
624
- this.__xml("presence", {
625
- type: "unsubscribe",
626
- to: job.target.id,
627
- }),
628
- )
629
- .then(done);
630
- }
631
-
632
- /**
633
- * @description
634
- * Confirm a friend request
635
- *
636
- * @param {object} job activity streams object
637
- * @param {object} done callback when job is done
638
- *
639
- * @example
640
- *
641
- * {
642
- * context: 'xmpp',
643
- * type: 'make-friend',
644
- * actor: {
645
- * id: 'user@host.org/Home'
646
- * },
647
- * target: {
648
- * id: 'homer@jabber.net/Home',
649
- * }
650
- * }
651
- */
652
- "make-friend"(job, done) {
653
- this.log.debug(`make-friend() called for ${job.actor.id}`);
654
- this.__client
655
- .send(
656
- this.__xml("presence", {
657
- type: "subscribe",
658
- to: job.target.id,
659
- }),
660
- )
661
- .then(done);
662
- }
663
-
664
- /**
665
- * Indicate an intent to query something (i.e. get a list of users in a room).
666
- *
667
- * @param {object} job activity streams object
668
- * @param {object} done callback when job is done
669
- *
670
- * @example
671
- *
672
- * {
673
- * context: 'xmpp',
674
- * type: 'query',
675
- * actor: {
676
- * id: 'slvrbckt@jabber.net/Home',
677
- * type: 'person'
678
- * },
679
- * target: {
680
- * id: 'PartyChatRoom@muc.jabber.net',
681
- * type: 'room'
682
- * },
683
- * object: {
684
- * type: 'attendance'
685
- * }
686
- * }
687
- *
688
- * // The above object might return:
689
- * {
690
- * context: 'xmpp',
691
- * type: 'query',
692
- * actor: {
693
- * id: 'PartyChatRoom@muc.jabber.net',
694
- * type: 'room'
695
- * },
696
- * target: {
697
- * id: 'slvrbckt@jabber.net/Home',
698
- * type: 'person'
699
- * },
700
- * object: {
701
- * type: 'attendance'
702
- * members: [
703
- * 'RyanGosling',
704
- * 'PeeWeeHerman',
705
- * 'Commando',
706
- * 'Smoochie',
707
- * 'neo'
708
- * ]
709
- * }
710
- * }
711
- */
712
- query(job, done) {
713
- this.log.debug(
714
- `sending query from ${job.actor.id} for ${job.target.id}`,
715
- );
716
- this.__client
717
- .send(
718
- this.__xml(
719
- "iq",
720
- {
721
- id: "muc_id",
722
- type: "get",
723
- from: job.actor.id,
724
- to: job.target.id,
725
- },
726
- this.__xml("query", {
727
- xmlns: "http://jabber.org/protocol/disco#items",
728
- }),
729
- ),
730
- )
731
- .then(done);
732
- }
733
-
734
- /**
735
- * Disconnect XMPP client
736
- * @param {object} job activity streams object
737
- * @param done
738
- *
739
- * @example
740
- *
741
- * {
742
- * context: 'xmpp',
743
- * type: 'disconnect',
744
- * actor: {
745
- * id: 'slvrbckt@jabber.net/Home',
746
- * type: 'person'
747
- * }
748
- * }
749
- */
750
- disconnect(job, done) {
751
- this.log.debug("disconnecting");
752
- this.cleanup(done);
753
- }
754
-
755
- /**
756
- * Called when it's time to close any connections or clean data before being wiped
757
- * forcefully.
758
- * @param {function} done - callback when complete
759
- */
760
- cleanup(done) {
761
- this.log.debug("cleanup");
762
- this.__initialized = false;
763
- this.__client.stop();
764
- done();
765
- }
766
-
767
- __registerHandlers() {
768
- const ih = new IncomingHandlers(this);
769
- this.__client.on("close", ih.close.bind(ih));
770
- this.__client.on("error", ih.error.bind(ih));
771
- this.__client.on("online", ih.online.bind(ih));
772
- this.__client.on("stanza", ih.stanza.bind(ih));
773
- }
774
- }