@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.
@@ -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>;
@@ -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);
@@ -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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
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",