@pmate/chat-sdk 0.9.6 → 0.9.8
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/lib/runtime/call.d.ts +5 -0
- package/lib/runtime/call.js +51 -1
- package/lib/runtime/chatClient.d.ts +3 -0
- package/lib/runtime/chatClient.js +5 -2
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/notification.d.ts +2 -1
- package/lib/runtime/notification.js +3 -3
- package/lib/runtime/reach.d.ts +23 -0
- package/lib/runtime/reach.js +18 -0
- package/lib/runtime/readSync.js +28 -1
- package/package.json +1 -1
package/lib/runtime/call.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ export declare class CallClient {
|
|
|
58
58
|
private generation;
|
|
59
59
|
private closed;
|
|
60
60
|
private capturePending;
|
|
61
|
+
private attachId?;
|
|
62
|
+
private terminalCalls;
|
|
61
63
|
private requestId?;
|
|
62
64
|
private groupIds;
|
|
63
65
|
private heartbeat?;
|
|
@@ -68,6 +70,9 @@ export declare class CallClient {
|
|
|
68
70
|
snapshot(): CallSnapshot;
|
|
69
71
|
subscribe(listener: (state: CallSnapshot) => void): () => void;
|
|
70
72
|
connect(): void;
|
|
73
|
+
/** Notification/reach entry: query authoritative state before showing a call. */
|
|
74
|
+
attach(callId: string): void;
|
|
75
|
+
private rememberTerminal;
|
|
71
76
|
watchPeer(peerId?: string): void;
|
|
72
77
|
watchGroups(groupIds: string[]): void;
|
|
73
78
|
start(peerId: string): Promise<void>;
|
package/lib/runtime/call.js
CHANGED
|
@@ -12,6 +12,8 @@ export class CallClient {
|
|
|
12
12
|
generation = 0;
|
|
13
13
|
closed = false;
|
|
14
14
|
capturePending = false;
|
|
15
|
+
attachId;
|
|
16
|
+
terminalCalls = new Map();
|
|
15
17
|
requestId;
|
|
16
18
|
groupIds = [];
|
|
17
19
|
heartbeat;
|
|
@@ -59,6 +61,24 @@ export class CallClient {
|
|
|
59
61
|
this.reconnect = setTimeout(() => this.connect(), 3000);
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
/** Notification/reach entry: query authoritative state before showing a call. */
|
|
65
|
+
attach(callId) {
|
|
66
|
+
if (this.closed || !callId || callId.length > 128)
|
|
67
|
+
throw new Error("INVALID_CALL_ID");
|
|
68
|
+
this.attachId = callId;
|
|
69
|
+
if (this.state.ready)
|
|
70
|
+
this.send({ type: "call.attach", callId });
|
|
71
|
+
else
|
|
72
|
+
this.connect();
|
|
73
|
+
}
|
|
74
|
+
rememberTerminal(callId) {
|
|
75
|
+
this.terminalCalls.set(callId, Date.now());
|
|
76
|
+
for (const [id, time] of this.terminalCalls)
|
|
77
|
+
if (Date.now() - time > 300000)
|
|
78
|
+
this.terminalCalls.delete(id);
|
|
79
|
+
while (this.terminalCalls.size > 2000)
|
|
80
|
+
this.terminalCalls.delete(this.terminalCalls.keys().next().value);
|
|
81
|
+
}
|
|
62
82
|
watchPeer(peerId) {
|
|
63
83
|
this.update({ watchedPeerId: peerId, peerOnline: undefined, peerInCall: undefined });
|
|
64
84
|
if (this.state.ready)
|
|
@@ -113,6 +133,8 @@ export class CallClient {
|
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
hangup() {
|
|
136
|
+
if (this.state.callId)
|
|
137
|
+
this.rememberTerminal(this.state.callId);
|
|
116
138
|
if (this.state.callId && !["idle", "ended"].includes(this.state.phase))
|
|
117
139
|
this.send({ type: "hangup", callId: this.state.callId });
|
|
118
140
|
this.release();
|
|
@@ -225,6 +247,8 @@ export class CallClient {
|
|
|
225
247
|
async receive(message) {
|
|
226
248
|
if (message.type === "ready") {
|
|
227
249
|
this.update({ ready: true, error: undefined });
|
|
250
|
+
if (this.attachId)
|
|
251
|
+
this.send({ type: "call.attach", callId: this.attachId });
|
|
228
252
|
if (this.state.watchedPeerId)
|
|
229
253
|
this.send({ type: "presence.watch", peerId: this.state.watchedPeerId });
|
|
230
254
|
if (this.groupIds.length)
|
|
@@ -260,7 +284,30 @@ export class CallClient {
|
|
|
260
284
|
this.fail(message.code);
|
|
261
285
|
return;
|
|
262
286
|
}
|
|
287
|
+
if (message.type === "call.status") {
|
|
288
|
+
if (message.callId !== this.attachId)
|
|
289
|
+
return;
|
|
290
|
+
this.attachId = undefined;
|
|
291
|
+
if (message.state === "ended") {
|
|
292
|
+
this.rememberTerminal(message.callId);
|
|
293
|
+
if (["idle", "ended"].includes(this.state.phase) || this.state.callId === message.callId) {
|
|
294
|
+
this.release();
|
|
295
|
+
this.update({ phase: "ended", callId: message.callId, reason: message.reason });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
263
300
|
if (message.type === "ringing") {
|
|
301
|
+
if (this.terminalCalls.has(message.callId))
|
|
302
|
+
return;
|
|
303
|
+
if (message.callId === this.state.callId && ["incoming", "ringing", "connecting", "connected"].includes(this.state.phase))
|
|
304
|
+
return;
|
|
305
|
+
if (typeof message.expiresAt === "number" && message.expiresAt <= Date.now()) {
|
|
306
|
+
this.rememberTerminal(message.callId);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (this.attachId === message.callId)
|
|
310
|
+
this.attachId = undefined;
|
|
264
311
|
if (message.direction === "outgoing" && (this.state.phase !== "requesting" || message.requestId !== this.requestId)) {
|
|
265
312
|
this.send({ type: "hangup", callId: message.callId });
|
|
266
313
|
return;
|
|
@@ -270,12 +317,15 @@ export class CallClient {
|
|
|
270
317
|
return;
|
|
271
318
|
}
|
|
272
319
|
this.update({ phase: message.direction === "incoming" ? "incoming" : "ringing", callId: message.callId, peerId: message.peerId, direction: message.direction, error: undefined, reason: undefined, startedAt: undefined });
|
|
273
|
-
this.armTimeout(35000);
|
|
320
|
+
this.armTimeout(typeof message.expiresAt === "number" ? Math.max(1, message.expiresAt - Date.now()) : 35000);
|
|
274
321
|
return;
|
|
275
322
|
}
|
|
323
|
+
if (message.type === "ended" && typeof message.callId === "string")
|
|
324
|
+
this.rememberTerminal(message.callId);
|
|
276
325
|
if (message.callId !== this.state.callId)
|
|
277
326
|
return;
|
|
278
327
|
if (message.type === "ended") {
|
|
328
|
+
this.rememberTerminal(message.callId);
|
|
279
329
|
const event = { callId: message.callId, peerId: this.state.peerId, direction: this.state.direction, reason: message.reason, durationMs: message.durationMs };
|
|
280
330
|
this.release();
|
|
281
331
|
this.update({ phase: "ended", reason: message.reason });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type OnlineReachHandler } from "./reach.js";
|
|
1
2
|
import { type OnlineNotificationHandler } from "./notification.js";
|
|
2
3
|
import { type ChatPresence } from "./presence.js";
|
|
3
4
|
import { type ChatUpdate } from "./updates.js";
|
|
@@ -10,6 +11,7 @@ export type ChatConnectionState = "connecting" | "reconnecting" | "authenticatin
|
|
|
10
11
|
export type ChatClientOptions = {
|
|
11
12
|
appId: string;
|
|
12
13
|
clientInstanceId?: string;
|
|
14
|
+
onReach?: OnlineReachHandler;
|
|
13
15
|
onNotification?: OnlineNotificationHandler;
|
|
14
16
|
profileId?: string;
|
|
15
17
|
botKey?: PmateBotKeyFile | string | unknown;
|
|
@@ -27,6 +29,7 @@ export type ChatClientOptions = {
|
|
|
27
29
|
export declare class ChatClient {
|
|
28
30
|
private readonly options;
|
|
29
31
|
private readonly instanceId;
|
|
32
|
+
private readonly reach;
|
|
30
33
|
private readonly notifications;
|
|
31
34
|
private presence?;
|
|
32
35
|
private presenceProfiles;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OnlineReachReceiver } from "./reach.js";
|
|
1
2
|
import { OnlineNotificationReceiver } from "./notification.js";
|
|
2
3
|
import { ChatPresenceClient } from "./presence.js";
|
|
3
4
|
import { parseChatUpdate } from "./updates.js";
|
|
@@ -11,6 +12,7 @@ const DEFAULT_ACK_TIMEOUT_MS = 5_000;
|
|
|
11
12
|
export class ChatClient {
|
|
12
13
|
options;
|
|
13
14
|
instanceId;
|
|
15
|
+
reach;
|
|
14
16
|
notifications;
|
|
15
17
|
presence;
|
|
16
18
|
presenceProfiles = [];
|
|
@@ -36,6 +38,7 @@ export class ChatClient {
|
|
|
36
38
|
constructor(options) {
|
|
37
39
|
this.options = options;
|
|
38
40
|
this.instanceId = options.clientInstanceId ?? crypto.randomUUID();
|
|
41
|
+
this.reach = new OnlineReachReceiver({ appId: options.appId, profileId: () => this.profileId(), send: message => this.requireClient().sendMsg(message), handler: options.onReach });
|
|
39
42
|
this.notifications = new OnlineNotificationReceiver({ appId: options.appId, profileId: () => this.profileId(),
|
|
40
43
|
send: message => this.requireClient().sendMsg(message), handler: options.onNotification });
|
|
41
44
|
if (options.botKey) {
|
|
@@ -268,7 +271,7 @@ export class ChatClient {
|
|
|
268
271
|
const auth = createRuntimeMsg(profileId, "@hub", ChatMsgOp.AUTH, {
|
|
269
272
|
token,
|
|
270
273
|
appId: this.options.appId,
|
|
271
|
-
notificationReceiver: !!this.options.onNotification,
|
|
274
|
+
notificationReceiver: !!(this.options.onNotification || this.options.onReach),
|
|
272
275
|
as: "user",
|
|
273
276
|
profileId,
|
|
274
277
|
clientInstanceId: this.instanceId,
|
|
@@ -303,7 +306,7 @@ export class ChatClient {
|
|
|
303
306
|
handleIncoming(message) {
|
|
304
307
|
if (message.opcode === ChatMsgOp.NOTIFICATION) {
|
|
305
308
|
if (this.connection === "connected")
|
|
306
|
-
void this.notifications.receive(message).catch(error => this.options.onActivityError?.(error));
|
|
309
|
+
void (message.from === "@hub/reach" ? this.reach.receive(message) : this.notifications.receive(message)).catch(error => this.options.onActivityError?.(error));
|
|
307
310
|
return;
|
|
308
311
|
}
|
|
309
312
|
const update = parseChatUpdate(message, this.options.appId);
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export type OnlineNotification = {
|
|
|
9
9
|
createdAtMs?: number;
|
|
10
10
|
expiresAtMs?: number;
|
|
11
11
|
};
|
|
12
|
-
export type NotificationHandling = "displayed" | "suppressed";
|
|
12
|
+
export type NotificationHandling = "displayed" | "suppressed" | "handled";
|
|
13
13
|
export type OnlineNotificationHandler = (notification: OnlineNotification) => Promise<NotificationHandling>;
|
|
14
14
|
/** Device-local receipt and deduplication; only the host can confirm presentation. */
|
|
15
15
|
export declare class OnlineNotificationReceiver {
|
|
@@ -20,6 +20,7 @@ export declare class OnlineNotificationReceiver {
|
|
|
20
20
|
appId: string;
|
|
21
21
|
profileId: () => string;
|
|
22
22
|
send: (message: ChatRuntimeMessage) => Promise<void>;
|
|
23
|
+
source?: "@hub/notifications" | "@hub/reach";
|
|
23
24
|
handler?: OnlineNotificationHandler;
|
|
24
25
|
now?: () => number;
|
|
25
26
|
});
|
|
@@ -8,13 +8,13 @@ export class OnlineNotificationReceiver {
|
|
|
8
8
|
this.options = options;
|
|
9
9
|
}
|
|
10
10
|
async receive(message) {
|
|
11
|
-
if (message.opcode !== ChatMsgOp.NOTIFICATION || message.from !== "@hub/notifications" || message.to !== this.options.profileId())
|
|
11
|
+
if (message.opcode !== ChatMsgOp.NOTIFICATION || message.from !== (this.options.source ?? "@hub/notifications") || message.to !== this.options.profileId())
|
|
12
12
|
return;
|
|
13
13
|
const body = message.body;
|
|
14
14
|
if (!body || body.kind !== "delivery" || typeof body.attemptId !== "string" || !body.attemptId
|
|
15
15
|
|| typeof body.notificationId !== "string" || !body.notificationId)
|
|
16
16
|
return;
|
|
17
|
-
const ack = (status, code) => this.options.send(createRuntimeMsg(this.options.profileId(), "@hub/notifications", ChatMsgOp.NOTIFICATION, {
|
|
17
|
+
const ack = (status, code) => this.options.send(createRuntimeMsg(this.options.profileId(), this.options.source ?? "@hub/notifications", ChatMsgOp.NOTIFICATION, {
|
|
18
18
|
kind: "ack", attemptId: body.attemptId, notificationId: body.notificationId, status,
|
|
19
19
|
displayed: status === "displayed", code, receivedAtMs: this.now(),
|
|
20
20
|
}));
|
|
@@ -44,7 +44,7 @@ export class OnlineNotificationReceiver {
|
|
|
44
44
|
if (!this.options.handler)
|
|
45
45
|
throw new Error("Notification host unavailable");
|
|
46
46
|
const status = await this.options.handler(structuredClone(body));
|
|
47
|
-
if (status !== "displayed" && status !== "suppressed")
|
|
47
|
+
if (status !== "displayed" && status !== "suppressed" && status !== "handled")
|
|
48
48
|
throw new Error("Invalid notification handling result");
|
|
49
49
|
if (this.handled.size >= 10_000)
|
|
50
50
|
this.handled.delete(this.handled.keys().next().value);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
2
|
+
export type ReachEventType = "call.incoming" | "call.ended" | "social.readStateChanged" | "meeting.invitation" | "meeting.ended";
|
|
3
|
+
export type OnlineReachEvent = {
|
|
4
|
+
eventId: string;
|
|
5
|
+
eventType: ReachEventType;
|
|
6
|
+
appId: string;
|
|
7
|
+
payload: Record<string, unknown>;
|
|
8
|
+
createdAtMs?: number;
|
|
9
|
+
expiresAtMs: number;
|
|
10
|
+
};
|
|
11
|
+
export type OnlineReachHandler = (event: OnlineReachEvent) => Promise<"handled" | "suppressed">;
|
|
12
|
+
/** Uses the verified Hub envelope; handling confirms business processing, never reading. */
|
|
13
|
+
export declare class OnlineReachReceiver {
|
|
14
|
+
private readonly receiver;
|
|
15
|
+
constructor(options: {
|
|
16
|
+
appId: string;
|
|
17
|
+
profileId: () => string;
|
|
18
|
+
send: (message: ChatRuntimeMessage) => Promise<void>;
|
|
19
|
+
handler?: OnlineReachHandler;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
});
|
|
22
|
+
receive(message: ChatRuntimeMessage): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { OnlineNotificationReceiver } from "./notification.js";
|
|
2
|
+
const types = ["call.incoming", "call.ended", "social.readStateChanged", "meeting.invitation", "meeting.ended"];
|
|
3
|
+
/** Uses the verified Hub envelope; handling confirms business processing, never reading. */
|
|
4
|
+
export class OnlineReachReceiver {
|
|
5
|
+
receiver;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.receiver = new OnlineNotificationReceiver({ ...options, source: "@hub/reach", handler: async (notification) => {
|
|
8
|
+
const { eventId, eventType, appId, payload } = notification.data;
|
|
9
|
+
if (eventId !== notification.notificationId || !types.includes(eventType) || typeof appId !== "string"
|
|
10
|
+
|| !payload || typeof payload !== "object" || Array.isArray(payload) || !Number.isFinite(notification.expiresAtMs))
|
|
11
|
+
throw new Error("INVALID_REACH_EVENT");
|
|
12
|
+
if (!options.handler)
|
|
13
|
+
throw new Error("REACH_HANDLER_UNAVAILABLE");
|
|
14
|
+
return options.handler({ eventId: notification.notificationId, eventType: eventType, appId, payload: payload, createdAtMs: notification.createdAtMs, expiresAtMs: notification.expiresAtMs });
|
|
15
|
+
} });
|
|
16
|
+
}
|
|
17
|
+
receive(message) { return this.receiver.receive(message); }
|
|
18
|
+
}
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
1
|
+
import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
2
2
|
/** Keeps local operations until an authoritative projection covers them. */
|
|
3
3
|
export class ReadSyncClient {
|
|
4
4
|
options;
|
|
@@ -57,6 +57,31 @@ export class ReadSyncClient {
|
|
|
57
57
|
this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
|
|
58
58
|
}
|
|
59
59
|
const query = new URLSearchParams({ appId, profileId });
|
|
60
|
+
if (!this.readAfter && current.syncCursor !== "0") {
|
|
61
|
+
const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
|
|
62
|
+
if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
|
|
63
|
+
|| typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
|
|
64
|
+
throw new Error("INVALID_READ_DELTA");
|
|
65
|
+
if (!delta.snapshotRequired) {
|
|
66
|
+
try {
|
|
67
|
+
if (this.controller.signal.aborted)
|
|
68
|
+
throw new Error("READ_SYNC_STOPPED");
|
|
69
|
+
const next = await persistence.update(appId, profileId, state => {
|
|
70
|
+
const applied = delta.changes.reduce(applyReadChange, state);
|
|
71
|
+
if (applied.syncCursor !== delta.syncCursor)
|
|
72
|
+
throw new ReadSyncGap(applied.syncCursor);
|
|
73
|
+
return applied;
|
|
74
|
+
});
|
|
75
|
+
if (!this.controller.signal.aborted)
|
|
76
|
+
this.options.onChange?.(next);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (!(error instanceof ReadSyncGap))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
60
85
|
const snapshot = await this.request(`/api/social/reading/snapshot?${query}`, {
|
|
61
86
|
headers: this.readAfter ? { "x-pmate-read-after-time": String(this.readAfter) } : {},
|
|
62
87
|
});
|
|
@@ -64,6 +89,8 @@ export class ReadSyncClient {
|
|
|
64
89
|
throw new Error("READ_SYNC_STOPPED");
|
|
65
90
|
// Merge into the latest transaction, including reads made while HTTP was pending.
|
|
66
91
|
const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
|
|
92
|
+
if (!next.pending.length)
|
|
93
|
+
this.readAfter = 0;
|
|
67
94
|
if (!this.controller.signal.aborted)
|
|
68
95
|
this.options.onChange?.(next);
|
|
69
96
|
return next;
|