@mentra/sdk 1.1.19
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/README.md +102 -0
- package/dist/constants/index.d.ts +14 -0
- package/dist/constants/index.d.ts.map +1 -0
- package/dist/constants/index.js +16 -0
- package/dist/examples/rtmp-streaming-example.d.ts +2 -0
- package/dist/examples/rtmp-streaming-example.d.ts.map +1 -0
- package/dist/examples/rtmp-streaming-example.js +102 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/logging/logger.d.ts +3 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +79 -0
- package/dist/tpa/index.d.ts +6 -0
- package/dist/tpa/index.d.ts.map +1 -0
- package/dist/tpa/index.js +24 -0
- package/dist/tpa/server/index.d.ts +193 -0
- package/dist/tpa/server/index.d.ts.map +1 -0
- package/dist/tpa/server/index.js +436 -0
- package/dist/tpa/session/api-client.d.ts +49 -0
- package/dist/tpa/session/api-client.d.ts.map +1 -0
- package/dist/tpa/session/api-client.js +101 -0
- package/dist/tpa/session/dashboard.d.ts +52 -0
- package/dist/tpa/session/dashboard.d.ts.map +1 -0
- package/dist/tpa/session/dashboard.js +149 -0
- package/dist/tpa/session/events.d.ts +178 -0
- package/dist/tpa/session/events.d.ts.map +1 -0
- package/dist/tpa/session/events.js +294 -0
- package/dist/tpa/session/index.d.ts +391 -0
- package/dist/tpa/session/index.d.ts.map +1 -0
- package/dist/tpa/session/index.js +1452 -0
- package/dist/tpa/session/layouts.d.ts +150 -0
- package/dist/tpa/session/layouts.d.ts.map +1 -0
- package/dist/tpa/session/layouts.js +282 -0
- package/dist/tpa/session/modules/streaming.d.ts +100 -0
- package/dist/tpa/session/modules/streaming.d.ts.map +1 -0
- package/dist/tpa/session/modules/streaming.js +270 -0
- package/dist/tpa/session/settings.d.ts +202 -0
- package/dist/tpa/session/settings.d.ts.map +1 -0
- package/dist/tpa/session/settings.js +361 -0
- package/dist/tpa/token/index.d.ts +7 -0
- package/dist/tpa/token/index.d.ts.map +1 -0
- package/dist/tpa/token/index.js +22 -0
- package/dist/tpa/token/utils.d.ts +69 -0
- package/dist/tpa/token/utils.d.ts.map +1 -0
- package/dist/tpa/token/utils.js +144 -0
- package/dist/tpa/webview/index.d.ts +47 -0
- package/dist/tpa/webview/index.d.ts.map +1 -0
- package/dist/tpa/webview/index.js +344 -0
- package/dist/types/dashboard/index.d.ts +128 -0
- package/dist/types/dashboard/index.d.ts.map +1 -0
- package/dist/types/dashboard/index.js +12 -0
- package/dist/types/enums.d.ts +57 -0
- package/dist/types/enums.d.ts.map +1 -0
- package/dist/types/enums.js +72 -0
- package/dist/types/index.d.ts +38 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +87 -0
- package/dist/types/layouts.d.ts +51 -0
- package/dist/types/layouts.d.ts.map +1 -0
- package/dist/types/layouts.js +3 -0
- package/dist/types/message-types.d.ts +109 -0
- package/dist/types/message-types.d.ts.map +1 -0
- package/dist/types/message-types.js +189 -0
- package/dist/types/messages/base.d.ts +12 -0
- package/dist/types/messages/base.d.ts.map +1 -0
- package/dist/types/messages/base.js +3 -0
- package/dist/types/messages/cloud-to-glasses.d.ts +126 -0
- package/dist/types/messages/cloud-to-glasses.d.ts.map +1 -0
- package/dist/types/messages/cloud-to-glasses.js +60 -0
- package/dist/types/messages/cloud-to-tpa.d.ts +228 -0
- package/dist/types/messages/cloud-to-tpa.d.ts.map +1 -0
- package/dist/types/messages/cloud-to-tpa.js +61 -0
- package/dist/types/messages/glasses-to-cloud.d.ts +219 -0
- package/dist/types/messages/glasses-to-cloud.d.ts.map +1 -0
- package/dist/types/messages/glasses-to-cloud.js +88 -0
- package/dist/types/messages/tpa-to-cloud.d.ts +146 -0
- package/dist/types/messages/tpa-to-cloud.d.ts.map +1 -0
- package/dist/types/messages/tpa-to-cloud.js +67 -0
- package/dist/types/models.d.ts +165 -0
- package/dist/types/models.d.ts.map +1 -0
- package/dist/types/models.js +84 -0
- package/dist/types/rtmp-stream.d.ts +68 -0
- package/dist/types/rtmp-stream.d.ts.map +1 -0
- package/dist/types/rtmp-stream.js +3 -0
- package/dist/types/streams.d.ts +138 -0
- package/dist/types/streams.d.ts.map +1 -0
- package/dist/types/streams.js +251 -0
- package/dist/types/token.d.ts +41 -0
- package/dist/types/token.d.ts.map +1 -0
- package/dist/types/token.js +7 -0
- package/dist/types/user-session.d.ts +73 -0
- package/dist/types/user-session.d.ts.map +1 -0
- package/dist/types/user-session.js +17 -0
- package/dist/types/webhooks.d.ts +107 -0
- package/dist/types/webhooks.d.ts.map +1 -0
- package/dist/types/webhooks.js +55 -0
- package/dist/utils/resource-tracker.d.ts +94 -0
- package/dist/utils/resource-tracker.d.ts.map +1 -0
- package/dist/utils/resource-tracker.js +153 -0
- package/package.json +50 -0
@@ -0,0 +1,1452 @@
|
|
1
|
+
"use strict";
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
4
|
+
};
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
6
|
+
exports.TpaSession = void 0;
|
7
|
+
/**
|
8
|
+
* 🎯 TPA Session Module
|
9
|
+
*
|
10
|
+
* Manages an active Third Party App session with AugmentOS Cloud.
|
11
|
+
* Handles real-time communication, event subscriptions, and display management.
|
12
|
+
*/
|
13
|
+
const ws_1 = require("ws");
|
14
|
+
const events_1 = require("./events");
|
15
|
+
const layouts_1 = require("./layouts");
|
16
|
+
const settings_1 = require("./settings");
|
17
|
+
const streaming_1 = require("./modules/streaming");
|
18
|
+
const resource_tracker_1 = require("../../utils/resource-tracker");
|
19
|
+
const types_1 = require("../../types");
|
20
|
+
const axios_1 = __importDefault(require("axios"));
|
21
|
+
const events_2 = __importDefault(require("events"));
|
22
|
+
// Import the cloud-to-tpa specific type guards
|
23
|
+
const cloud_to_tpa_1 = require("../../types/messages/cloud-to-tpa");
|
24
|
+
// List of event types that should never be subscribed to as streams
|
25
|
+
const TPA_TO_TPA_EVENT_TYPES = [
|
26
|
+
'tpa_message_received',
|
27
|
+
'tpa_user_joined',
|
28
|
+
'tpa_user_left',
|
29
|
+
'tpa_room_updated',
|
30
|
+
'tpa_direct_message_response'
|
31
|
+
];
|
32
|
+
/**
|
33
|
+
* 🚀 TPA Session Implementation
|
34
|
+
*
|
35
|
+
* Manages a live connection between your TPA and AugmentOS Cloud.
|
36
|
+
* Provides interfaces for:
|
37
|
+
* - 🎮 Event handling (transcription, head position, etc.)
|
38
|
+
* - 📱 Display management in AR view
|
39
|
+
* - 🔌 Connection lifecycle
|
40
|
+
* - 🔄 Automatic reconnection
|
41
|
+
*
|
42
|
+
* @example
|
43
|
+
* ```typescript
|
44
|
+
* const session = new TpaSession({
|
45
|
+
* packageName: 'org.example.myapp',
|
46
|
+
* apiKey: 'your_api_key'
|
47
|
+
* });
|
48
|
+
*
|
49
|
+
* // Handle events
|
50
|
+
* session.onTranscription((data) => {
|
51
|
+
* session.layouts.showTextWall(data.text);
|
52
|
+
* });
|
53
|
+
*
|
54
|
+
* // Connect to cloud
|
55
|
+
* await session.connect('session_123');
|
56
|
+
* ```
|
57
|
+
*/
|
58
|
+
class TpaSession {
|
59
|
+
constructor(config) {
|
60
|
+
this.config = config;
|
61
|
+
/** WebSocket connection to AugmentOS Cloud */
|
62
|
+
this.ws = null;
|
63
|
+
/** Current session identifier */
|
64
|
+
this.sessionId = null;
|
65
|
+
/** Number of reconnection attempts made */
|
66
|
+
this.reconnectAttempts = 0;
|
67
|
+
/** Active event subscriptions */
|
68
|
+
this.subscriptions = new Set();
|
69
|
+
/** Resource tracker for automatic cleanup */
|
70
|
+
this.resources = new resource_tracker_1.ResourceTracker();
|
71
|
+
/** Internal settings storage - use public settings API instead */
|
72
|
+
this.settingsData = [];
|
73
|
+
/** TPA configuration loaded from tpa_config.json */
|
74
|
+
this.tpaConfig = null;
|
75
|
+
/** Whether to update subscriptions when settings change */
|
76
|
+
this.shouldUpdateSubscriptionsOnSettingsChange = false;
|
77
|
+
/** Settings that should trigger subscription updates when changed */
|
78
|
+
this.subscriptionUpdateTriggers = [];
|
79
|
+
/** Pending photo requests waiting for responses */
|
80
|
+
this.pendingPhotoRequests = new Map();
|
81
|
+
/** Pending user discovery requests waiting for responses */
|
82
|
+
this.pendingUserDiscoveryRequests = new Map();
|
83
|
+
/** Pending direct message requests waiting for responses */
|
84
|
+
this.pendingDirectMessages = new Map();
|
85
|
+
/** Dedicated emitter for TPA-to-TPA events */
|
86
|
+
this.tpaEvents = new events_2.default();
|
87
|
+
// Set defaults and merge with provided config
|
88
|
+
this.config = {
|
89
|
+
augmentOSWebsocketUrl: `ws://localhost:8002/tpa-ws`, // Use localhost as default
|
90
|
+
autoReconnect: true, // Enable auto-reconnection by default for better reliability
|
91
|
+
maxReconnectAttempts: 3, // Default to 3 reconnection attempts for better resilience
|
92
|
+
reconnectDelay: 1000, // Start with 1 second delay (uses exponential backoff)
|
93
|
+
...config
|
94
|
+
};
|
95
|
+
this.tpaServer = this.config.tpaServer;
|
96
|
+
this.logger = this.tpaServer.logger.child({ userId: this.config.userId, service: 'tpa-session' });
|
97
|
+
this.userId = this.config.userId;
|
98
|
+
// Make sure the URL is correctly formatted to prevent double protocol issues
|
99
|
+
if (this.config.augmentOSWebsocketUrl) {
|
100
|
+
try {
|
101
|
+
const url = new URL(this.config.augmentOSWebsocketUrl);
|
102
|
+
if (!['ws:', 'wss:'].includes(url.protocol)) {
|
103
|
+
// Fix URLs with incorrect protocol (e.g., 'ws://http://host')
|
104
|
+
const fixedUrl = this.config.augmentOSWebsocketUrl.replace(/^ws:\/\/http:\/\//, 'ws://');
|
105
|
+
this.config.augmentOSWebsocketUrl = fixedUrl;
|
106
|
+
this.logger.warn(`⚠️ [${this.config.packageName}] Fixed malformed WebSocket URL: ${fixedUrl}`);
|
107
|
+
}
|
108
|
+
}
|
109
|
+
catch (error) {
|
110
|
+
this.logger.error(error, `⚠️ [${this.config.packageName}] Invalid WebSocket URL format: ${this.config.augmentOSWebsocketUrl}`);
|
111
|
+
}
|
112
|
+
}
|
113
|
+
// Log initialization
|
114
|
+
this.logger.debug(`🚀 [${this.config.packageName}] TPA Session initialized`);
|
115
|
+
this.logger.debug(`🚀 [${this.config.packageName}] WebSocket URL: ${this.config.augmentOSWebsocketUrl}`);
|
116
|
+
// Validate URL format - give early warning for obvious issues
|
117
|
+
// Check URL format but handle undefined case
|
118
|
+
if (this.config.augmentOSWebsocketUrl) {
|
119
|
+
try {
|
120
|
+
const url = new URL(this.config.augmentOSWebsocketUrl);
|
121
|
+
if (!['ws:', 'wss:'].includes(url.protocol)) {
|
122
|
+
this.logger.error({ config: this.config }, `⚠️ [${this.config.packageName}] Invalid WebSocket URL protocol: ${url.protocol}. Should be ws: or wss:`);
|
123
|
+
}
|
124
|
+
}
|
125
|
+
catch (error) {
|
126
|
+
this.logger.error(error, `⚠️ [${this.config.packageName}] Invalid WebSocket URL format: ${this.config.augmentOSWebsocketUrl}`);
|
127
|
+
}
|
128
|
+
}
|
129
|
+
this.events = new events_1.EventManager(this.subscribe.bind(this), this.unsubscribe.bind(this));
|
130
|
+
this.layouts = new layouts_1.LayoutManager(config.packageName, this.send.bind(this));
|
131
|
+
// Initialize settings manager with all necessary parameters, including subscribeFn for AugmentOS settings
|
132
|
+
this.settings = new settings_1.SettingsManager(this.settingsData, this.config.packageName, this.config.augmentOSWebsocketUrl, this.sessionId ?? undefined, async (streams) => {
|
133
|
+
this.logger.debug(`[TpaSession] subscribeFn called for streams:`, streams);
|
134
|
+
streams.forEach((stream) => {
|
135
|
+
if (!this.subscriptions.has(stream)) {
|
136
|
+
this.subscriptions.add(stream);
|
137
|
+
this.logger.debug(`[TpaSession] Auto-subscribed to stream '${stream}' for AugmentOS setting.`);
|
138
|
+
}
|
139
|
+
else {
|
140
|
+
this.logger.debug(`[TpaSession] Already subscribed to stream '${stream}'.`);
|
141
|
+
}
|
142
|
+
});
|
143
|
+
this.logger.debug(`[TpaSession] Current subscriptions after subscribeFn:`, Array.from(this.subscriptions));
|
144
|
+
if (this.ws?.readyState === 1) {
|
145
|
+
this.updateSubscriptions();
|
146
|
+
this.logger.debug(`[TpaSession] Sent updated subscriptions to cloud after auto-subscribing to AugmentOS setting.`);
|
147
|
+
}
|
148
|
+
else {
|
149
|
+
this.logger.debug(`[TpaSession] WebSocket not open, will send subscriptions when connected.`);
|
150
|
+
}
|
151
|
+
});
|
152
|
+
// Initialize dashboard API with this session instance
|
153
|
+
// Import DashboardManager dynamically to avoid circular dependency
|
154
|
+
const { DashboardManager } = require('./dashboard');
|
155
|
+
this.dashboard = new DashboardManager(this, this.send.bind(this));
|
156
|
+
// Initialize streaming module with session reference
|
157
|
+
this.streaming = new streaming_1.StreamingModule(this.config.packageName, this.sessionId || 'unknown-session-id', this.send.bind(this), this // Pass session reference
|
158
|
+
);
|
159
|
+
}
|
160
|
+
/**
|
161
|
+
* Get the current session ID
|
162
|
+
* @returns The current session ID or 'unknown-session-id' if not connected
|
163
|
+
*/
|
164
|
+
getSessionId() {
|
165
|
+
return this.sessionId || 'unknown-session-id';
|
166
|
+
}
|
167
|
+
/**
|
168
|
+
* Get the package name for this TPA
|
169
|
+
* @returns The package name
|
170
|
+
*/
|
171
|
+
getPackageName() {
|
172
|
+
return this.config.packageName;
|
173
|
+
}
|
174
|
+
// =====================================
|
175
|
+
// 🎮 Direct Event Handling Interface
|
176
|
+
// =====================================
|
177
|
+
/**
|
178
|
+
* 🎤 Listen for speech transcription events
|
179
|
+
* @param handler - Function to handle transcription data
|
180
|
+
* @returns Cleanup function to remove the handler
|
181
|
+
*/
|
182
|
+
onTranscription(handler) {
|
183
|
+
return this.events.onTranscription(handler);
|
184
|
+
}
|
185
|
+
/**
|
186
|
+
* 🌐 Listen for speech transcription events in a specific language
|
187
|
+
* @param language - Language code (e.g., "en-US")
|
188
|
+
* @param handler - Function to handle transcription data
|
189
|
+
* @returns Cleanup function to remove the handler
|
190
|
+
* @throws Error if language code is invalid
|
191
|
+
*/
|
192
|
+
onTranscriptionForLanguage(language, handler) {
|
193
|
+
return this.events.onTranscriptionForLanguage(language, handler);
|
194
|
+
}
|
195
|
+
/**
|
196
|
+
* 🌐 Listen for speech translation events for a specific language pair
|
197
|
+
* @param sourceLanguage - Source language code (e.g., "es-ES")
|
198
|
+
* @param targetLanguage - Target language code (e.g., "en-US")
|
199
|
+
* @param handler - Function to handle translation data
|
200
|
+
* @returns Cleanup function to remove the handler
|
201
|
+
* @throws Error if language codes are invalid
|
202
|
+
*/
|
203
|
+
onTranslationForLanguage(sourceLanguage, targetLanguage, handler) {
|
204
|
+
return this.events.ontranslationForLanguage(sourceLanguage, targetLanguage, handler);
|
205
|
+
}
|
206
|
+
/**
|
207
|
+
* 👤 Listen for head position changes
|
208
|
+
* @param handler - Function to handle head position updates
|
209
|
+
* @returns Cleanup function to remove the handler
|
210
|
+
*/
|
211
|
+
onHeadPosition(handler) {
|
212
|
+
return this.events.onHeadPosition(handler);
|
213
|
+
}
|
214
|
+
/**
|
215
|
+
* 🔘 Listen for hardware button press events
|
216
|
+
* @param handler - Function to handle button events
|
217
|
+
* @returns Cleanup function to remove the handler
|
218
|
+
*/
|
219
|
+
onButtonPress(handler) {
|
220
|
+
return this.events.onButtonPress(handler);
|
221
|
+
}
|
222
|
+
/**
|
223
|
+
* 📱 Listen for phone notification events
|
224
|
+
* @param handler - Function to handle notifications
|
225
|
+
* @returns Cleanup function to remove the handler
|
226
|
+
*/
|
227
|
+
onPhoneNotifications(handler) {
|
228
|
+
return this.events.onPhoneNotifications(handler);
|
229
|
+
}
|
230
|
+
/**
|
231
|
+
* 📡 Listen for VPS coordinates updates
|
232
|
+
* @param handler - Function to handle VPS coordinates
|
233
|
+
* @returns Cleanup function to remove the handler
|
234
|
+
*/
|
235
|
+
onVpsCoordinates(handler) {
|
236
|
+
this.subscribe(types_1.StreamType.VPS_COORDINATES);
|
237
|
+
return this.events.onVpsCoordinates(handler);
|
238
|
+
}
|
239
|
+
/**
|
240
|
+
* 📸 Listen for photo responses
|
241
|
+
* @param handler - Function to handle photo response data
|
242
|
+
* @returns Cleanup function to remove the handler
|
243
|
+
*/
|
244
|
+
onPhotoTaken(handler) {
|
245
|
+
this.subscribe(types_1.StreamType.PHOTO_TAKEN);
|
246
|
+
return this.events.onPhotoTaken(handler);
|
247
|
+
}
|
248
|
+
// =====================================
|
249
|
+
// 📡 Pub/Sub Interface
|
250
|
+
// =====================================
|
251
|
+
/**
|
252
|
+
* 📬 Subscribe to a specific event stream
|
253
|
+
* @param type - Type of event to subscribe to
|
254
|
+
*/
|
255
|
+
subscribe(type) {
|
256
|
+
if (TPA_TO_TPA_EVENT_TYPES.includes(type)) {
|
257
|
+
this.logger.warn(`[TpaSession] Attempted to subscribe to TPA-to-TPA event type '${type}', which is not a valid stream. Use the event handler (e.g., onTpaMessage) instead.`);
|
258
|
+
return;
|
259
|
+
}
|
260
|
+
this.subscriptions.add(type);
|
261
|
+
if (this.ws?.readyState === 1) {
|
262
|
+
this.updateSubscriptions();
|
263
|
+
}
|
264
|
+
}
|
265
|
+
/**
|
266
|
+
* 📭 Unsubscribe from a specific event stream
|
267
|
+
* @param type - Type of event to unsubscribe from
|
268
|
+
*/
|
269
|
+
unsubscribe(type) {
|
270
|
+
if (TPA_TO_TPA_EVENT_TYPES.includes(type)) {
|
271
|
+
this.logger.warn(`[TpaSession] Attempted to unsubscribe from TPA-to-TPA event type '${type}', which is not a valid stream.`);
|
272
|
+
return;
|
273
|
+
}
|
274
|
+
this.subscriptions.delete(type);
|
275
|
+
if (this.ws?.readyState === 1) {
|
276
|
+
this.updateSubscriptions();
|
277
|
+
}
|
278
|
+
}
|
279
|
+
/**
|
280
|
+
* 🎯 Generic event listener (pub/sub style)
|
281
|
+
* @param event - Event name to listen for
|
282
|
+
* @param handler - Event handler function
|
283
|
+
*/
|
284
|
+
on(event, handler) {
|
285
|
+
return this.events.on(event, handler);
|
286
|
+
}
|
287
|
+
// =====================================
|
288
|
+
// 🔌 Connection Management
|
289
|
+
// =====================================
|
290
|
+
/**
|
291
|
+
* 🚀 Connect to AugmentOS Cloud
|
292
|
+
* @param sessionId - Unique session identifier
|
293
|
+
* @returns Promise that resolves when connected
|
294
|
+
*/
|
295
|
+
async connect(sessionId) {
|
296
|
+
this.sessionId = sessionId;
|
297
|
+
// Configure settings API client with the WebSocket URL and session ID
|
298
|
+
// This allows settings to be fetched from the correct server
|
299
|
+
this.settings.configureApiClient(this.config.packageName, this.config.augmentOSWebsocketUrl || '', sessionId);
|
300
|
+
// Update the sessionId in the streaming module
|
301
|
+
if (this.streaming) {
|
302
|
+
Object.defineProperty(this.streaming, 'sessionId', { value: sessionId });
|
303
|
+
}
|
304
|
+
return new Promise((resolve, reject) => {
|
305
|
+
try {
|
306
|
+
// Clear previous resources if reconnecting
|
307
|
+
if (this.ws) {
|
308
|
+
// Don't call full dispose() as that would clear subscriptions
|
309
|
+
if (this.ws.readyState !== 3) { // 3 = CLOSED
|
310
|
+
this.ws.close();
|
311
|
+
}
|
312
|
+
this.ws = null;
|
313
|
+
}
|
314
|
+
// Validate WebSocket URL before attempting connection
|
315
|
+
if (!this.config.augmentOSWebsocketUrl) {
|
316
|
+
this.logger.error('WebSocket URL is missing or undefined');
|
317
|
+
reject(new Error('WebSocket URL is required'));
|
318
|
+
return;
|
319
|
+
}
|
320
|
+
// Add debug logging for connection attempts
|
321
|
+
this.logger.info(`🔌🔌🔌 [${this.config.packageName}] Attempting to connect to: ${this.config.augmentOSWebsocketUrl} for session ${this.sessionId}`);
|
322
|
+
// Create connection with error handling
|
323
|
+
this.ws = new ws_1.WebSocket(this.config.augmentOSWebsocketUrl);
|
324
|
+
// Track WebSocket for automatic cleanup
|
325
|
+
this.resources.track(() => {
|
326
|
+
if (this.ws && this.ws.readyState !== 3) { // 3 = CLOSED
|
327
|
+
this.ws.close();
|
328
|
+
}
|
329
|
+
});
|
330
|
+
this.ws.on('open', () => {
|
331
|
+
try {
|
332
|
+
this.sendConnectionInit();
|
333
|
+
}
|
334
|
+
catch (error) {
|
335
|
+
this.logger.error(error, 'Error during connection initialization');
|
336
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
337
|
+
this.events.emit('error', new Error(`Connection initialization failed: ${errorMessage}`));
|
338
|
+
reject(error);
|
339
|
+
}
|
340
|
+
});
|
341
|
+
// Message handler with comprehensive error recovery
|
342
|
+
const messageHandler = async (data, isBinary) => {
|
343
|
+
try {
|
344
|
+
// Handle binary messages (typically audio data)
|
345
|
+
if (isBinary && Buffer.isBuffer(data)) {
|
346
|
+
try {
|
347
|
+
// Validate buffer before processing
|
348
|
+
if (data.length === 0) {
|
349
|
+
this.events.emit('error', new Error('Received empty binary data'));
|
350
|
+
return;
|
351
|
+
}
|
352
|
+
// Convert Node.js Buffer to ArrayBuffer safely
|
353
|
+
const arrayBuf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
354
|
+
// Create AUDIO_CHUNK event message with validation
|
355
|
+
const audioChunk = {
|
356
|
+
type: types_1.StreamType.AUDIO_CHUNK,
|
357
|
+
arrayBuffer: arrayBuf,
|
358
|
+
timestamp: new Date() // Ensure timestamp is present
|
359
|
+
};
|
360
|
+
this.handleMessage(audioChunk);
|
361
|
+
return;
|
362
|
+
}
|
363
|
+
catch (error) {
|
364
|
+
this.logger.error(error, 'Error processing binary message:');
|
365
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
366
|
+
this.events.emit('error', new Error(`Failed to process binary message: ${errorMessage}`));
|
367
|
+
return;
|
368
|
+
}
|
369
|
+
}
|
370
|
+
// Handle ArrayBuffer data type directly
|
371
|
+
if (data instanceof ArrayBuffer) {
|
372
|
+
return;
|
373
|
+
}
|
374
|
+
// Handle JSON messages with validation
|
375
|
+
try {
|
376
|
+
// Convert string data to JSON safely
|
377
|
+
let jsonData;
|
378
|
+
if (typeof data === 'string') {
|
379
|
+
jsonData = data;
|
380
|
+
}
|
381
|
+
else if (Buffer.isBuffer(data)) {
|
382
|
+
jsonData = data.toString('utf8');
|
383
|
+
}
|
384
|
+
else {
|
385
|
+
throw new Error('Unknown message format');
|
386
|
+
}
|
387
|
+
// Validate JSON before parsing
|
388
|
+
if (!jsonData || jsonData.trim() === '') {
|
389
|
+
this.events.emit('error', new Error('Received empty JSON message'));
|
390
|
+
return;
|
391
|
+
}
|
392
|
+
// Parse JSON with error handling
|
393
|
+
const message = JSON.parse(jsonData);
|
394
|
+
// Basic schema validation
|
395
|
+
if (!message || typeof message !== 'object' || !('type' in message)) {
|
396
|
+
this.events.emit('error', new Error('Malformed message: missing type property'));
|
397
|
+
return;
|
398
|
+
}
|
399
|
+
// Process the validated message
|
400
|
+
this.handleMessage(message);
|
401
|
+
}
|
402
|
+
catch (error) {
|
403
|
+
this.logger.error(error, 'JSON parsing error');
|
404
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
405
|
+
this.events.emit('error', new Error(`Failed to parse JSON message: ${errorMessage}`));
|
406
|
+
}
|
407
|
+
}
|
408
|
+
catch (error) {
|
409
|
+
// Final catch - should never reach here if individual handlers work correctly
|
410
|
+
this.logger.error({ error }, 'Unhandled message processing error');
|
411
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
412
|
+
this.events.emit('error', new Error(`Unhandled message error: ${errorMessage}`));
|
413
|
+
}
|
414
|
+
};
|
415
|
+
this.ws.on('message', messageHandler);
|
416
|
+
// Track event handler removal for automatic cleanup
|
417
|
+
this.resources.track(() => {
|
418
|
+
if (this.ws) {
|
419
|
+
this.ws.off('message', messageHandler);
|
420
|
+
}
|
421
|
+
});
|
422
|
+
// Connection closure handler
|
423
|
+
const closeHandler = (code, reason) => {
|
424
|
+
const reasonStr = reason ? `: ${reason}` : '';
|
425
|
+
const closeInfo = `Connection closed (code: ${code})${reasonStr}`;
|
426
|
+
// Emit the disconnected event with structured data for better handling
|
427
|
+
this.events.emit('disconnected', {
|
428
|
+
message: closeInfo,
|
429
|
+
code: code,
|
430
|
+
reason: reason || '',
|
431
|
+
wasClean: code === 1000 || code === 1001,
|
432
|
+
});
|
433
|
+
// Only attempt reconnection for abnormal closures
|
434
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
|
435
|
+
// 1000 (Normal Closure) and 1001 (Going Away) are normal
|
436
|
+
// 1002-1015 are abnormal, and reason "App stopped" means intentional closure
|
437
|
+
// 1008 usually when the userSession no longer exists on server. i.e user disconnected from cloud.
|
438
|
+
const isNormalClosure = (code === 1000 || code === 1001 || code === 1008);
|
439
|
+
const isManualStop = reason && reason.includes('App stopped');
|
440
|
+
// Log closure details for diagnostics
|
441
|
+
this.logger.debug(`🔌 [${this.config.packageName}] WebSocket closed with code ${code}${reasonStr}`);
|
442
|
+
this.logger.debug(`🔌 [${this.config.packageName}] isNormalClosure: ${isNormalClosure}, isManualStop: ${isManualStop}`);
|
443
|
+
if (!isNormalClosure && !isManualStop) {
|
444
|
+
this.logger.warn(`🔌 [${this.config.packageName}] Abnormal closure detected, attempting reconnection`);
|
445
|
+
this.handleReconnection();
|
446
|
+
}
|
447
|
+
else {
|
448
|
+
this.logger.debug(`🔌 [${this.config.packageName}] Normal closure detected, not attempting reconnection`);
|
449
|
+
}
|
450
|
+
};
|
451
|
+
this.ws.on('close', closeHandler);
|
452
|
+
// Track event handler removal
|
453
|
+
this.resources.track(() => {
|
454
|
+
if (this.ws) {
|
455
|
+
this.ws.off('close', closeHandler);
|
456
|
+
}
|
457
|
+
});
|
458
|
+
// Connection error handler
|
459
|
+
const errorHandler = (error) => {
|
460
|
+
this.logger.error(error, 'WebSocket error');
|
461
|
+
this.events.emit('error', error);
|
462
|
+
};
|
463
|
+
// Enhanced error handler with detailed logging
|
464
|
+
this.ws.on('error', (error) => {
|
465
|
+
this.logger.error(error, `⛔️⛔️⛔️ [${this.config.packageName}] WebSocket connection error: ${error.message}`);
|
466
|
+
// Try to provide more context
|
467
|
+
const errMsg = error.message || '';
|
468
|
+
if (errMsg.includes('ECONNREFUSED')) {
|
469
|
+
this.logger.error(`⛔️⛔️⛔️ [${this.config.packageName}] Connection refused - Check if the server is running at the specified URL`);
|
470
|
+
}
|
471
|
+
else if (errMsg.includes('ETIMEDOUT')) {
|
472
|
+
this.logger.error(`⛔️⛔️⛔️ [${this.config.packageName}] Connection timed out - Check network connectivity and firewall rules`);
|
473
|
+
}
|
474
|
+
errorHandler(error);
|
475
|
+
});
|
476
|
+
// Track event handler removal
|
477
|
+
this.resources.track(() => {
|
478
|
+
if (this.ws) {
|
479
|
+
this.ws.off('error', errorHandler);
|
480
|
+
}
|
481
|
+
});
|
482
|
+
// Set up connection success handler
|
483
|
+
const connectedCleanup = this.events.onConnected(() => resolve());
|
484
|
+
// Track event handler removal
|
485
|
+
this.resources.track(connectedCleanup);
|
486
|
+
// Connection timeout with configurable duration
|
487
|
+
const timeoutMs = 5000; // 5 seconds default
|
488
|
+
const connectionTimeout = this.resources.setTimeout(() => {
|
489
|
+
// Use tracked timeout that will be auto-cleared
|
490
|
+
this.logger.error({
|
491
|
+
config: this.config,
|
492
|
+
sessionId: this.sessionId,
|
493
|
+
timeoutMs
|
494
|
+
}, `⏱️⏱️⏱️ [${this.config.packageName}] Connection timeout after ${timeoutMs}ms`);
|
495
|
+
this.events.emit('error', new Error(`Connection timeout after ${timeoutMs}ms`));
|
496
|
+
reject(new Error('Connection timeout'));
|
497
|
+
}, timeoutMs);
|
498
|
+
// Clear timeout on successful connection
|
499
|
+
const timeoutCleanup = this.events.onConnected(() => {
|
500
|
+
clearTimeout(connectionTimeout);
|
501
|
+
resolve();
|
502
|
+
});
|
503
|
+
// Track event handler removal
|
504
|
+
this.resources.track(timeoutCleanup);
|
505
|
+
}
|
506
|
+
catch (error) {
|
507
|
+
this.logger.error(error, 'Connection setup error');
|
508
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
509
|
+
reject(new Error(`Failed to setup connection: ${errorMessage}`));
|
510
|
+
}
|
511
|
+
});
|
512
|
+
}
|
513
|
+
/**
|
514
|
+
* 👋 Disconnect from AugmentOS Cloud
|
515
|
+
*/
|
516
|
+
disconnect() {
|
517
|
+
// Use the resource tracker to clean up everything
|
518
|
+
this.resources.dispose();
|
519
|
+
// Clean up additional resources not handled by the tracker
|
520
|
+
this.ws = null;
|
521
|
+
this.sessionId = null;
|
522
|
+
this.subscriptions.clear();
|
523
|
+
this.reconnectAttempts = 0;
|
524
|
+
}
|
525
|
+
/**
|
526
|
+
* 📸 Request a photo from the connected glasses
|
527
|
+
* @param options - Optional configuration for the photo request
|
528
|
+
* @returns Promise that resolves with the URL to the captured photo
|
529
|
+
*/
|
530
|
+
requestPhoto(options) {
|
531
|
+
return new Promise((resolve, reject) => {
|
532
|
+
try {
|
533
|
+
// Generate unique request ID
|
534
|
+
const requestId = `photo_req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
535
|
+
// Store promise resolvers for when we get the response
|
536
|
+
this.pendingPhotoRequests.set(requestId, { resolve, reject });
|
537
|
+
// Create photo request message
|
538
|
+
const message = {
|
539
|
+
type: types_1.TpaToCloudMessageType.PHOTO_REQUEST,
|
540
|
+
packageName: this.config.packageName,
|
541
|
+
sessionId: this.sessionId,
|
542
|
+
timestamp: new Date(),
|
543
|
+
saveToGallery: options?.saveToGallery || false
|
544
|
+
};
|
545
|
+
// Send request to cloud
|
546
|
+
this.send(message);
|
547
|
+
// Set timeout to avoid hanging promises
|
548
|
+
const timeoutMs = 30000; // 30 seconds
|
549
|
+
this.resources.setTimeout(() => {
|
550
|
+
if (this.pendingPhotoRequests.has(requestId)) {
|
551
|
+
this.pendingPhotoRequests.get(requestId).reject(new Error('Photo request timed out'));
|
552
|
+
this.pendingPhotoRequests.delete(requestId);
|
553
|
+
}
|
554
|
+
}, timeoutMs);
|
555
|
+
}
|
556
|
+
catch (error) {
|
557
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
558
|
+
reject(new Error(`Failed to request photo: ${errorMessage}`));
|
559
|
+
}
|
560
|
+
});
|
561
|
+
}
|
562
|
+
/**
|
563
|
+
* 🛠️ Get all current user settings
|
564
|
+
* @returns A copy of the current settings array
|
565
|
+
* @deprecated Use session.settings.getAll() instead
|
566
|
+
*/
|
567
|
+
getSettings() {
|
568
|
+
return this.settings.getAll();
|
569
|
+
}
|
570
|
+
/**
|
571
|
+
* 🔍 Get a specific setting value by key
|
572
|
+
* @param key The setting key to look for
|
573
|
+
* @returns The setting's value, or undefined if not found
|
574
|
+
* @deprecated Use session.settings.get(key) instead
|
575
|
+
*/
|
576
|
+
getSetting(key) {
|
577
|
+
return this.settings.get(key);
|
578
|
+
}
|
579
|
+
/**
|
580
|
+
* ⚙️ Configure settings-based subscription updates
|
581
|
+
* This allows TPAs to automatically update their subscriptions when certain settings change
|
582
|
+
* @param options Configuration options for settings-based subscriptions
|
583
|
+
*/
|
584
|
+
setSubscriptionSettings(options) {
|
585
|
+
this.shouldUpdateSubscriptionsOnSettingsChange = true;
|
586
|
+
this.subscriptionUpdateTriggers = options.updateOnChange;
|
587
|
+
this.subscriptionSettingsHandler = options.handler;
|
588
|
+
// If we already have settings, update subscriptions immediately
|
589
|
+
if (this.settingsData.length > 0) {
|
590
|
+
this.updateSubscriptionsFromSettings();
|
591
|
+
}
|
592
|
+
}
|
593
|
+
/**
|
594
|
+
* 🔄 Update subscriptions based on current settings
|
595
|
+
* Called automatically when relevant settings change
|
596
|
+
*/
|
597
|
+
updateSubscriptionsFromSettings() {
|
598
|
+
if (!this.subscriptionSettingsHandler)
|
599
|
+
return;
|
600
|
+
try {
|
601
|
+
// Get new subscriptions from handler
|
602
|
+
const newSubscriptions = this.subscriptionSettingsHandler(this.settingsData);
|
603
|
+
// Update all subscriptions at once
|
604
|
+
this.subscriptions.clear();
|
605
|
+
newSubscriptions.forEach(subscription => {
|
606
|
+
this.subscriptions.add(subscription);
|
607
|
+
});
|
608
|
+
// Send subscription update to cloud if connected
|
609
|
+
if (this.ws && this.ws.readyState === 1) {
|
610
|
+
this.updateSubscriptions();
|
611
|
+
}
|
612
|
+
}
|
613
|
+
catch (error) {
|
614
|
+
this.logger.error(error, 'Error updating subscriptions from settings');
|
615
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
616
|
+
this.events.emit('error', new Error(`Failed to update subscriptions: ${errorMessage}`));
|
617
|
+
}
|
618
|
+
}
|
619
|
+
/**
|
620
|
+
* 🧪 For testing: Update settings locally
|
621
|
+
* In normal operation, settings come from the cloud
|
622
|
+
* @param newSettings The new settings to apply
|
623
|
+
*/
|
624
|
+
updateSettingsForTesting(newSettings) {
|
625
|
+
this.settingsData = newSettings;
|
626
|
+
// Update the settings manager with the new settings
|
627
|
+
this.settings.updateSettings(newSettings);
|
628
|
+
// Emit update event for backwards compatibility
|
629
|
+
this.events.emit('settings_update', this.settingsData);
|
630
|
+
// Check if we should update subscriptions
|
631
|
+
if (this.shouldUpdateSubscriptionsOnSettingsChange) {
|
632
|
+
this.updateSubscriptionsFromSettings();
|
633
|
+
}
|
634
|
+
}
|
635
|
+
/**
|
636
|
+
* 📝 Load configuration from a JSON file
|
637
|
+
* @param jsonData JSON string containing TPA configuration
|
638
|
+
* @returns The loaded configuration
|
639
|
+
* @throws Error if the configuration is invalid
|
640
|
+
*/
|
641
|
+
loadConfigFromJson(jsonData) {
|
642
|
+
try {
|
643
|
+
const parsedConfig = JSON.parse(jsonData);
|
644
|
+
if ((0, types_1.validateTpaConfig)(parsedConfig)) {
|
645
|
+
this.tpaConfig = parsedConfig;
|
646
|
+
return parsedConfig;
|
647
|
+
}
|
648
|
+
else {
|
649
|
+
throw new Error('Invalid TPA configuration format');
|
650
|
+
}
|
651
|
+
}
|
652
|
+
catch (error) {
|
653
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
654
|
+
throw new Error(`Failed to load TPA configuration: ${errorMessage}`);
|
655
|
+
}
|
656
|
+
}
|
657
|
+
/**
|
658
|
+
* 📋 Get the loaded TPA configuration
|
659
|
+
* @returns The current TPA configuration or null if not loaded
|
660
|
+
*/
|
661
|
+
getConfig() {
|
662
|
+
return this.tpaConfig;
|
663
|
+
}
|
664
|
+
/**
|
665
|
+
* 🔌 Get the WebSocket server URL for this session
|
666
|
+
* @returns The WebSocket server URL used by this session
|
667
|
+
*/
|
668
|
+
getServerUrl() {
|
669
|
+
return this.config.augmentOSWebsocketUrl;
|
670
|
+
}
|
671
|
+
getHttpsServerUrl() {
|
672
|
+
if (!this.config.augmentOSWebsocketUrl) {
|
673
|
+
return undefined;
|
674
|
+
}
|
675
|
+
return TpaSession.convertToHttps(this.config.augmentOSWebsocketUrl);
|
676
|
+
}
|
677
|
+
static convertToHttps(rawUrl) {
|
678
|
+
if (!rawUrl)
|
679
|
+
return '';
|
680
|
+
// Remove ws:// or wss://
|
681
|
+
let url = rawUrl.replace(/^wss?:\/\//, '');
|
682
|
+
// Remove trailing /tpa-ws
|
683
|
+
url = url.replace(/\/tpa-ws$/, '');
|
684
|
+
// Prepend https://
|
685
|
+
return `https://${url}`;
|
686
|
+
}
|
687
|
+
/**
|
688
|
+
* 🔍 Get default settings from the TPA configuration
|
689
|
+
* @returns Array of settings with default values
|
690
|
+
* @throws Error if configuration is not loaded
|
691
|
+
*/
|
692
|
+
getDefaultSettings() {
|
693
|
+
if (!this.tpaConfig) {
|
694
|
+
throw new Error('TPA configuration not loaded. Call loadConfigFromJson first.');
|
695
|
+
}
|
696
|
+
return this.tpaConfig.settings
|
697
|
+
.filter((s) => s.type !== 'group')
|
698
|
+
.map((s) => ({
|
699
|
+
...s,
|
700
|
+
value: s.defaultValue // Set value to defaultValue
|
701
|
+
}));
|
702
|
+
}
|
703
|
+
/**
|
704
|
+
* 🔍 Get setting schema from configuration
|
705
|
+
* @param key Setting key to look up
|
706
|
+
* @returns The setting schema or undefined if not found
|
707
|
+
*/
|
708
|
+
getSettingSchema(key) {
|
709
|
+
if (!this.tpaConfig)
|
710
|
+
return undefined;
|
711
|
+
const setting = this.tpaConfig.settings.find((s) => s.type !== 'group' && 'key' in s && s.key === key);
|
712
|
+
return setting;
|
713
|
+
}
|
714
|
+
// =====================================
|
715
|
+
// 🔧 Private Methods
|
716
|
+
// =====================================
|
717
|
+
/**
|
718
|
+
* 📨 Handle incoming messages from cloud
|
719
|
+
*/
|
720
|
+
handleMessage(message) {
|
721
|
+
try {
|
722
|
+
// Validate message before processing
|
723
|
+
if (!this.validateMessage(message)) {
|
724
|
+
this.events.emit('error', new Error('Invalid message format received'));
|
725
|
+
return;
|
726
|
+
}
|
727
|
+
// Handle binary data (audio or video)
|
728
|
+
if (message instanceof ArrayBuffer) {
|
729
|
+
this.handleBinaryMessage(message);
|
730
|
+
return;
|
731
|
+
}
|
732
|
+
// Using type guards to determine message type and safely handle each case
|
733
|
+
try {
|
734
|
+
if ((0, types_1.isTpaConnectionAck)(message)) {
|
735
|
+
// Get settings from connection acknowledgment
|
736
|
+
const receivedSettings = message.settings || [];
|
737
|
+
this.settingsData = receivedSettings;
|
738
|
+
// Store config if provided
|
739
|
+
if (message.config && (0, types_1.validateTpaConfig)(message.config)) {
|
740
|
+
this.tpaConfig = message.config;
|
741
|
+
}
|
742
|
+
// Use default settings from config if no settings were provided
|
743
|
+
if (receivedSettings.length === 0 && this.tpaConfig) {
|
744
|
+
try {
|
745
|
+
this.settingsData = this.getDefaultSettings();
|
746
|
+
}
|
747
|
+
catch (error) {
|
748
|
+
this.logger.warn('Failed to load default settings from config:', error);
|
749
|
+
}
|
750
|
+
}
|
751
|
+
// Update the settings manager with the new settings
|
752
|
+
this.settings.updateSettings(this.settingsData);
|
753
|
+
// Handle AugmentOS system settings if provided
|
754
|
+
this.logger.debug(`[TpaSession] CONNECTION_ACK augmentosSettings:`, message.augmentosSettings);
|
755
|
+
if (message.augmentosSettings) {
|
756
|
+
this.logger.info(`[TpaSession] Calling updateAugmentosSettings with:`, message.augmentosSettings);
|
757
|
+
this.settings.updateAugmentosSettings(message.augmentosSettings);
|
758
|
+
}
|
759
|
+
else {
|
760
|
+
this.logger.warn(`[TpaSession] CONNECTION_ACK message missing augmentosSettings field`);
|
761
|
+
}
|
762
|
+
// Emit connected event with settings
|
763
|
+
this.events.emit('connected', this.settingsData);
|
764
|
+
// Update subscriptions (normal flow)
|
765
|
+
this.updateSubscriptions();
|
766
|
+
// If settings-based subscriptions are enabled, update those too
|
767
|
+
if (this.shouldUpdateSubscriptionsOnSettingsChange && this.settingsData.length > 0) {
|
768
|
+
this.updateSubscriptionsFromSettings();
|
769
|
+
}
|
770
|
+
}
|
771
|
+
else if ((0, types_1.isTpaConnectionError)(message) || message.type === 'connection_error') {
|
772
|
+
// Handle both TPA-specific connection_error and standard connection_error
|
773
|
+
const errorMessage = message.message || 'Unknown connection error';
|
774
|
+
this.events.emit('error', new Error(errorMessage));
|
775
|
+
}
|
776
|
+
else if (message.type === types_1.StreamType.AUDIO_CHUNK) {
|
777
|
+
if (this.subscriptions.has(types_1.StreamType.AUDIO_CHUNK)) {
|
778
|
+
// Only process if we're subscribed to avoid unnecessary processing
|
779
|
+
this.events.emit(types_1.StreamType.AUDIO_CHUNK, message);
|
780
|
+
}
|
781
|
+
}
|
782
|
+
else if ((0, types_1.isDataStream)(message)) {
|
783
|
+
// Ensure streamType exists before emitting the event
|
784
|
+
let messageStreamType = message.streamType;
|
785
|
+
if (message.streamType === types_1.StreamType.TRANSCRIPTION) {
|
786
|
+
const transcriptionData = message.data;
|
787
|
+
if (transcriptionData.transcribeLanguage) {
|
788
|
+
messageStreamType = (0, types_1.createTranscriptionStream)(transcriptionData.transcribeLanguage);
|
789
|
+
}
|
790
|
+
}
|
791
|
+
else if (message.streamType === types_1.StreamType.TRANSLATION) {
|
792
|
+
const translationData = message.data;
|
793
|
+
if (translationData.transcribeLanguage && translationData.translateLanguage) {
|
794
|
+
messageStreamType = (0, types_1.createTranslationStream)(translationData.transcribeLanguage, translationData.translateLanguage);
|
795
|
+
}
|
796
|
+
}
|
797
|
+
if (messageStreamType && this.subscriptions.has(messageStreamType)) {
|
798
|
+
const sanitizedData = this.sanitizeEventData(messageStreamType, message.data);
|
799
|
+
this.events.emit(messageStreamType, sanitizedData);
|
800
|
+
}
|
801
|
+
}
|
802
|
+
else if ((0, cloud_to_tpa_1.isPhotoResponse)(message)) {
|
803
|
+
// Handle photo response by resolving the pending promise
|
804
|
+
if (this.pendingPhotoRequests.has(message.requestId)) {
|
805
|
+
const { resolve } = this.pendingPhotoRequests.get(message.requestId);
|
806
|
+
resolve(message.photoUrl);
|
807
|
+
this.pendingPhotoRequests.delete(message.requestId);
|
808
|
+
}
|
809
|
+
}
|
810
|
+
else if ((0, cloud_to_tpa_1.isRtmpStreamStatus)(message)) {
|
811
|
+
// Emit as a standard stream event if subscribed
|
812
|
+
if (this.subscriptions.has(types_1.StreamType.RTMP_STREAM_STATUS)) {
|
813
|
+
this.events.emit(types_1.StreamType.RTMP_STREAM_STATUS, message);
|
814
|
+
}
|
815
|
+
// Update streaming module's internal state
|
816
|
+
this.streaming.updateStreamState(message);
|
817
|
+
}
|
818
|
+
else if ((0, types_1.isSettingsUpdate)(message)) {
|
819
|
+
// Store previous settings to check for changes
|
820
|
+
const prevSettings = [...this.settingsData];
|
821
|
+
// Update internal settings storage
|
822
|
+
this.settingsData = message.settings || [];
|
823
|
+
// Update the settings manager with the new settings
|
824
|
+
const changes = this.settings.updateSettings(this.settingsData);
|
825
|
+
// Emit settings update event (for backwards compatibility)
|
826
|
+
this.events.emit('settings_update', this.settingsData);
|
827
|
+
// --- AugmentOS settings update logic ---
|
828
|
+
// If the message.settings looks like AugmentOS settings (object with known keys), update augmentosSettings
|
829
|
+
if (message.settings && typeof message.settings === 'object') {
|
830
|
+
this.settings.updateAugmentosSettings(message.settings);
|
831
|
+
}
|
832
|
+
// Check if we should update subscriptions
|
833
|
+
if (this.shouldUpdateSubscriptionsOnSettingsChange) {
|
834
|
+
// Check if any subscription trigger settings changed
|
835
|
+
const shouldUpdateSubs = this.subscriptionUpdateTriggers.some(key => {
|
836
|
+
return key in changes;
|
837
|
+
});
|
838
|
+
if (shouldUpdateSubs) {
|
839
|
+
this.updateSubscriptionsFromSettings();
|
840
|
+
}
|
841
|
+
}
|
842
|
+
}
|
843
|
+
else if ((0, types_1.isAppStopped)(message)) {
|
844
|
+
const reason = message.reason || 'unknown';
|
845
|
+
const displayReason = `App stopped: ${reason}`;
|
846
|
+
// Emit disconnected event with clean closure info to prevent reconnection attempts
|
847
|
+
this.events.emit('disconnected', {
|
848
|
+
message: displayReason,
|
849
|
+
code: 1000, // Normal closure code
|
850
|
+
reason: displayReason,
|
851
|
+
wasClean: true,
|
852
|
+
});
|
853
|
+
// Clear reconnection state
|
854
|
+
this.reconnectAttempts = 0;
|
855
|
+
}
|
856
|
+
// Handle dashboard mode changes
|
857
|
+
else if ((0, types_1.isDashboardModeChanged)(message)) {
|
858
|
+
try {
|
859
|
+
// Use proper type
|
860
|
+
const mode = message.mode || 'none';
|
861
|
+
// Update dashboard state in the API
|
862
|
+
if (this.dashboard && 'content' in this.dashboard) {
|
863
|
+
this.dashboard.content.setCurrentMode(mode);
|
864
|
+
}
|
865
|
+
}
|
866
|
+
catch (error) {
|
867
|
+
this.logger.error(error, 'Error handling dashboard mode change');
|
868
|
+
}
|
869
|
+
}
|
870
|
+
// Handle always-on dashboard state changes
|
871
|
+
else if ((0, types_1.isDashboardAlwaysOnChanged)(message)) {
|
872
|
+
try {
|
873
|
+
// Use proper type
|
874
|
+
const enabled = !!message.enabled;
|
875
|
+
// Update dashboard state in the API
|
876
|
+
if (this.dashboard && 'content' in this.dashboard) {
|
877
|
+
this.dashboard.content.setAlwaysOnEnabled(enabled);
|
878
|
+
}
|
879
|
+
}
|
880
|
+
catch (error) {
|
881
|
+
this.logger.error(error, 'Error handling dashboard always-on change');
|
882
|
+
}
|
883
|
+
}
|
884
|
+
// Handle custom messages
|
885
|
+
else if (message.type === types_1.CloudToTpaMessageType.CUSTOM_MESSAGE) {
|
886
|
+
this.events.emit('custom_message', message);
|
887
|
+
return;
|
888
|
+
}
|
889
|
+
// Handle TPA-to-TPA communication messages
|
890
|
+
else if (message.type === 'tpa_message_received') {
|
891
|
+
this.tpaEvents.emit('tpa_message_received', message);
|
892
|
+
}
|
893
|
+
else if (message.type === 'tpa_user_joined') {
|
894
|
+
this.tpaEvents.emit('tpa_user_joined', message);
|
895
|
+
}
|
896
|
+
else if (message.type === 'tpa_user_left') {
|
897
|
+
this.tpaEvents.emit('tpa_user_left', message);
|
898
|
+
}
|
899
|
+
else if (message.type === 'tpa_room_updated') {
|
900
|
+
this.tpaEvents.emit('tpa_room_updated', message);
|
901
|
+
}
|
902
|
+
else if (message.type === 'tpa_direct_message_response') {
|
903
|
+
const response = message;
|
904
|
+
if (response.messageId && this.pendingDirectMessages.has(response.messageId)) {
|
905
|
+
const { resolve } = this.pendingDirectMessages.get(response.messageId);
|
906
|
+
resolve(response.success);
|
907
|
+
this.pendingDirectMessages.delete(response.messageId);
|
908
|
+
}
|
909
|
+
}
|
910
|
+
else if (message.type === 'augmentos_settings_update') {
|
911
|
+
const augmentosMsg = message;
|
912
|
+
if (augmentosMsg.settings && typeof augmentosMsg.settings === 'object') {
|
913
|
+
this.settings.updateAugmentosSettings(augmentosMsg.settings);
|
914
|
+
}
|
915
|
+
}
|
916
|
+
// Handle 'connection_error' as a specific case if cloud sends this string literal
|
917
|
+
else if (message.type === 'connection_error') {
|
918
|
+
// Treat 'connection_error' (string literal) like TpaConnectionError
|
919
|
+
// This handles cases where the cloud might send the type as a direct string
|
920
|
+
// instead of the enum's 'tpa_connection_error' value.
|
921
|
+
const errorMessage = message.message || 'Unknown connection error (type: connection_error)';
|
922
|
+
this.logger.warn(`Received 'connection_error' type directly. Consider aligning cloud to send 'tpa_connection_error'. Message: ${errorMessage}`);
|
923
|
+
this.events.emit('error', new Error(errorMessage));
|
924
|
+
}
|
925
|
+
else if (message.type === 'permission_error') {
|
926
|
+
// Handle permission errors from cloud
|
927
|
+
this.logger.warn('Permission error received:', {
|
928
|
+
message: message.message,
|
929
|
+
details: message.details,
|
930
|
+
detailsCount: message.details?.length || 0,
|
931
|
+
rejectedStreams: message.details?.map(d => d.stream) || []
|
932
|
+
});
|
933
|
+
// Emit permission error event for application handling
|
934
|
+
this.events.emit('permission_error', {
|
935
|
+
message: message.message,
|
936
|
+
details: message.details,
|
937
|
+
timestamp: message.timestamp
|
938
|
+
});
|
939
|
+
// Optionally emit individual permission denied events for each stream
|
940
|
+
message.details?.forEach(detail => {
|
941
|
+
this.events.emit('permission_denied', {
|
942
|
+
stream: detail.stream,
|
943
|
+
requiredPermission: detail.requiredPermission,
|
944
|
+
message: detail.message
|
945
|
+
});
|
946
|
+
});
|
947
|
+
}
|
948
|
+
// Handle unrecognized message types gracefully
|
949
|
+
else {
|
950
|
+
console.log(`Unrecognized message type: ${message.type}. Full message details:`, {
|
951
|
+
messageType: message.type,
|
952
|
+
fullMessage: message,
|
953
|
+
messageKeys: Object.keys(message || {}),
|
954
|
+
messageStringified: JSON.stringify(message, null, 2)
|
955
|
+
});
|
956
|
+
// Log all message object details for debugging
|
957
|
+
this.logger.warn(`Unrecognized message type: ${message.type}. Full message details:`, {
|
958
|
+
messageType: message.type,
|
959
|
+
fullMessage: message,
|
960
|
+
messageKeys: Object.keys(message || {}),
|
961
|
+
messageStringified: JSON.stringify(message, null, 2)
|
962
|
+
});
|
963
|
+
this.events.emit('error', new Error(`Unrecognized message type: ${message.type}`));
|
964
|
+
}
|
965
|
+
}
|
966
|
+
catch (processingError) {
|
967
|
+
// Catch any errors during message processing to prevent TPA crashes
|
968
|
+
this.logger.error(processingError, 'Error processing message:');
|
969
|
+
const errorMessage = processingError instanceof Error ? processingError.message : String(processingError);
|
970
|
+
this.events.emit('error', new Error(`Error processing message: ${errorMessage}`));
|
971
|
+
}
|
972
|
+
}
|
973
|
+
catch (error) {
|
974
|
+
// Final safety net to ensure the TPA doesn't crash on any unexpected errors
|
975
|
+
this.logger.error(error, 'Unexpected error in message handler');
|
976
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
977
|
+
this.events.emit('error', new Error(`Unexpected error in message handler: ${errorMessage}`));
|
978
|
+
}
|
979
|
+
}
|
980
|
+
/**
|
981
|
+
* 🧪 Validate incoming message structure
|
982
|
+
* @param message - Message to validate
|
983
|
+
* @returns boolean indicating if the message is valid
|
984
|
+
*/
|
985
|
+
validateMessage(message) {
|
986
|
+
// Handle ArrayBuffer case separately
|
987
|
+
if (message instanceof ArrayBuffer) {
|
988
|
+
return true; // ArrayBuffers are always considered valid at this level
|
989
|
+
}
|
990
|
+
// Check if message is null or undefined
|
991
|
+
if (!message) {
|
992
|
+
return false;
|
993
|
+
}
|
994
|
+
// Check if message has a type property
|
995
|
+
if (!('type' in message)) {
|
996
|
+
return false;
|
997
|
+
}
|
998
|
+
// All other message types should be objects with a type property
|
999
|
+
return true;
|
1000
|
+
}
|
1001
|
+
/**
|
1002
|
+
* 📦 Handle binary message data (audio or video)
|
1003
|
+
* @param buffer - Binary data as ArrayBuffer
|
1004
|
+
*/
|
1005
|
+
handleBinaryMessage(buffer) {
|
1006
|
+
try {
|
1007
|
+
// Safety check - only process if we're subscribed to avoid unnecessary work
|
1008
|
+
if (!this.subscriptions.has(types_1.StreamType.AUDIO_CHUNK)) {
|
1009
|
+
return;
|
1010
|
+
}
|
1011
|
+
// Validate buffer has content before processing
|
1012
|
+
if (!buffer || buffer.byteLength === 0) {
|
1013
|
+
this.events.emit('error', new Error('Received empty binary message'));
|
1014
|
+
return;
|
1015
|
+
}
|
1016
|
+
// Create a safety wrapped audio chunk with proper defaults
|
1017
|
+
const audioChunk = {
|
1018
|
+
type: types_1.StreamType.AUDIO_CHUNK,
|
1019
|
+
timestamp: new Date(),
|
1020
|
+
arrayBuffer: buffer,
|
1021
|
+
sampleRate: 16000 // Default sample rate
|
1022
|
+
};
|
1023
|
+
// Emit to subscribers
|
1024
|
+
this.events.emit(types_1.StreamType.AUDIO_CHUNK, audioChunk);
|
1025
|
+
}
|
1026
|
+
catch (error) {
|
1027
|
+
this.logger.error(error, 'Error processing binary message');
|
1028
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1029
|
+
this.events.emit('error', new Error(`Error processing binary message: ${errorMessage}`));
|
1030
|
+
}
|
1031
|
+
}
|
1032
|
+
/**
|
1033
|
+
* 🧹 Sanitize event data to prevent crashes from malformed data
|
1034
|
+
* @param streamType - The type of stream data
|
1035
|
+
* @param data - The potentially unsafe data to sanitize
|
1036
|
+
* @returns Sanitized data safe for processing
|
1037
|
+
*/
|
1038
|
+
sanitizeEventData(streamType, data) {
|
1039
|
+
try {
|
1040
|
+
// If data is null or undefined, return an empty object to prevent crashes
|
1041
|
+
if (data === null || data === undefined) {
|
1042
|
+
return {};
|
1043
|
+
}
|
1044
|
+
// For specific stream types, perform targeted sanitization
|
1045
|
+
switch (streamType) {
|
1046
|
+
case types_1.StreamType.TRANSCRIPTION:
|
1047
|
+
// Ensure text field exists and is a string
|
1048
|
+
if (typeof data.text !== 'string') {
|
1049
|
+
return {
|
1050
|
+
text: '',
|
1051
|
+
isFinal: true,
|
1052
|
+
startTime: Date.now(),
|
1053
|
+
endTime: Date.now()
|
1054
|
+
};
|
1055
|
+
}
|
1056
|
+
break;
|
1057
|
+
case types_1.StreamType.HEAD_POSITION:
|
1058
|
+
// Ensure position data has required numeric fields
|
1059
|
+
// Handle HeadPosition - Note the property position instead of x,y,z
|
1060
|
+
const pos = data;
|
1061
|
+
if (typeof pos?.position !== 'string') {
|
1062
|
+
return { position: 'up', timestamp: new Date() };
|
1063
|
+
}
|
1064
|
+
break;
|
1065
|
+
case types_1.StreamType.BUTTON_PRESS:
|
1066
|
+
// Ensure button type is valid
|
1067
|
+
const btn = data;
|
1068
|
+
if (!btn.buttonId || !btn.pressType) {
|
1069
|
+
return { buttonId: 'unknown', pressType: 'short', timestamp: new Date() };
|
1070
|
+
}
|
1071
|
+
break;
|
1072
|
+
}
|
1073
|
+
return data;
|
1074
|
+
}
|
1075
|
+
catch (error) {
|
1076
|
+
this.logger.error(error, `Error sanitizing ${streamType} data`);
|
1077
|
+
// Return a safe empty object if something goes wrong
|
1078
|
+
return {};
|
1079
|
+
}
|
1080
|
+
}
|
1081
|
+
/**
|
1082
|
+
* 🔐 Send connection initialization message
|
1083
|
+
*/
|
1084
|
+
sendConnectionInit() {
|
1085
|
+
const message = {
|
1086
|
+
type: types_1.TpaToCloudMessageType.CONNECTION_INIT,
|
1087
|
+
sessionId: this.sessionId,
|
1088
|
+
packageName: this.config.packageName,
|
1089
|
+
apiKey: this.config.apiKey,
|
1090
|
+
timestamp: new Date()
|
1091
|
+
};
|
1092
|
+
this.send(message);
|
1093
|
+
}
|
1094
|
+
/**
|
1095
|
+
* 📝 Update subscription list with cloud
|
1096
|
+
*/
|
1097
|
+
updateSubscriptions() {
|
1098
|
+
this.logger.info(`[TpaSession] updateSubscriptions: sending subscriptions to cloud:`, Array.from(this.subscriptions));
|
1099
|
+
const message = {
|
1100
|
+
type: types_1.TpaToCloudMessageType.SUBSCRIPTION_UPDATE,
|
1101
|
+
packageName: this.config.packageName,
|
1102
|
+
subscriptions: Array.from(this.subscriptions),
|
1103
|
+
sessionId: this.sessionId,
|
1104
|
+
timestamp: new Date()
|
1105
|
+
};
|
1106
|
+
this.send(message);
|
1107
|
+
}
|
1108
|
+
/**
|
1109
|
+
* 🔄 Handle reconnection with exponential backoff
|
1110
|
+
*/
|
1111
|
+
async handleReconnection() {
|
1112
|
+
// Check if reconnection is allowed
|
1113
|
+
if (!this.config.autoReconnect || !this.sessionId) {
|
1114
|
+
this.logger.debug(`🔄 Reconnection skipped: autoReconnect=${this.config.autoReconnect}, sessionId=${this.sessionId ? 'valid' : 'invalid'}`);
|
1115
|
+
return;
|
1116
|
+
}
|
1117
|
+
// Check if we've exceeded the maximum attempts
|
1118
|
+
const maxAttempts = this.config.maxReconnectAttempts || 3;
|
1119
|
+
if (this.reconnectAttempts >= maxAttempts) {
|
1120
|
+
this.logger.info(`🔄 Maximum reconnection attempts (${maxAttempts}) reached, giving up`);
|
1121
|
+
// Emit a permanent disconnection event to trigger onStop in the TPA server
|
1122
|
+
this.events.emit('disconnected', {
|
1123
|
+
message: `Connection permanently lost after ${maxAttempts} failed reconnection attempts`,
|
1124
|
+
code: 4000, // Custom code for max reconnection attempts exhausted
|
1125
|
+
reason: 'Maximum reconnection attempts exceeded',
|
1126
|
+
wasClean: false,
|
1127
|
+
permanent: true // Flag this as a permanent disconnection
|
1128
|
+
});
|
1129
|
+
return;
|
1130
|
+
}
|
1131
|
+
// Calculate delay with exponential backoff
|
1132
|
+
const baseDelay = this.config.reconnectDelay || 1000;
|
1133
|
+
const delay = baseDelay * Math.pow(2, this.reconnectAttempts);
|
1134
|
+
this.reconnectAttempts++;
|
1135
|
+
this.logger.debug(`🔄 [${this.config.packageName}] Reconnection attempt ${this.reconnectAttempts}/${maxAttempts} in ${delay}ms`);
|
1136
|
+
// Use the resource tracker for the timeout
|
1137
|
+
await new Promise(resolve => {
|
1138
|
+
this.resources.setTimeout(() => resolve(), delay);
|
1139
|
+
});
|
1140
|
+
try {
|
1141
|
+
this.logger.debug(`🔄 [${this.config.packageName}] Attempting to reconnect...`);
|
1142
|
+
await this.connect(this.sessionId);
|
1143
|
+
this.logger.debug(`✅ [${this.config.packageName}] Reconnection successful!`);
|
1144
|
+
this.reconnectAttempts = 0;
|
1145
|
+
}
|
1146
|
+
catch (error) {
|
1147
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1148
|
+
this.logger.error(error, `❌ [${this.config.packageName}] Reconnection failed for user ${this.userId}`);
|
1149
|
+
this.events.emit('error', new Error(`Reconnection failed: ${errorMessage}`));
|
1150
|
+
// Check if this was the last attempt
|
1151
|
+
if (this.reconnectAttempts >= maxAttempts) {
|
1152
|
+
this.logger.debug(`🔄 [${this.config.packageName}] Final reconnection attempt failed, emitting permanent disconnection`);
|
1153
|
+
// Emit permanent disconnection event after the last failed attempt
|
1154
|
+
this.events.emit('disconnected', {
|
1155
|
+
message: `Connection permanently lost after ${maxAttempts} failed reconnection attempts`,
|
1156
|
+
code: 4000, // Custom code for max reconnection attempts exhausted
|
1157
|
+
reason: 'Maximum reconnection attempts exceeded',
|
1158
|
+
wasClean: false,
|
1159
|
+
permanent: true // Flag this as a permanent disconnection
|
1160
|
+
});
|
1161
|
+
}
|
1162
|
+
}
|
1163
|
+
}
|
1164
|
+
/**
|
1165
|
+
* 📤 Send message to cloud with validation and error handling
|
1166
|
+
* @throws {Error} If WebSocket is not connected
|
1167
|
+
*/
|
1168
|
+
send(message) {
|
1169
|
+
try {
|
1170
|
+
// Verify WebSocket connection is valid
|
1171
|
+
if (!this.ws) {
|
1172
|
+
throw new Error('WebSocket connection not established');
|
1173
|
+
}
|
1174
|
+
if (this.ws.readyState !== 1) {
|
1175
|
+
const stateMap = {
|
1176
|
+
0: 'CONNECTING',
|
1177
|
+
1: 'OPEN',
|
1178
|
+
2: 'CLOSING',
|
1179
|
+
3: 'CLOSED'
|
1180
|
+
};
|
1181
|
+
const stateName = stateMap[this.ws.readyState] || 'UNKNOWN';
|
1182
|
+
throw new Error(`WebSocket not connected (current state: ${stateName})`);
|
1183
|
+
}
|
1184
|
+
// Validate message before sending
|
1185
|
+
if (!message || typeof message !== 'object') {
|
1186
|
+
throw new Error('Invalid message: must be an object');
|
1187
|
+
}
|
1188
|
+
if (!('type' in message)) {
|
1189
|
+
throw new Error('Invalid message: missing "type" property');
|
1190
|
+
}
|
1191
|
+
// Ensure message format is consistent
|
1192
|
+
if (!('timestamp' in message) || !(message.timestamp instanceof Date)) {
|
1193
|
+
message.timestamp = new Date();
|
1194
|
+
}
|
1195
|
+
// Try to send with error handling
|
1196
|
+
try {
|
1197
|
+
const serializedMessage = JSON.stringify(message);
|
1198
|
+
this.ws.send(serializedMessage);
|
1199
|
+
}
|
1200
|
+
catch (sendError) {
|
1201
|
+
const errorMessage = sendError instanceof Error ? sendError.message : String(sendError);
|
1202
|
+
throw new Error(`Failed to send message: ${errorMessage}`);
|
1203
|
+
}
|
1204
|
+
}
|
1205
|
+
catch (error) {
|
1206
|
+
// Log the error and emit an event so TPA developers are aware
|
1207
|
+
this.logger.error(error, 'Message send error');
|
1208
|
+
// Ensure we always emit an Error object
|
1209
|
+
if (error instanceof Error) {
|
1210
|
+
this.events.emit('error', error);
|
1211
|
+
}
|
1212
|
+
else {
|
1213
|
+
this.events.emit('error', new Error(String(error)));
|
1214
|
+
}
|
1215
|
+
// Re-throw to maintain the original function behavior
|
1216
|
+
throw error;
|
1217
|
+
}
|
1218
|
+
}
|
1219
|
+
/**
|
1220
|
+
* Fetch the onboarding instructions for this session from the backend.
|
1221
|
+
* @returns Promise resolving to the instructions string or null
|
1222
|
+
*/
|
1223
|
+
async getInstructions() {
|
1224
|
+
try {
|
1225
|
+
const baseUrl = this.getServerUrl();
|
1226
|
+
const response = await axios_1.default.get(`${baseUrl}/api/instructions`, { params: { userId: this.userId } });
|
1227
|
+
return response.data.instructions || null;
|
1228
|
+
}
|
1229
|
+
catch (err) {
|
1230
|
+
this.logger.error('Error fetching instructions from backend:', err);
|
1231
|
+
return null;
|
1232
|
+
}
|
1233
|
+
}
|
1234
|
+
// =====================================
|
1235
|
+
// 👥 TPA-to-TPA Communication Interface
|
1236
|
+
// =====================================
|
1237
|
+
/**
|
1238
|
+
* 👥 Discover other users currently using the same TPA
|
1239
|
+
* @param includeProfiles - Whether to include user profile information
|
1240
|
+
* @returns Promise that resolves with list of active users
|
1241
|
+
*/
|
1242
|
+
async discoverTpaUsers(domain, includeProfiles = false) {
|
1243
|
+
// Use the domain argument as the base URL if provided
|
1244
|
+
if (!domain) {
|
1245
|
+
throw new Error('Domain (API base URL) is required for user discovery');
|
1246
|
+
}
|
1247
|
+
const url = `${domain}/api/tpa-communication/discover-users`;
|
1248
|
+
// Use the user's core token for authentication
|
1249
|
+
const tpaApiKey = this.config.apiKey; // This may need to be updated if you store the core token elsewhere
|
1250
|
+
if (!tpaApiKey) {
|
1251
|
+
throw new Error('Core token (apiKey) is required for user discovery');
|
1252
|
+
}
|
1253
|
+
const body = {
|
1254
|
+
packageName: this.config.packageName,
|
1255
|
+
userId: this.userId,
|
1256
|
+
includeUserProfiles: includeProfiles
|
1257
|
+
};
|
1258
|
+
const response = await fetch(url, {
|
1259
|
+
method: 'POST',
|
1260
|
+
headers: {
|
1261
|
+
'Authorization': `Bearer ${tpaApiKey}`,
|
1262
|
+
'Content-Type': 'application/json'
|
1263
|
+
},
|
1264
|
+
body: JSON.stringify(body)
|
1265
|
+
});
|
1266
|
+
if (!response.ok) {
|
1267
|
+
const errorText = await response.text();
|
1268
|
+
throw new Error(`Failed to discover users: ${response.status} ${response.statusText} - ${errorText}`);
|
1269
|
+
}
|
1270
|
+
return await response.json();
|
1271
|
+
}
|
1272
|
+
/**
|
1273
|
+
* 🔍 Check if a specific user is currently active
|
1274
|
+
* @param userId - User ID to check for
|
1275
|
+
* @returns Promise that resolves with boolean indicating if user is active
|
1276
|
+
*/
|
1277
|
+
async isUserActive(userId) {
|
1278
|
+
try {
|
1279
|
+
const userList = await this.discoverTpaUsers('', false);
|
1280
|
+
return userList.users.some((user) => user.userId === userId);
|
1281
|
+
}
|
1282
|
+
catch (error) {
|
1283
|
+
this.logger.error({ error, userId }, 'Error checking if user is active');
|
1284
|
+
return false;
|
1285
|
+
}
|
1286
|
+
}
|
1287
|
+
/**
|
1288
|
+
* 📊 Get user count for this TPA
|
1289
|
+
* @returns Promise that resolves with number of active users
|
1290
|
+
*/
|
1291
|
+
async getUserCount(domain) {
|
1292
|
+
try {
|
1293
|
+
const userList = await this.discoverTpaUsers(domain, false);
|
1294
|
+
return userList.totalUsers;
|
1295
|
+
}
|
1296
|
+
catch (error) {
|
1297
|
+
this.logger.error(error, 'Error getting user count');
|
1298
|
+
return 0;
|
1299
|
+
}
|
1300
|
+
}
|
1301
|
+
/**
|
1302
|
+
* 📢 Send broadcast message to all users with same TPA active
|
1303
|
+
* @param payload - Message payload to send
|
1304
|
+
* @param roomId - Optional room ID for room-based messaging
|
1305
|
+
* @returns Promise that resolves when message is sent
|
1306
|
+
*/
|
1307
|
+
async broadcastToTpaUsers(payload, roomId) {
|
1308
|
+
try {
|
1309
|
+
const messageId = this.generateMessageId();
|
1310
|
+
const message = {
|
1311
|
+
type: 'tpa_broadcast_message',
|
1312
|
+
packageName: this.config.packageName,
|
1313
|
+
sessionId: this.sessionId,
|
1314
|
+
payload,
|
1315
|
+
messageId,
|
1316
|
+
senderUserId: this.userId,
|
1317
|
+
timestamp: new Date()
|
1318
|
+
};
|
1319
|
+
this.send(message);
|
1320
|
+
}
|
1321
|
+
catch (error) {
|
1322
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1323
|
+
throw new Error(`Failed to broadcast message: ${errorMessage}`);
|
1324
|
+
}
|
1325
|
+
}
|
1326
|
+
/**
|
1327
|
+
* 📤 Send direct message to specific user
|
1328
|
+
* @param targetUserId - User ID to send message to
|
1329
|
+
* @param payload - Message payload to send
|
1330
|
+
* @returns Promise that resolves with success status
|
1331
|
+
*/
|
1332
|
+
async sendDirectMessage(targetUserId, payload) {
|
1333
|
+
return new Promise((resolve, reject) => {
|
1334
|
+
try {
|
1335
|
+
const messageId = this.generateMessageId();
|
1336
|
+
// Store promise resolver
|
1337
|
+
this.pendingDirectMessages.set(messageId, { resolve, reject });
|
1338
|
+
const message = {
|
1339
|
+
type: 'tpa_direct_message',
|
1340
|
+
packageName: this.config.packageName,
|
1341
|
+
sessionId: this.sessionId,
|
1342
|
+
targetUserId,
|
1343
|
+
payload,
|
1344
|
+
messageId,
|
1345
|
+
senderUserId: this.userId,
|
1346
|
+
timestamp: new Date()
|
1347
|
+
};
|
1348
|
+
this.send(message);
|
1349
|
+
// Set timeout to avoid hanging promises
|
1350
|
+
const timeoutMs = 15000; // 15 seconds
|
1351
|
+
this.resources.setTimeout(() => {
|
1352
|
+
if (this.pendingDirectMessages.has(messageId)) {
|
1353
|
+
this.pendingDirectMessages.get(messageId).reject(new Error('Direct message timed out'));
|
1354
|
+
this.pendingDirectMessages.delete(messageId);
|
1355
|
+
}
|
1356
|
+
}, timeoutMs);
|
1357
|
+
}
|
1358
|
+
catch (error) {
|
1359
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1360
|
+
reject(new Error(`Failed to send direct message: ${errorMessage}`));
|
1361
|
+
}
|
1362
|
+
});
|
1363
|
+
}
|
1364
|
+
/**
|
1365
|
+
* 🏠 Join a communication room for group messaging
|
1366
|
+
* @param roomId - Room ID to join
|
1367
|
+
* @param roomConfig - Optional room configuration
|
1368
|
+
* @returns Promise that resolves when room is joined
|
1369
|
+
*/
|
1370
|
+
async joinTpaRoom(roomId, roomConfig) {
|
1371
|
+
try {
|
1372
|
+
const message = {
|
1373
|
+
type: 'tpa_room_join',
|
1374
|
+
packageName: this.config.packageName,
|
1375
|
+
sessionId: this.sessionId,
|
1376
|
+
roomId,
|
1377
|
+
roomConfig,
|
1378
|
+
timestamp: new Date()
|
1379
|
+
};
|
1380
|
+
this.send(message);
|
1381
|
+
}
|
1382
|
+
catch (error) {
|
1383
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1384
|
+
throw new Error(`Failed to join room: ${errorMessage}`);
|
1385
|
+
}
|
1386
|
+
}
|
1387
|
+
/**
|
1388
|
+
* 🚪 Leave a communication room
|
1389
|
+
* @param roomId - Room ID to leave
|
1390
|
+
* @returns Promise that resolves when room is left
|
1391
|
+
*/
|
1392
|
+
async leaveTpaRoom(roomId) {
|
1393
|
+
try {
|
1394
|
+
const message = {
|
1395
|
+
type: 'tpa_room_leave',
|
1396
|
+
packageName: this.config.packageName,
|
1397
|
+
sessionId: this.sessionId,
|
1398
|
+
roomId,
|
1399
|
+
timestamp: new Date()
|
1400
|
+
};
|
1401
|
+
this.send(message);
|
1402
|
+
}
|
1403
|
+
catch (error) {
|
1404
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
1405
|
+
throw new Error(`Failed to leave room: ${errorMessage}`);
|
1406
|
+
}
|
1407
|
+
}
|
1408
|
+
/**
|
1409
|
+
* 📨 Listen for messages from other TPA users
|
1410
|
+
* @param handler - Function to handle incoming messages
|
1411
|
+
* @returns Cleanup function to remove the handler
|
1412
|
+
*/
|
1413
|
+
onTpaMessage(handler) {
|
1414
|
+
this.tpaEvents.on('tpa_message_received', handler);
|
1415
|
+
return () => this.tpaEvents.off('tpa_message_received', handler);
|
1416
|
+
}
|
1417
|
+
/**
|
1418
|
+
* 👋 Listen for user join events
|
1419
|
+
* @param handler - Function to handle user join events
|
1420
|
+
* @returns Cleanup function to remove the handler
|
1421
|
+
*/
|
1422
|
+
onTpaUserJoined(handler) {
|
1423
|
+
this.tpaEvents.on('tpa_user_joined', handler);
|
1424
|
+
return () => this.tpaEvents.off('tpa_user_joined', handler);
|
1425
|
+
}
|
1426
|
+
/**
|
1427
|
+
* 🚪 Listen for user leave events
|
1428
|
+
* @param handler - Function to handle user leave events
|
1429
|
+
* @returns Cleanup function to remove the handler
|
1430
|
+
*/
|
1431
|
+
onTpaUserLeft(handler) {
|
1432
|
+
this.tpaEvents.on('tpa_user_left', handler);
|
1433
|
+
return () => this.tpaEvents.off('tpa_user_left', handler);
|
1434
|
+
}
|
1435
|
+
/**
|
1436
|
+
* 🏠 Listen for room update events
|
1437
|
+
* @param handler - Function to handle room updates
|
1438
|
+
* @returns Cleanup function to remove the handler
|
1439
|
+
*/
|
1440
|
+
onTpaRoomUpdated(handler) {
|
1441
|
+
this.tpaEvents.on('tpa_room_updated', handler);
|
1442
|
+
return () => this.tpaEvents.off('tpa_room_updated', handler);
|
1443
|
+
}
|
1444
|
+
/**
|
1445
|
+
* 🔧 Generate unique message ID
|
1446
|
+
* @returns Unique message identifier
|
1447
|
+
*/
|
1448
|
+
generateMessageId() {
|
1449
|
+
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
1450
|
+
}
|
1451
|
+
}
|
1452
|
+
exports.TpaSession = TpaSession;
|