@pmate/chat-sdk 0.9.7 → 0.9.9

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.
@@ -1,3 +1,4 @@
1
+ import type { OnlineReachEvent } from "./reach.js";
1
2
  export type MeetingRoom = {
2
3
  groupId: string;
3
4
  meetingId: string;
@@ -36,6 +37,7 @@ export type CallEnded = {
36
37
  };
37
38
  export type CallClientOptions = {
38
39
  profileId: string;
40
+ clientInstanceId?: string;
39
41
  token: string;
40
42
  url?: string;
41
43
  iceTransportPolicy?: RTCIceTransportPolicy;
@@ -58,6 +60,9 @@ export declare class CallClient {
58
60
  private generation;
59
61
  private closed;
60
62
  private capturePending;
63
+ private attachId?;
64
+ private attachWaiters;
65
+ private terminalCalls;
61
66
  private requestId?;
62
67
  private groupIds;
63
68
  private heartbeat?;
@@ -68,6 +73,14 @@ export declare class CallClient {
68
73
  snapshot(): CallSnapshot;
69
74
  subscribe(listener: (state: CallSnapshot) => void): () => void;
70
75
  connect(): void;
76
+ /** Notification/reach entry: query authoritative state before showing a call. */
77
+ attach(callId: string): void;
78
+ /** Resolves only after an authoritative response has been applied, never after socket send. */
79
+ attachAndWait(callId: string, timeoutMs?: number): Promise<"handled" | "suppressed">;
80
+ private settleAttach;
81
+ /** Pass only events received through the authenticated OnlineReachReceiver. */
82
+ handleReach(event: OnlineReachEvent): Promise<"handled" | "suppressed">;
83
+ private rememberTerminal;
71
84
  watchPeer(peerId?: string): void;
72
85
  watchGroups(groupIds: string[]): void;
73
86
  start(peerId: string): Promise<void>;
@@ -12,6 +12,9 @@ export class CallClient {
12
12
  generation = 0;
13
13
  closed = false;
14
14
  capturePending = false;
15
+ attachId;
16
+ attachWaiters = new Map();
17
+ terminalCalls = new Map();
15
18
  requestId;
16
19
  groupIds = [];
17
20
  heartbeat;
@@ -29,7 +32,7 @@ export class CallClient {
29
32
  const ws = (this.options.createWebSocket ?? (url => new WebSocket(url)))(this.options.url ?? "wss://calls.pmate.chat/call");
30
33
  this.ws = ws;
31
34
  ws.onopen = () => { if (this.ws === ws)
32
- this.send({ type: "auth", token: this.options.token, profileId: this.options.profileId }); };
35
+ this.send({ type: "auth", token: this.options.token, profileId: this.options.profileId, ...(this.options.clientInstanceId ? { clientInstanceId: this.options.clientInstanceId } : {}) }); };
33
36
  ws.onmessage = event => {
34
37
  if (this.ws !== ws)
35
38
  return;
@@ -59,6 +62,87 @@ export class CallClient {
59
62
  this.reconnect = setTimeout(() => this.connect(), 3000);
60
63
  };
61
64
  }
65
+ /** Notification/reach entry: query authoritative state before showing a call. */
66
+ attach(callId) {
67
+ if (this.closed || !callId || callId.length > 128)
68
+ throw new Error("INVALID_CALL_ID");
69
+ this.attachId = callId;
70
+ if (this.state.ready)
71
+ this.send({ type: "call.attach", callId });
72
+ else
73
+ this.connect();
74
+ }
75
+ /** Resolves only after an authoritative response has been applied, never after socket send. */
76
+ attachAndWait(callId, timeoutMs = 4000) {
77
+ if (this.closed || !callId || callId.length > 128)
78
+ return Promise.reject(new Error("INVALID_CALL_ID"));
79
+ if (this.terminalCalls.has(callId))
80
+ return Promise.resolve("suppressed");
81
+ return new Promise((resolve, reject) => {
82
+ const finish = () => {
83
+ clearTimeout(timer);
84
+ const waiters = this.attachWaiters.get(callId);
85
+ waiters?.delete(waiter);
86
+ if (!waiters?.size)
87
+ this.attachWaiters.delete(callId);
88
+ };
89
+ const waiter = { resolve: (result) => { finish(); resolve(result); }, reject: (error) => { finish(); reject(error); } };
90
+ const timer = setTimeout(() => waiter.reject(new Error("CALL_ATTACH_TIMEOUT")), timeoutMs);
91
+ const waiters = this.attachWaiters.get(callId) ?? new Set();
92
+ waiters.add(waiter);
93
+ this.attachWaiters.set(callId, waiters);
94
+ try {
95
+ this.attach(callId);
96
+ }
97
+ catch (error) {
98
+ waiter.reject(error instanceof Error ? error : new Error("CALL_ATTACH_FAILED"));
99
+ }
100
+ });
101
+ }
102
+ settleAttach(callId, result) {
103
+ for (const waiter of [...(this.attachWaiters.get(callId) ?? [])]) {
104
+ if (result instanceof Error)
105
+ waiter.reject(result);
106
+ else
107
+ waiter.resolve(result);
108
+ }
109
+ if (this.attachId === callId)
110
+ this.attachId = undefined;
111
+ }
112
+ /** Pass only events received through the authenticated OnlineReachReceiver. */
113
+ async handleReach(event) {
114
+ if (this.closed)
115
+ throw new Error("CALL_CLIENT_CLOSED");
116
+ const { callId, callerId, calleeId, reason, winningClientInstanceId } = event.payload;
117
+ if (typeof callId !== "string" || !callId || callId.length > 128 || (callerId !== this.options.profileId && calleeId !== this.options.profileId))
118
+ throw new Error("INVALID_CALL_REACH");
119
+ if (event.expiresAtMs <= Date.now())
120
+ return "suppressed";
121
+ if (event.eventType === "call.incoming") {
122
+ if (calleeId !== this.options.profileId)
123
+ throw new Error("INVALID_CALL_RECIPIENT");
124
+ return this.attachAndWait(callId);
125
+ }
126
+ if (event.eventType !== "call.ended" || typeof reason !== "string" || !reason || reason.length > 128)
127
+ throw new Error("INVALID_CALL_REACH");
128
+ if (reason === "answered_elsewhere" && winningClientInstanceId && winningClientInstanceId === this.options.clientInstanceId)
129
+ return "suppressed";
130
+ this.rememberTerminal(callId);
131
+ if (this.state.callId === callId && !["idle", "ended"].includes(this.state.phase)) {
132
+ this.release();
133
+ this.update({ phase: "ended", reason });
134
+ }
135
+ this.settleAttach(callId, "suppressed");
136
+ return "handled";
137
+ }
138
+ rememberTerminal(callId) {
139
+ this.terminalCalls.set(callId, Date.now());
140
+ for (const [id, time] of this.terminalCalls)
141
+ if (Date.now() - time > 300000)
142
+ this.terminalCalls.delete(id);
143
+ while (this.terminalCalls.size > 2000)
144
+ this.terminalCalls.delete(this.terminalCalls.keys().next().value);
145
+ }
62
146
  watchPeer(peerId) {
63
147
  this.update({ watchedPeerId: peerId, peerOnline: undefined, peerInCall: undefined });
64
148
  if (this.state.ready)
@@ -113,6 +197,8 @@ export class CallClient {
113
197
  }
114
198
  }
115
199
  hangup() {
200
+ if (this.state.callId)
201
+ this.rememberTerminal(this.state.callId);
116
202
  if (this.state.callId && !["idle", "ended"].includes(this.state.phase))
117
203
  this.send({ type: "hangup", callId: this.state.callId });
118
204
  this.release();
@@ -186,6 +272,8 @@ export class CallClient {
186
272
  this.closed = true;
187
273
  clearTimeout(this.reconnect);
188
274
  clearInterval(this.heartbeat);
275
+ for (const callId of [...this.attachWaiters.keys()])
276
+ this.settleAttach(callId, new Error("CALL_CLIENT_CLOSED"));
189
277
  this.hangup();
190
278
  this.ws?.close();
191
279
  this.ws = undefined;
@@ -225,6 +313,8 @@ export class CallClient {
225
313
  async receive(message) {
226
314
  if (message.type === "ready") {
227
315
  this.update({ ready: true, error: undefined });
316
+ for (const callId of new Set([...this.attachWaiters.keys(), ...(this.attachId ? [this.attachId] : [])]))
317
+ this.send({ type: "call.attach", callId });
228
318
  if (this.state.watchedPeerId)
229
319
  this.send({ type: "presence.watch", peerId: this.state.watchedPeerId });
230
320
  if (this.groupIds.length)
@@ -256,26 +346,63 @@ export class CallClient {
256
346
  }
257
347
  if (message.requestId && message.requestId !== this.requestId)
258
348
  return;
349
+ if (typeof message.callId === "string" && this.attachWaiters.has(message.callId))
350
+ this.settleAttach(message.callId, new Error(message.code ?? "CALL_ATTACH_FAILED"));
259
351
  if (!message.callId || message.callId === this.state.callId)
260
352
  this.fail(message.code);
261
353
  return;
262
354
  }
355
+ if (message.type === "call.status") {
356
+ if (message.callId !== this.attachId && !this.attachWaiters.has(message.callId))
357
+ return;
358
+ if (message.state === "ended") {
359
+ this.rememberTerminal(message.callId);
360
+ if (["idle", "ended"].includes(this.state.phase) || this.state.callId === message.callId) {
361
+ this.release();
362
+ this.update({ phase: "ended", callId: message.callId, reason: message.reason });
363
+ }
364
+ }
365
+ this.settleAttach(message.callId, message.state === "accepted" && message.callId === this.state.callId ? "handled" : "suppressed");
366
+ return;
367
+ }
263
368
  if (message.type === "ringing") {
369
+ if (this.terminalCalls.has(message.callId)) {
370
+ this.settleAttach(message.callId, "suppressed");
371
+ return;
372
+ }
373
+ if (message.callId === this.state.callId && ["incoming", "ringing", "connecting", "connected"].includes(this.state.phase)) {
374
+ this.settleAttach(message.callId, "handled");
375
+ return;
376
+ }
377
+ if (typeof message.expiresAt === "number" && message.expiresAt <= Date.now()) {
378
+ this.rememberTerminal(message.callId);
379
+ this.settleAttach(message.callId, "suppressed");
380
+ return;
381
+ }
382
+ if (this.attachId === message.callId)
383
+ this.attachId = undefined;
264
384
  if (message.direction === "outgoing" && (this.state.phase !== "requesting" || message.requestId !== this.requestId)) {
265
385
  this.send({ type: "hangup", callId: message.callId });
266
386
  return;
267
387
  }
268
388
  if (message.direction === "incoming" && !["idle", "ended"].includes(this.state.phase)) {
269
389
  this.send({ type: "hangup", callId: message.callId });
390
+ this.settleAttach(message.callId, "suppressed");
270
391
  return;
271
392
  }
272
393
  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);
394
+ this.armTimeout(typeof message.expiresAt === "number" ? Math.max(1, message.expiresAt - Date.now()) : 35000);
395
+ this.settleAttach(message.callId, "handled");
274
396
  return;
275
397
  }
398
+ if (message.type === "ended" && typeof message.callId === "string") {
399
+ this.rememberTerminal(message.callId);
400
+ this.settleAttach(message.callId, "suppressed");
401
+ }
276
402
  if (message.callId !== this.state.callId)
277
403
  return;
278
404
  if (message.type === "ended") {
405
+ this.rememberTerminal(message.callId);
279
406
  const event = { callId: message.callId, peerId: this.state.peerId, direction: this.state.direction, reason: message.reason, durationMs: message.durationMs };
280
407
  this.release();
281
408
  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);
@@ -14,3 +14,4 @@ export * from "./presence.js";
14
14
  export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
16
  export * from "./readSync.js";
17
+ export * from "./reach.js";
@@ -14,3 +14,4 @@ export * from "./presence.js";
14
14
  export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
16
  export * from "./readSync.js";
17
+ export * from "./reach.js";
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -28,15 +28,6 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "scripts": {
32
- "clean": "rimraf lib",
33
- "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
34
- "prepack": "pnpm run build",
35
- "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
36
- "test": "vitest run",
37
- "storybook": "storybook dev -p 6009",
38
- "build-storybook": "storybook build --output-dir dist"
39
- },
40
31
  "dependencies": {
41
32
  "@emoji-mart/data": "^1.2.1",
42
33
  "@emoji-mart/react": "^1.1.1",
@@ -51,16 +42,24 @@
51
42
  "devDependencies": {
52
43
  "@storybook/addon-docs": "^9.0.17",
53
44
  "@storybook/react-vite": "9.0.17",
54
- "@tailwindcss/vite": "catalog:",
55
- "@types/react": "catalog:",
56
- "@types/react-dom": "catalog:",
45
+ "@tailwindcss/vite": "^4.1.3",
46
+ "@types/react": "^19.2.7",
47
+ "@types/react-dom": "^19.2.3",
57
48
  "fake-indexeddb": "^6.2.5",
58
- "react": "catalog:",
59
- "react-dom": "catalog:",
49
+ "react": "^19.1.1",
50
+ "react-dom": "^19.1.1",
60
51
  "rimraf": "^6.1.2",
61
52
  "storybook": "^9.0.17",
62
- "tailwindcss": "catalog:",
53
+ "tailwindcss": "^4.0.14",
63
54
  "typescript": "5.9.2",
64
55
  "vitest": "^4.0.17"
56
+ },
57
+ "scripts": {
58
+ "clean": "rimraf lib",
59
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
60
+ "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
61
+ "test": "vitest run",
62
+ "storybook": "storybook dev -p 6009",
63
+ "build-storybook": "storybook build --output-dir dist"
65
64
  }
66
- }
65
+ }