@ctliz/agent-intercom-pi 0.11.0-connect.2
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/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +26 -0
- package/README.md +693 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/banner.png +0 -0
- package/boss-team-scope.ts +241 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +140 -0
- package/broker/boss-adapter.ts +383 -0
- package/broker/broker-impl.ts +2019 -0
- package/broker/broker.ts +7 -0
- package/broker/client.ts +932 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/protected-service.ts +158 -0
- package/broker/registration.ts +173 -0
- package/broker/spawn.ts +448 -0
- package/config.ts +183 -0
- package/control.ts +150 -0
- package/durable-json.ts +25 -0
- package/inbound-inbox.ts +296 -0
- package/index.ts +2826 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +72 -0
- package/protocol-v4/contract.ts +39 -0
- package/provider/provider.mjs +34 -0
- package/reply-tracker.ts +231 -0
- package/skills/pi-intercom/SKILL.md +537 -0
- package/team.ts +192 -0
- package/types.ts +183 -0
- package/ui/compose.ts +229 -0
- package/ui/inline-message.ts +126 -0
- package/ui/session-identity.ts +55 -0
- package/ui/session-list.ts +205 -0
- package/ui/timestamps.ts +50 -0
package/broker/client.ts
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { POLICY_SEMANTICS_VERSION } from "@ctliz/agent-intercom-core/policy";
|
|
5
|
+
import { POLICY_SEMANTICS_HASH } from "@ctliz/agent-intercom-core/vectors";
|
|
6
|
+
import { writeMessage, createMessageReader } from "./framing.ts";
|
|
7
|
+
import { intercomScopeIdFromEnvForRegistration, parseIntercomScopeIdForRegistration } from "../protocol-v4/contract.ts";
|
|
8
|
+
import { PersistentOutboundOutbox } from "../outbound-outbox.ts";
|
|
9
|
+
import { isIntercomCommonControlEnvelope, type IntercomCommonControlEnvelope } from "../control.ts";
|
|
10
|
+
import {
|
|
11
|
+
negotiateBossRegistration,
|
|
12
|
+
parseBossControl,
|
|
13
|
+
parseBossParticipantRegistrationMetadata,
|
|
14
|
+
parseBossSessionMetadata,
|
|
15
|
+
type BossParticipantRegistrationMetadata,
|
|
16
|
+
} from "./boss-adapter.ts";
|
|
17
|
+
import {
|
|
18
|
+
assertNoProxyGraph,
|
|
19
|
+
isExactRegisteredFrame,
|
|
20
|
+
parseSessionRegistration,
|
|
21
|
+
readExactFrameType,
|
|
22
|
+
} from "./registration.ts";
|
|
23
|
+
import { loadRemoteAccessCredential, writeRemoteSessionCredential, type LoadedRemoteAccessCredential } from "./access-credential.ts";
|
|
24
|
+
import {
|
|
25
|
+
getBrokerConnectTarget,
|
|
26
|
+
INTERCOM_PROTOCOL_NAME,
|
|
27
|
+
INTERCOM_PROTOCOL_VERSION,
|
|
28
|
+
type BrokerConnectTarget,
|
|
29
|
+
} from "./paths.ts";
|
|
30
|
+
import type {
|
|
31
|
+
AskCancellationReason,
|
|
32
|
+
DeliveryFailureCode,
|
|
33
|
+
SessionInfo,
|
|
34
|
+
Message,
|
|
35
|
+
Attachment,
|
|
36
|
+
SessionRegistration,
|
|
37
|
+
} from "../types.ts";
|
|
38
|
+
|
|
39
|
+
export interface SendOptions {
|
|
40
|
+
text: string;
|
|
41
|
+
attachments?: Attachment[];
|
|
42
|
+
control?: IntercomCommonControlEnvelope;
|
|
43
|
+
replyTo?: string;
|
|
44
|
+
expectsReply?: boolean;
|
|
45
|
+
messageId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IntercomClientOptions {
|
|
49
|
+
/** Return true only for durable outbox targets that may be replayed after registration. */
|
|
50
|
+
authorizeOutboxReplayTarget?: (target: string) => boolean;
|
|
51
|
+
/** Exact private registration scope captured for this client lifecycle. */
|
|
52
|
+
scopeId?: string;
|
|
53
|
+
/** Environment used only when scopeId is not explicitly supplied. */
|
|
54
|
+
env?: NodeJS.ProcessEnv;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SendResult {
|
|
58
|
+
id: string;
|
|
59
|
+
accepted: boolean;
|
|
60
|
+
delivered: boolean;
|
|
61
|
+
sentAt?: number;
|
|
62
|
+
deliveredAt?: number;
|
|
63
|
+
deliveryId?: string;
|
|
64
|
+
code?: DeliveryFailureCode;
|
|
65
|
+
reason?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function toError(error: unknown): Error {
|
|
69
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function connectToBrokerTarget(target: BrokerConnectTarget): net.Socket {
|
|
73
|
+
return typeof target === "string"
|
|
74
|
+
? net.connect(target)
|
|
75
|
+
: net.connect({ host: target.host, port: target.port });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isAttachment(value: unknown): value is Attachment {
|
|
79
|
+
if (typeof value !== "object" || value === null) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const attachment = value as Record<string, unknown>;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
attachment.type !== "file"
|
|
87
|
+
&& attachment.type !== "snippet"
|
|
88
|
+
&& attachment.type !== "context"
|
|
89
|
+
) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof attachment.name !== "string" || typeof attachment.content !== "string") {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return attachment.language === undefined || typeof attachment.language === "string";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isMessage(value: unknown): value is Message {
|
|
101
|
+
if (typeof value !== "object" || value === null) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const message = value as Record<string, unknown>;
|
|
106
|
+
|
|
107
|
+
if (typeof message.id !== "string" || typeof message.timestamp !== "number") {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (message.replyTo !== undefined && typeof message.replyTo !== "string") {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof message.content !== "object" || message.content === null) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const content = message.content as Record<string, unknown>;
|
|
124
|
+
if (typeof content.text !== "string") {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const attachmentsValid = content.attachments === undefined
|
|
129
|
+
|| (Array.isArray(content.attachments) && content.attachments.every(isAttachment));
|
|
130
|
+
const controlValid = content.control === undefined || isIntercomCommonControlEnvelope(content.control);
|
|
131
|
+
const controlThreadingValid = content.control === undefined
|
|
132
|
+
|| (message.replyTo === undefined && message.expectsReply !== true);
|
|
133
|
+
return attachmentsValid && controlValid && controlThreadingValid;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isSessionInfo(value: unknown): value is SessionInfo {
|
|
137
|
+
try {
|
|
138
|
+
assertNoProxyGraph(value);
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (typeof value !== "object" || value === null) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const session = value as Record<string, unknown>;
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
typeof session.id !== "string"
|
|
150
|
+
|| typeof session.cwd !== "string"
|
|
151
|
+
|| typeof session.model !== "string"
|
|
152
|
+
|| typeof session.pid !== "number"
|
|
153
|
+
|| typeof session.startedAt !== "number"
|
|
154
|
+
|| typeof session.lastActivity !== "number"
|
|
155
|
+
) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (session.name !== undefined && typeof session.name !== "string") {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (session.status !== undefined && typeof session.status !== "string") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (session.peerUid !== undefined && typeof session.peerUid !== "number") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (session.trustedLocal !== undefined && typeof session.trustedLocal !== "boolean") return false;
|
|
172
|
+
if (session.origin !== undefined && session.origin !== "local" && session.origin !== "remote") return false;
|
|
173
|
+
if (session.remoteHostId !== undefined && typeof session.remoteHostId !== "string") return false;
|
|
174
|
+
if (session.parentSessionId !== undefined && typeof session.parentSessionId !== "string") return false;
|
|
175
|
+
if (session.rootSessionId !== undefined && typeof session.rootSessionId !== "string") return false;
|
|
176
|
+
if (session.generation !== undefined && (typeof session.generation !== "number" || !Number.isSafeInteger(session.generation))) return false;
|
|
177
|
+
if (session.canDelegate !== undefined && typeof session.canDelegate !== "boolean") return false;
|
|
178
|
+
for (const field of ["depth", "maxDepth", "maxChildren"] as const) {
|
|
179
|
+
if (session[field] !== undefined && (typeof session[field] !== "number" || !Number.isSafeInteger(session[field]))) return false;
|
|
180
|
+
}
|
|
181
|
+
if (session.boss !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
parseBossSessionMetadata(session.boss, session.id as string);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isRemoteAccessMetadata(value: unknown): value is import("../types.ts").RemoteAccessMetadata {
|
|
192
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
193
|
+
const access = value as Record<string, unknown>;
|
|
194
|
+
return access.origin === "remote"
|
|
195
|
+
&& typeof access.remoteHostId === "string"
|
|
196
|
+
&& typeof access.parentSessionId === "string"
|
|
197
|
+
&& typeof access.rootSessionId === "string"
|
|
198
|
+
&& typeof access.generation === "number"
|
|
199
|
+
&& Number.isSafeInteger(access.generation)
|
|
200
|
+
&& access.generation > 0
|
|
201
|
+
&& typeof access.canDelegate === "boolean"
|
|
202
|
+
&& typeof access.depth === "number"
|
|
203
|
+
&& Number.isSafeInteger(access.depth)
|
|
204
|
+
&& typeof access.maxDepth === "number"
|
|
205
|
+
&& Number.isSafeInteger(access.maxDepth)
|
|
206
|
+
&& typeof access.maxChildren === "number"
|
|
207
|
+
&& Number.isSafeInteger(access.maxChildren)
|
|
208
|
+
&& (access.sessionCredential === undefined || typeof access.sessionCredential === "string");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class IntercomClient extends EventEmitter {
|
|
212
|
+
private socket: net.Socket | null = null;
|
|
213
|
+
private readonly scopeId: string | undefined;
|
|
214
|
+
private _sessionId: string | null = null;
|
|
215
|
+
private pendingSends = new Map<string, {
|
|
216
|
+
accepted: boolean;
|
|
217
|
+
sentAt: number;
|
|
218
|
+
deliveryId?: string;
|
|
219
|
+
resolve: (r: SendResult) => void;
|
|
220
|
+
reject: (e: Error) => void;
|
|
221
|
+
}>();
|
|
222
|
+
private pendingLists = new Map<string, { resolve: (sessions: SessionInfo[]) => void; reject: (e: Error) => void }>();
|
|
223
|
+
private pendingAskControls = new Map<string, { resolve: (applied: boolean) => void; timeout: NodeJS.Timeout }>();
|
|
224
|
+
private outbox: PersistentOutboundOutbox | null = null;
|
|
225
|
+
private remoteAccessCredential: LoadedRemoteAccessCredential | undefined;
|
|
226
|
+
private requestedBossRegistration: BossParticipantRegistrationMetadata | undefined;
|
|
227
|
+
private disconnecting = false;
|
|
228
|
+
private disconnectError: Error | null = null;
|
|
229
|
+
|
|
230
|
+
constructor(private readonly options: IntercomClientOptions = {}) {
|
|
231
|
+
super();
|
|
232
|
+
this.scopeId = options.scopeId === undefined
|
|
233
|
+
? intercomScopeIdFromEnvForRegistration(options.env ?? process.env)
|
|
234
|
+
: parseIntercomScopeIdForRegistration(options.scopeId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private failPending(error: Error): void {
|
|
238
|
+
for (const pending of this.pendingSends.values()) {
|
|
239
|
+
pending.reject(error);
|
|
240
|
+
}
|
|
241
|
+
this.pendingSends.clear();
|
|
242
|
+
for (const pending of this.pendingLists.values()) {
|
|
243
|
+
pending.reject(error);
|
|
244
|
+
}
|
|
245
|
+
this.pendingLists.clear();
|
|
246
|
+
for (const pending of this.pendingAskControls.values()) {
|
|
247
|
+
clearTimeout(pending.timeout);
|
|
248
|
+
pending.resolve(false);
|
|
249
|
+
}
|
|
250
|
+
this.pendingAskControls.clear();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get sessionId(): string | null {
|
|
254
|
+
return this._sessionId;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get outboxSize(): number {
|
|
258
|
+
return this.outbox?.list().length ?? 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
isConnected(): boolean {
|
|
262
|
+
const socket = this.socket;
|
|
263
|
+
return Boolean(socket && this._sessionId && !this.disconnecting && !socket.destroyed && !socket.writableEnded && socket.writable);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private requireActiveSocket(): net.Socket {
|
|
267
|
+
if (this.disconnecting) {
|
|
268
|
+
throw new Error("Client disconnecting");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const socket = this.socket;
|
|
272
|
+
if (!socket || !this._sessionId) {
|
|
273
|
+
throw new Error("Not connected");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
277
|
+
throw new Error("Client disconnected");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return socket;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
connect(session: SessionRegistration, sessionId?: string): Promise<void> {
|
|
284
|
+
if (this.socket) {
|
|
285
|
+
return Promise.reject(new Error("Already connected"));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const parsedSession = parseSessionRegistration(session);
|
|
289
|
+
if (!parsedSession) return Promise.reject(new Error("Invalid session registration"));
|
|
290
|
+
|
|
291
|
+
let requestedBossRegistration: BossParticipantRegistrationMetadata | undefined;
|
|
292
|
+
try {
|
|
293
|
+
requestedBossRegistration = parsedSession.boss === undefined
|
|
294
|
+
? undefined
|
|
295
|
+
: parseBossParticipantRegistrationMetadata(parsedSession.boss);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return Promise.reject(toError(error));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
let socket: net.Socket;
|
|
302
|
+
let target: BrokerConnectTarget;
|
|
303
|
+
try {
|
|
304
|
+
target = getBrokerConnectTarget();
|
|
305
|
+
this.remoteAccessCredential = loadRemoteAccessCredential();
|
|
306
|
+
socket = connectToBrokerTarget(target);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
reject(toError(error));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.socket = socket;
|
|
312
|
+
this.requestedBossRegistration = requestedBossRegistration;
|
|
313
|
+
this.disconnectError = null;
|
|
314
|
+
let settled = false;
|
|
315
|
+
const timeout = setTimeout(() => {
|
|
316
|
+
if (!this._sessionId) {
|
|
317
|
+
cleanupConnectionAttempt();
|
|
318
|
+
cleanupSocketListeners();
|
|
319
|
+
if (this.socket === socket) {
|
|
320
|
+
this.socket = null;
|
|
321
|
+
}
|
|
322
|
+
this.requestedBossRegistration = undefined;
|
|
323
|
+
socket.destroy();
|
|
324
|
+
reject(new Error("Connection timeout"));
|
|
325
|
+
}
|
|
326
|
+
}, 10000);
|
|
327
|
+
|
|
328
|
+
let connectionEstablished = false;
|
|
329
|
+
|
|
330
|
+
const onRegistered = () => {
|
|
331
|
+
settled = true;
|
|
332
|
+
connectionEstablished = true;
|
|
333
|
+
cleanupConnectionAttempt();
|
|
334
|
+
resolve();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const onError = (err: Error) => {
|
|
338
|
+
settled = true;
|
|
339
|
+
cleanupConnectionAttempt();
|
|
340
|
+
cleanupSocketListeners();
|
|
341
|
+
if (this.socket === socket) {
|
|
342
|
+
this.socket = null;
|
|
343
|
+
}
|
|
344
|
+
this.requestedBossRegistration = undefined;
|
|
345
|
+
socket.destroy();
|
|
346
|
+
reject(err);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const onClose = () => {
|
|
350
|
+
const wasConnecting = !settled && !this._sessionId;
|
|
351
|
+
const wasDisconnecting = this.disconnecting;
|
|
352
|
+
const disconnectError = this.disconnectError ?? new Error("Client disconnected");
|
|
353
|
+
this.disconnecting = false;
|
|
354
|
+
cleanupConnectionAttempt();
|
|
355
|
+
cleanupSocketListeners();
|
|
356
|
+
this.failPending(disconnectError);
|
|
357
|
+
if (this.socket === socket) {
|
|
358
|
+
this.socket = null;
|
|
359
|
+
}
|
|
360
|
+
this._sessionId = null;
|
|
361
|
+
this.requestedBossRegistration = undefined;
|
|
362
|
+
this.disconnectError = null;
|
|
363
|
+
if (connectionEstablished && !wasDisconnecting) {
|
|
364
|
+
this.emit("disconnected", disconnectError);
|
|
365
|
+
}
|
|
366
|
+
if (wasConnecting) {
|
|
367
|
+
reject(new Error("Connection closed before registration"));
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const onSocketError = (err: Error) => {
|
|
372
|
+
if (connectionEstablished) {
|
|
373
|
+
this.disconnectError = err;
|
|
374
|
+
this.emit("error", err);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const onReaderError = (error: Error) => {
|
|
379
|
+
const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
|
|
380
|
+
if (!connectionEstablished) {
|
|
381
|
+
onError(protocolError);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.disconnectError = protocolError;
|
|
385
|
+
this.emit("error", protocolError);
|
|
386
|
+
socket.destroy();
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const reader = createMessageReader((msg) => {
|
|
390
|
+
this.handleBrokerMessage(msg);
|
|
391
|
+
}, onReaderError);
|
|
392
|
+
|
|
393
|
+
const cleanupConnectionAttempt = () => {
|
|
394
|
+
this.off("_registered", onRegistered);
|
|
395
|
+
socket.off("error", onError);
|
|
396
|
+
clearTimeout(timeout);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const cleanupSocketListeners = () => {
|
|
400
|
+
socket.off("data", reader);
|
|
401
|
+
socket.off("error", onSocketError);
|
|
402
|
+
socket.off("close", onClose);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
socket.on("data", reader);
|
|
406
|
+
socket.on("error", onError);
|
|
407
|
+
socket.on("close", onClose);
|
|
408
|
+
|
|
409
|
+
socket.on("error", onSocketError);
|
|
410
|
+
this.once("_registered", onRegistered);
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
writeMessage(socket, {
|
|
414
|
+
type: "register",
|
|
415
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
416
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
417
|
+
session: parsedSession,
|
|
418
|
+
...(!this.remoteAccessCredential && sessionId ? { sessionId } : {}),
|
|
419
|
+
...(this.remoteAccessCredential ? { access: this.remoteAccessCredential.access } : {}),
|
|
420
|
+
...(this.scopeId ? { scopeId: this.scopeId } : {}),
|
|
421
|
+
...(typeof target === "string" ? {} : { stateId: target.stateId }),
|
|
422
|
+
});
|
|
423
|
+
} catch (error) {
|
|
424
|
+
cleanupConnectionAttempt();
|
|
425
|
+
cleanupSocketListeners();
|
|
426
|
+
if (this.socket === socket) {
|
|
427
|
+
this.socket = null;
|
|
428
|
+
}
|
|
429
|
+
this.requestedBossRegistration = undefined;
|
|
430
|
+
socket.destroy();
|
|
431
|
+
reject(toError(error));
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private handleBrokerMessage(msg: unknown): void {
|
|
437
|
+
const messageType = readExactFrameType(msg);
|
|
438
|
+
if (messageType === null) {
|
|
439
|
+
throw new Error("Invalid broker message");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const brokerMessage = msg as { type: string } & Record<string, unknown>;
|
|
443
|
+
|
|
444
|
+
if (this._sessionId === null && brokerMessage.type !== "registered" && brokerMessage.type !== "error") {
|
|
445
|
+
throw new Error(`Received ${brokerMessage.type} before registered`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
switch (brokerMessage.type) {
|
|
449
|
+
case "registered": {
|
|
450
|
+
const registrationMode = this.requestedBossRegistration
|
|
451
|
+
? "boss"
|
|
452
|
+
: this.remoteAccessCredential
|
|
453
|
+
? "remote"
|
|
454
|
+
: "ordinary";
|
|
455
|
+
if (!isExactRegisteredFrame(brokerMessage, registrationMode)) {
|
|
456
|
+
throw new Error("Invalid registered message");
|
|
457
|
+
}
|
|
458
|
+
if (
|
|
459
|
+
typeof brokerMessage.sessionId !== "string"
|
|
460
|
+
|| brokerMessage.protocol !== INTERCOM_PROTOCOL_NAME
|
|
461
|
+
|| brokerMessage.version !== INTERCOM_PROTOCOL_VERSION
|
|
462
|
+
) {
|
|
463
|
+
throw new Error("Invalid registered message");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (this._sessionId !== null) {
|
|
467
|
+
throw new Error("Received duplicate registered message");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (this.remoteAccessCredential) {
|
|
471
|
+
const contract = brokerMessage.remoteAccess;
|
|
472
|
+
const contractFields = typeof contract === "object" && contract !== null
|
|
473
|
+
? contract as Record<string, unknown>
|
|
474
|
+
: undefined;
|
|
475
|
+
if (
|
|
476
|
+
!contractFields
|
|
477
|
+
|| contractFields.feature !== "remote-access-v1"
|
|
478
|
+
|| contractFields.policySemanticsVersion !== POLICY_SEMANTICS_VERSION
|
|
479
|
+
|| contractFields.policySemanticsHash !== POLICY_SEMANTICS_HASH
|
|
480
|
+
) {
|
|
481
|
+
throw new Error("Remote Intercom policy contract is absent or incompatible");
|
|
482
|
+
}
|
|
483
|
+
if (!isRemoteAccessMetadata(brokerMessage.access)) {
|
|
484
|
+
throw new Error("Remote Intercom registration omitted broker-owned provenance");
|
|
485
|
+
}
|
|
486
|
+
if (this.remoteAccessCredential.enrollment) {
|
|
487
|
+
writeRemoteSessionCredential(this.remoteAccessCredential.path, brokerMessage.sessionId, brokerMessage.access);
|
|
488
|
+
} else {
|
|
489
|
+
const reconnect = this.remoteAccessCredential.access;
|
|
490
|
+
if (!("sessionId" in reconnect) || reconnect.sessionId !== brokerMessage.sessionId || reconnect.generation !== brokerMessage.access.generation) {
|
|
491
|
+
throw new Error("Remote Intercom reconnect identity or generation changed unexpectedly");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (this.requestedBossRegistration) {
|
|
497
|
+
const negotiation = negotiateBossRegistration(this.requestedBossRegistration, brokerMessage.capabilities);
|
|
498
|
+
if ("code" in negotiation) throw new Error(`Boss registration negotiation failed: ${negotiation.code}`);
|
|
499
|
+
parseBossSessionMetadata(brokerMessage.boss, brokerMessage.sessionId);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this._sessionId = brokerMessage.sessionId;
|
|
503
|
+
this.outbox = new PersistentOutboundOutbox(brokerMessage.sessionId);
|
|
504
|
+
const authorizeReplay = this.options.authorizeOutboxReplayTarget;
|
|
505
|
+
if (authorizeReplay) {
|
|
506
|
+
for (const entry of this.outbox.list()) {
|
|
507
|
+
if (!authorizeReplay(entry.to)) this.outbox.remove(entry.message.id);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
this.replayOutbox();
|
|
511
|
+
this.emit("_registered", { type: "registered", sessionId: brokerMessage.sessionId });
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
case "sessions": {
|
|
516
|
+
const { requestId, sessions } = brokerMessage;
|
|
517
|
+
if (typeof requestId !== "string" || !Array.isArray(sessions) || !sessions.every(isSessionInfo)) {
|
|
518
|
+
throw new Error("Invalid sessions message");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const pending = this.pendingLists.get(requestId);
|
|
522
|
+
if (!pending) {
|
|
523
|
+
// Late list responses can still arrive after the caller has already timed out.
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.pendingLists.delete(requestId);
|
|
528
|
+
pending.resolve(sessions);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case "message": {
|
|
533
|
+
const { deliveryId, from, message } = brokerMessage;
|
|
534
|
+
if (typeof deliveryId !== "string" || !isSessionInfo(from) || !isMessage(message)) {
|
|
535
|
+
throw new Error("Invalid message event");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.emit("message", from, message, deliveryId);
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
case "delivery_accepted": {
|
|
543
|
+
const { deliveryId, messageId } = brokerMessage;
|
|
544
|
+
if (typeof deliveryId !== "string" || typeof messageId !== "string") {
|
|
545
|
+
throw new Error("Invalid delivery_accepted message");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const pending = this.pendingSends.get(messageId);
|
|
549
|
+
if (!pending) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
pending.accepted = true;
|
|
553
|
+
pending.deliveryId = deliveryId;
|
|
554
|
+
this.emit("delivery_accepted", messageId, deliveryId);
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
case "delivered": {
|
|
559
|
+
const { deliveryId, messageId } = brokerMessage;
|
|
560
|
+
if (typeof deliveryId !== "string" || typeof messageId !== "string") {
|
|
561
|
+
throw new Error("Invalid delivered message");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.outbox?.remove(messageId);
|
|
565
|
+
const pending = this.pendingSends.get(messageId);
|
|
566
|
+
if (!pending) {
|
|
567
|
+
this.emit("outbox_delivered", messageId, deliveryId);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
this.pendingSends.delete(messageId);
|
|
572
|
+
pending.resolve({
|
|
573
|
+
id: messageId,
|
|
574
|
+
accepted: true,
|
|
575
|
+
delivered: true,
|
|
576
|
+
sentAt: pending.sentAt,
|
|
577
|
+
deliveredAt: Date.now(),
|
|
578
|
+
deliveryId,
|
|
579
|
+
});
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case "delivery_failed": {
|
|
584
|
+
const { accepted, code, messageId, reason } = brokerMessage;
|
|
585
|
+
if (
|
|
586
|
+
typeof accepted !== "boolean"
|
|
587
|
+
|| typeof code !== "string"
|
|
588
|
+
|| typeof messageId !== "string"
|
|
589
|
+
|| typeof reason !== "string"
|
|
590
|
+
) {
|
|
591
|
+
throw new Error("Invalid delivery_failed message");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
this.outbox?.remove(messageId);
|
|
595
|
+
const pending = this.pendingSends.get(messageId);
|
|
596
|
+
if (!pending) {
|
|
597
|
+
this.emit("outbox_failed", messageId, code, reason);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.pendingSends.delete(messageId);
|
|
602
|
+
pending.resolve({
|
|
603
|
+
id: messageId,
|
|
604
|
+
accepted,
|
|
605
|
+
delivered: false,
|
|
606
|
+
sentAt: pending.sentAt,
|
|
607
|
+
code: code as DeliveryFailureCode,
|
|
608
|
+
reason,
|
|
609
|
+
...(pending.deliveryId ? { deliveryId: pending.deliveryId } : {}),
|
|
610
|
+
});
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case "ask_deferred": {
|
|
615
|
+
const { fromSessionId, messageId } = brokerMessage;
|
|
616
|
+
if (typeof fromSessionId !== "string" || typeof messageId !== "string") {
|
|
617
|
+
throw new Error("Invalid ask_deferred message");
|
|
618
|
+
}
|
|
619
|
+
this.emit("ask_deferred", messageId, fromSessionId);
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
case "ask_cancelled": {
|
|
624
|
+
const { fromSessionId, messageId, reason } = brokerMessage;
|
|
625
|
+
if (typeof fromSessionId !== "string" || typeof messageId !== "string" || typeof reason !== "string") {
|
|
626
|
+
throw new Error("Invalid ask_cancelled message");
|
|
627
|
+
}
|
|
628
|
+
this.emit("ask_cancelled", messageId, fromSessionId, reason as AskCancellationReason);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
case "ask_control_result": {
|
|
633
|
+
const { action, applied, messageId, requestId } = brokerMessage;
|
|
634
|
+
if (
|
|
635
|
+
(action !== "defer" && action !== "cancel")
|
|
636
|
+
|| typeof applied !== "boolean"
|
|
637
|
+
|| typeof messageId !== "string"
|
|
638
|
+
|| typeof requestId !== "string"
|
|
639
|
+
) {
|
|
640
|
+
throw new Error("Invalid ask_control_result message");
|
|
641
|
+
}
|
|
642
|
+
const pending = this.pendingAskControls.get(requestId);
|
|
643
|
+
if (!pending) return;
|
|
644
|
+
clearTimeout(pending.timeout);
|
|
645
|
+
this.pendingAskControls.delete(requestId);
|
|
646
|
+
pending.resolve(applied);
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
case "session_joined": {
|
|
651
|
+
if (!isSessionInfo(brokerMessage.session)) {
|
|
652
|
+
throw new Error("Invalid session_joined message");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
this.emit("session_joined", brokerMessage.session);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
case "session_left": {
|
|
660
|
+
if (typeof brokerMessage.sessionId !== "string") {
|
|
661
|
+
throw new Error("Invalid session_left message");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
this.emit("session_left", brokerMessage.sessionId);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
case "presence_update": {
|
|
669
|
+
if (!isSessionInfo(brokerMessage.session)) {
|
|
670
|
+
throw new Error("Invalid presence_update message");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
this.emit("presence_update", brokerMessage.session);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
case "error": {
|
|
678
|
+
if (typeof brokerMessage.code !== "string" || typeof brokerMessage.error !== "string") {
|
|
679
|
+
throw new Error("Invalid error message");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (this._sessionId === null) {
|
|
683
|
+
const error = new Error(brokerMessage.error) as Error & { code?: string };
|
|
684
|
+
error.code = brokerMessage.code;
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
const error = new Error(brokerMessage.error) as Error & { code?: string };
|
|
688
|
+
error.code = brokerMessage.code;
|
|
689
|
+
this.emit("error", error);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
default:
|
|
694
|
+
throw new Error(`Unknown broker message type: ${brokerMessage.type}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async disconnect(preserveAsks = false): Promise<void> {
|
|
699
|
+
const socket = this.socket;
|
|
700
|
+
if (!socket) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
this.disconnecting = true;
|
|
705
|
+
this.disconnectError = null;
|
|
706
|
+
this.failPending(new Error("Client disconnected"));
|
|
707
|
+
if (!preserveAsks) this.outbox?.clear();
|
|
708
|
+
|
|
709
|
+
await new Promise<void>((resolve) => {
|
|
710
|
+
let settled = false;
|
|
711
|
+
const finish = () => {
|
|
712
|
+
if (settled) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
settled = true;
|
|
716
|
+
clearTimeout(timeout);
|
|
717
|
+
socket.off("close", onClose);
|
|
718
|
+
socket.off("error", onError);
|
|
719
|
+
resolve();
|
|
720
|
+
};
|
|
721
|
+
const onClose = () => finish();
|
|
722
|
+
const onError = () => {
|
|
723
|
+
socket.destroy();
|
|
724
|
+
};
|
|
725
|
+
const timeout = setTimeout(() => {
|
|
726
|
+
socket.destroy();
|
|
727
|
+
}, 2000);
|
|
728
|
+
|
|
729
|
+
socket.once("close", onClose);
|
|
730
|
+
socket.once("error", onError);
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
writeMessage(socket, { type: "unregister", ...(preserveAsks ? { preserveAsks: true } : {}) });
|
|
734
|
+
socket.end();
|
|
735
|
+
} catch {
|
|
736
|
+
// Disconnect should still finish even if the unregister write fails.
|
|
737
|
+
socket.destroy();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
listSessions(): Promise<SessionInfo[]> {
|
|
743
|
+
let socket: net.Socket;
|
|
744
|
+
try {
|
|
745
|
+
socket = this.requireActiveSocket();
|
|
746
|
+
} catch (error) {
|
|
747
|
+
return Promise.reject(toError(error));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return new Promise((resolve, reject) => {
|
|
751
|
+
const requestId = randomUUID();
|
|
752
|
+
const wrappedResolve = (sessions: SessionInfo[]) => {
|
|
753
|
+
clearTimeout(timeout);
|
|
754
|
+
resolve(sessions);
|
|
755
|
+
};
|
|
756
|
+
const wrappedReject = (error: Error) => {
|
|
757
|
+
clearTimeout(timeout);
|
|
758
|
+
reject(error);
|
|
759
|
+
};
|
|
760
|
+
const timeout = setTimeout(() => {
|
|
761
|
+
if (this.pendingLists.has(requestId)) {
|
|
762
|
+
this.pendingLists.delete(requestId);
|
|
763
|
+
wrappedReject(new Error("List sessions timeout"));
|
|
764
|
+
}
|
|
765
|
+
}, 5000);
|
|
766
|
+
this.pendingLists.set(requestId, { resolve: wrappedResolve, reject: wrappedReject });
|
|
767
|
+
try {
|
|
768
|
+
writeMessage(socket, { type: "list", requestId });
|
|
769
|
+
} catch (error) {
|
|
770
|
+
clearTimeout(timeout);
|
|
771
|
+
this.pendingLists.delete(requestId);
|
|
772
|
+
reject(toError(error));
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
send(to: string, options: SendOptions): Promise<SendResult> {
|
|
778
|
+
const messageId = options.messageId ?? randomUUID();
|
|
779
|
+
if (options.control !== undefined && parseBossControl(options.control) !== null) {
|
|
780
|
+
return Promise.resolve({
|
|
781
|
+
id: messageId,
|
|
782
|
+
accepted: false,
|
|
783
|
+
delivered: false,
|
|
784
|
+
code: "CONTROL_DISPATCH_UNAVAILABLE",
|
|
785
|
+
reason: "Boss typed control is unavailable until the durable Controller delivery authority is installed",
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
let socket: net.Socket;
|
|
790
|
+
try {
|
|
791
|
+
socket = this.requireActiveSocket();
|
|
792
|
+
} catch (error) {
|
|
793
|
+
return Promise.reject(toError(error));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (this.pendingSends.has(messageId)) {
|
|
797
|
+
return Promise.resolve({
|
|
798
|
+
id: messageId,
|
|
799
|
+
accepted: false,
|
|
800
|
+
delivered: false,
|
|
801
|
+
code: "DUPLICATE_MESSAGE_ID",
|
|
802
|
+
reason: `Message ID ${messageId} is already pending`,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const message: Message = {
|
|
806
|
+
id: messageId,
|
|
807
|
+
timestamp: Date.now(),
|
|
808
|
+
replyTo: options.replyTo,
|
|
809
|
+
expectsReply: options.expectsReply,
|
|
810
|
+
content: {
|
|
811
|
+
text: options.text,
|
|
812
|
+
attachments: options.attachments,
|
|
813
|
+
control: options.control,
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
this.outbox?.enqueue(to, message);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
return Promise.reject(toError(error));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return new Promise((resolve, reject) => {
|
|
824
|
+
const wrappedResolve = (result: SendResult) => {
|
|
825
|
+
clearTimeout(timeout);
|
|
826
|
+
resolve(result);
|
|
827
|
+
};
|
|
828
|
+
const wrappedReject = (error: Error) => {
|
|
829
|
+
clearTimeout(timeout);
|
|
830
|
+
reject(error);
|
|
831
|
+
};
|
|
832
|
+
const timeout = setTimeout(() => {
|
|
833
|
+
if (this.pendingSends.has(messageId)) {
|
|
834
|
+
this.pendingSends.delete(messageId);
|
|
835
|
+
wrappedReject(new Error("Send timeout"));
|
|
836
|
+
}
|
|
837
|
+
}, 10000);
|
|
838
|
+
this.pendingSends.set(messageId, {
|
|
839
|
+
accepted: false,
|
|
840
|
+
sentAt: message.timestamp,
|
|
841
|
+
resolve: wrappedResolve,
|
|
842
|
+
reject: wrappedReject,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
writeMessage(socket, { type: "send", to, message });
|
|
847
|
+
} catch (error) {
|
|
848
|
+
clearTimeout(timeout);
|
|
849
|
+
this.pendingSends.delete(messageId);
|
|
850
|
+
reject(toError(error));
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
acknowledgeMessage(deliveryId: string): boolean {
|
|
856
|
+
return this.writeControlMessage({ type: "message_received", deliveryId });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
rejectMessage(deliveryId: string, reason: string): boolean {
|
|
860
|
+
return this.writeControlMessage({ type: "message_rejected", deliveryId, code: "CONFLICTING_MESSAGE_ID", reason });
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
deferAsk(messageId: string): Promise<boolean> {
|
|
864
|
+
return this.sendAskControl("defer", messageId);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
cancelAsk(messageId: string): Promise<boolean> {
|
|
868
|
+
return this.sendAskControl("cancel", messageId);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private sendAskControl(action: "defer" | "cancel", messageId: string): Promise<boolean> {
|
|
872
|
+
const requestId = randomUUID();
|
|
873
|
+
return new Promise((resolve) => {
|
|
874
|
+
const timeout = setTimeout(() => {
|
|
875
|
+
this.pendingAskControls.delete(requestId);
|
|
876
|
+
resolve(false);
|
|
877
|
+
}, 2000);
|
|
878
|
+
timeout.unref?.();
|
|
879
|
+
this.pendingAskControls.set(requestId, { resolve, timeout });
|
|
880
|
+
if (!this.writeControlMessage({ type: action === "defer" ? "defer_ask" : "cancel_ask", requestId, messageId })) {
|
|
881
|
+
clearTimeout(timeout);
|
|
882
|
+
this.pendingAskControls.delete(requestId);
|
|
883
|
+
resolve(false);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private writeControlMessage(message: Record<string, unknown>): boolean {
|
|
889
|
+
if (this.disconnecting) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const socket = this.socket;
|
|
894
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
try {
|
|
899
|
+
writeMessage(socket, message);
|
|
900
|
+
return true;
|
|
901
|
+
} catch {
|
|
902
|
+
// Control messages are best-effort; local cleanup must still proceed.
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private replayOutbox(): void {
|
|
908
|
+
const socket = this.socket;
|
|
909
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) return;
|
|
910
|
+
for (const entry of this.outbox?.list() ?? []) {
|
|
911
|
+
if (this.pendingSends.has(entry.message.id)) continue;
|
|
912
|
+
try {
|
|
913
|
+
writeMessage(socket, { type: "send", to: entry.to, message: entry.message });
|
|
914
|
+
} catch {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
updatePresence(updates: { name?: string; status?: string; model?: string }): void {
|
|
921
|
+
if (this.disconnecting) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const socket = this.socket;
|
|
926
|
+
if (!socket || !this._sessionId || socket.destroyed || socket.writableEnded || !socket.writable) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
writeMessage(socket, { type: "presence", ...updates });
|
|
931
|
+
}
|
|
932
|
+
}
|