@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -81
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +148 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +11 -0
- package/lib/chatError.js +13 -0
- package/lib/chatTypes.d.ts +121 -0
- package/lib/chatTypes.js +1 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/internal/chatEngine.d.ts +131 -0
- package/lib/internal/chatEngine.js +280 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +2 -0
- package/lib/runtime/chatClient.js +8 -3
- package/lib/runtime/chatError.d.ts +3 -0
- package/lib/runtime/chatError.js +15 -0
- package/lib/runtime/chatSession.d.ts +127 -13
- package/lib/runtime/chatSession.js +727 -40
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +76 -0
- package/lib/runtime/groupClient.js +325 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +40 -2
- package/lib/runtime/messageStore.js +227 -11
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +89 -0
- package/lib/runtime/outgoingContent.js +113 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +97 -9
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +26 -9
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/responseCache.d.ts +59 -0
- package/lib/runtime/responseCache.js +232 -0
- package/lib/runtime/socialReadClient.d.ts +4 -2
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/package.json +27 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ChatError } from "./chatError.js";
|
|
2
|
+
/** Cancel the caller's wait, not the underlying shared operation. */
|
|
3
|
+
export function waitForCaller(task, signal, onAbandonedValue) {
|
|
4
|
+
if (!signal)
|
|
5
|
+
return task;
|
|
6
|
+
if (signal.aborted) {
|
|
7
|
+
void task.then(value => onAbandonedValue?.(value), () => { });
|
|
8
|
+
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
9
|
+
}
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let settled = false;
|
|
12
|
+
const abort = () => { if (settled)
|
|
13
|
+
return; settled = true; signal.removeEventListener("abort", abort); reject(signal.reason ?? new DOMException("Aborted", "AbortError")); };
|
|
14
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
15
|
+
task.then(value => { signal.removeEventListener("abort", abort); if (settled) {
|
|
16
|
+
onAbandonedValue?.(value);
|
|
17
|
+
return;
|
|
18
|
+
} settled = true; resolve(value); }, error => { signal.removeEventListener("abort", abort); if (!settled) {
|
|
19
|
+
settled = true;
|
|
20
|
+
reject(error);
|
|
21
|
+
} });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** One scoped credential owner; stale 401s share one refresh rather than cascading. */
|
|
25
|
+
export class ChatCredentials {
|
|
26
|
+
provider;
|
|
27
|
+
origin;
|
|
28
|
+
transport;
|
|
29
|
+
lifetime = new AbortController();
|
|
30
|
+
flight;
|
|
31
|
+
refreshing;
|
|
32
|
+
latest;
|
|
33
|
+
recovery;
|
|
34
|
+
recoveredFrom = -1;
|
|
35
|
+
version = 0;
|
|
36
|
+
constructor(provider, origin, transport = fetch) {
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
this.origin = origin;
|
|
39
|
+
this.transport = transport;
|
|
40
|
+
}
|
|
41
|
+
acquire(forceRefresh = false) {
|
|
42
|
+
if (this.lifetime.signal.aborted)
|
|
43
|
+
return Promise.reject(new ChatError("closed", "Chat credentials closed"));
|
|
44
|
+
if (this.refreshing)
|
|
45
|
+
return this.refreshing;
|
|
46
|
+
if (!forceRefresh && this.flight)
|
|
47
|
+
return this.flight;
|
|
48
|
+
const version = forceRefresh ? ++this.version : this.version;
|
|
49
|
+
const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(15000)]);
|
|
50
|
+
const task = waitForCaller(Promise.resolve().then(() => this.provider({ forceRefresh, signal })), signal).then(token => {
|
|
51
|
+
if (this.lifetime.signal.aborted)
|
|
52
|
+
throw new ChatError("closed", "Chat credentials closed");
|
|
53
|
+
if (typeof token !== "string" || !token.trim())
|
|
54
|
+
throw new ChatError("authentication-required", "A valid access token is required");
|
|
55
|
+
if (version < this.version && this.latest)
|
|
56
|
+
return this.latest;
|
|
57
|
+
const value = { token, version };
|
|
58
|
+
this.latest = value;
|
|
59
|
+
return value;
|
|
60
|
+
}).catch(error => {
|
|
61
|
+
if (error?.status === 401)
|
|
62
|
+
throw new ChatError("authentication-required", "Credentials require a fresh login", { cause: error, status: 401 });
|
|
63
|
+
if (error?.status === 403)
|
|
64
|
+
throw new ChatError("access-denied", "Credential access denied", { cause: error, status: 403 });
|
|
65
|
+
throw error;
|
|
66
|
+
}).finally(() => { if (this.flight === task)
|
|
67
|
+
this.flight = undefined; if (this.refreshing === task)
|
|
68
|
+
this.refreshing = undefined; });
|
|
69
|
+
if (forceRefresh)
|
|
70
|
+
this.refreshing = task;
|
|
71
|
+
else
|
|
72
|
+
this.flight = task;
|
|
73
|
+
return task;
|
|
74
|
+
}
|
|
75
|
+
recover(used) {
|
|
76
|
+
if (this.latest && this.latest.version > used.version)
|
|
77
|
+
return Promise.resolve(this.latest);
|
|
78
|
+
if (this.recovery && used.version <= this.recoveredFrom)
|
|
79
|
+
return this.recovery;
|
|
80
|
+
this.recoveredFrom = used.version;
|
|
81
|
+
this.recovery = this.acquire(true);
|
|
82
|
+
return this.recovery;
|
|
83
|
+
}
|
|
84
|
+
getToken = async () => { return (await this.acquire()).token; };
|
|
85
|
+
fetch = async (input, init) => {
|
|
86
|
+
const transport = this.transport;
|
|
87
|
+
const url = new URL(input instanceof Request ? input.url : String(input)), headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
88
|
+
// Never attach an app credential to uploads, other origins or unauthenticated requests.
|
|
89
|
+
if (url.origin !== this.origin || !headers.has("authorization"))
|
|
90
|
+
return transport(input, init);
|
|
91
|
+
const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
92
|
+
const signal = AbortSignal.any([this.lifetime.signal, ...(callerSignal ? [callerSignal] : [])]);
|
|
93
|
+
signal?.throwIfAborted();
|
|
94
|
+
const original = input instanceof Request ? input.clone() : input;
|
|
95
|
+
const send = async (token) => {
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
98
|
+
return transport(original instanceof Request ? original.clone() : original, { ...init, headers, signal });
|
|
99
|
+
};
|
|
100
|
+
const used = await waitForCaller(this.acquire(), signal ?? undefined);
|
|
101
|
+
const response = await send(used.token);
|
|
102
|
+
if (response.status !== 401)
|
|
103
|
+
return response;
|
|
104
|
+
// Denial (403) is terminal. Only an unauthenticated response gets one replay.
|
|
105
|
+
void response.body?.cancel().catch(() => { });
|
|
106
|
+
const next = await waitForCaller(this.recover(used), signal);
|
|
107
|
+
return send(next.token);
|
|
108
|
+
};
|
|
109
|
+
close() { this.lifetime.abort(new ChatError("closed", "Chat credentials closed")); }
|
|
110
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Durable file reference. Upload limits and download policy belong to the host. */
|
|
2
|
+
export type ChatFile = Readonly<{
|
|
3
|
+
url: string;
|
|
4
|
+
name: string;
|
|
5
|
+
size: number;
|
|
6
|
+
mimeType?: string;
|
|
7
|
+
expiresAt?: number;
|
|
8
|
+
}>;
|
|
9
|
+
export declare function parseChatFile(value: unknown): ChatFile | undefined;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parseChatFile(value) {
|
|
2
|
+
if (!value || typeof value !== "object")
|
|
3
|
+
return;
|
|
4
|
+
const file = value;
|
|
5
|
+
if (typeof file.name !== "string" || !file.name.trim() || file.name.length > 255 || /[\u0000-\u001f\\/]/u.test(file.name) || typeof file.url !== "string" || file.url.length > 8192 || !Number.isSafeInteger(file.size) || Number(file.size) < 0)
|
|
6
|
+
return;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(file.url);
|
|
9
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
return { url: file.url, name: file.name, size: file.size,
|
|
16
|
+
...(typeof file.mimeType === "string" && file.mimeType.length <= 255 && !/[\r\n]/.test(file.mimeType) ? { mimeType: file.mimeType } : {}),
|
|
17
|
+
...(typeof file.expiresAt === "number" && Number.isSafeInteger(file.expiresAt) && file.expiresAt > 0 ? { expiresAt: file.expiresAt } : {}), };
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Chat } from "./chatContract.js";
|
|
2
|
+
import type { ChatSnapshot, ChatThreadSnapshot } from "./chatTypes.js";
|
|
3
|
+
export interface ChatStore {
|
|
4
|
+
subscribeThread(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot)[]): () => void;
|
|
5
|
+
getSnapshot(): ChatSnapshot;
|
|
6
|
+
getSnapshot(threadId: string): ChatThreadSnapshot;
|
|
7
|
+
subscribeChanges(listener: () => void): () => void;
|
|
8
|
+
subscribeChanges(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot)[]): () => void;
|
|
9
|
+
}
|
|
10
|
+
/** Synchronous memory reads are for external-store integrations, not cache selection. */
|
|
11
|
+
export declare function getChatStore(chat: Chat): ChatStore;
|
package/lib/framework.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { chatEngine } from "./internal/chatRegistry.js";
|
|
2
|
+
const stores = new WeakMap();
|
|
3
|
+
/** Synchronous memory reads are for external-store integrations, not cache selection. */
|
|
4
|
+
export function getChatStore(chat) {
|
|
5
|
+
let store = stores.get(chat);
|
|
6
|
+
if (!store) {
|
|
7
|
+
const engine = chatEngine(chat);
|
|
8
|
+
store = { subscribeThread: (id, listener, fields) => engine.subscribeThread(id, listener, { fields, emitCurrent: false }), getSnapshot: ((id) => id === undefined ? engine.getSnapshot() : engine.getSnapshot(id)), subscribeChanges: ((target, callback, fields) => typeof target === "function" ? engine.subscribe(target, { emitCurrent: false }) : engine.subscribe(target, callback, { fields, emitCurrent: false })) };
|
|
9
|
+
stores.set(chat, store);
|
|
10
|
+
}
|
|
11
|
+
return store;
|
|
12
|
+
}
|
package/lib/history.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Chat } from "./chatContract.js";
|
|
2
|
+
import type { ChatThreadSnapshot, ReadOptions } from "./chatTypes.js";
|
|
3
|
+
/** Explicit complete-history operation for search/export; may scan many pages. */
|
|
4
|
+
export declare function loadThreadHistory(chat: Chat, threadId: string, { signal }?: ReadOptions): Promise<ChatThreadSnapshot>;
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { chatEngine } from "./internal/chatRegistry.js";
|
|
2
|
+
import { waitForCaller } from "./credentials.js";
|
|
3
|
+
/** Explicit complete-history operation for search/export; may scan many pages. */
|
|
4
|
+
export function loadThreadHistory(chat, threadId, { signal } = {}) {
|
|
5
|
+
if (signal?.aborted)
|
|
6
|
+
return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
7
|
+
return waitForCaller(chatEngine(chat).getMessages(threadId, { all: true }), signal);
|
|
8
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Chat, ChatSnapshot, ChatThreadSnapshot } from "../chat.js";
|
|
2
|
+
/** React consumes the same stable view as callback subscribers. No App-side cache. */
|
|
3
|
+
export declare function useChatSnapshot(chat: Chat | undefined): ChatSnapshot;
|
|
4
|
+
export declare function useChatSnapshot(chat: Chat | undefined, threadId: string): ChatThreadSnapshot;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
import { getChatStore } from "../framework.js";
|
|
3
|
+
const emptySummary = Object.freeze({ threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" });
|
|
4
|
+
const emptyThread = Object.freeze({ threadId: "", messages: [], status: "pending", ready: false, hasEarlier: false, unread: undefined, group: undefined, readCounts: undefined });
|
|
5
|
+
export function useChatSnapshot(chat, threadId) {
|
|
6
|
+
const subscribe = useCallback((notify) => !chat ? () => { } : threadId === undefined ? getChatStore(chat).subscribeChanges(notify) : getChatStore(chat).subscribeThread(threadId, notify), [chat, threadId]);
|
|
7
|
+
const snapshot = useCallback(() => threadId === undefined ? (chat ? getChatStore(chat).getSnapshot() : undefined) ?? emptySummary : (chat ? getChatStore(chat).getSnapshot(threadId) : undefined) ?? emptyThread, [chat, threadId]);
|
|
8
|
+
return useSyncExternalStore(subscribe, snapshot, () => threadId === undefined ? emptySummary : emptyThread);
|
|
9
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
+
export { createChat, type Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type ReadOptions, type ReadersQuery, type GroupChanges } from "./chat.js";
|
|
45
|
+
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
package/lib/index.js
CHANGED
|
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
+
export { createChat, ChatError } from "./chat.js";
|
|
45
|
+
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export type { ChatMessage } from "../chatTypes.js";
|
|
2
|
+
import { type CreateChatOptions } from "../runtime/createChat.js";
|
|
3
|
+
import type { ChatSession } from "../runtime/chatSession.js";
|
|
4
|
+
import type { CachedChatMessage } from "../runtime/cachedMessage.js";
|
|
5
|
+
import type { ConversationTarget } from "../runtime/conversationDirectory.js";
|
|
6
|
+
import type { OutgoingContent as RuntimeContent } from "../runtime/outgoingContent.js";
|
|
7
|
+
export { ChatError } from "../chatError.js";
|
|
8
|
+
export type { ChatErrorCode } from "../chatError.js";
|
|
9
|
+
export type { CreateChatOptions, ConversationTarget };
|
|
10
|
+
export type OutgoingContent = Exclude<RuntimeContent, {
|
|
11
|
+
groupCreate: unknown;
|
|
12
|
+
} | {
|
|
13
|
+
groupUpdate: unknown;
|
|
14
|
+
}>;
|
|
15
|
+
import type { ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
|
|
16
|
+
export type { ChatGroup, ConversationInfo, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
|
|
17
|
+
export type MessageQuery = {
|
|
18
|
+
earlier?: boolean;
|
|
19
|
+
all?: boolean;
|
|
20
|
+
refresh?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type ReadersQuery = {
|
|
23
|
+
afterProfileId?: string;
|
|
24
|
+
revision?: number;
|
|
25
|
+
};
|
|
26
|
+
export type GroupChanges = {
|
|
27
|
+
expectedRevision?: string;
|
|
28
|
+
addMembers?: readonly string[];
|
|
29
|
+
removeMembers?: readonly string[];
|
|
30
|
+
title?: string;
|
|
31
|
+
avatar?: string;
|
|
32
|
+
};
|
|
33
|
+
export type SendOptions = {
|
|
34
|
+
waitFor?: "accepted" | "delivered";
|
|
35
|
+
};
|
|
36
|
+
export type SentMessage = Readonly<{
|
|
37
|
+
id: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
}>;
|
|
40
|
+
type SubscribeOptions<T> = {
|
|
41
|
+
fields?: readonly (keyof T)[];
|
|
42
|
+
emitCurrent?: boolean;
|
|
43
|
+
};
|
|
44
|
+
/** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
|
|
45
|
+
export declare class ChatEngine {
|
|
46
|
+
readonly advanced: ChatSession<CachedChatMessage>;
|
|
47
|
+
private options;
|
|
48
|
+
private groups;
|
|
49
|
+
private group;
|
|
50
|
+
private present;
|
|
51
|
+
private redactedQuotes;
|
|
52
|
+
private messageLists;
|
|
53
|
+
private messages;
|
|
54
|
+
private views;
|
|
55
|
+
private summary?;
|
|
56
|
+
private targets;
|
|
57
|
+
private errors;
|
|
58
|
+
private listeners;
|
|
59
|
+
private closed;
|
|
60
|
+
constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
|
|
61
|
+
private operation;
|
|
62
|
+
private deny;
|
|
63
|
+
private threadOperation;
|
|
64
|
+
start(): Promise<void>;
|
|
65
|
+
/** Resolve a scoped ID locally, registering its target when needed. No remote group is created. */
|
|
66
|
+
resolveThreadId(target: ConversationTarget): Promise<string>;
|
|
67
|
+
/** A trusted recent window. Source selection and synchronization belong to the SDK. */
|
|
68
|
+
getThread(threadId: string, options?: ReadOptions): Promise<ChatThreadSnapshot>;
|
|
69
|
+
loadEarlier(threadId: string, options?: ReadOptions): Promise<ChatThreadSnapshot>;
|
|
70
|
+
refreshThread(threadId: string, options?: ReadOptions): Promise<ChatThreadSnapshot>;
|
|
71
|
+
private readForCaller;
|
|
72
|
+
private threadSubscribers;
|
|
73
|
+
private openQueue;
|
|
74
|
+
private opening;
|
|
75
|
+
private drainOpens;
|
|
76
|
+
/** Listen first, then open the local window; concurrent consumers share the initial read. */
|
|
77
|
+
subscribeThread(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
|
|
78
|
+
/** @deprecated Use resolveThreadId. Retained only for the migration window. */
|
|
79
|
+
getConversation(target: ConversationTarget): Promise<string>;
|
|
80
|
+
getSnapshot(): ChatSnapshot;
|
|
81
|
+
getSnapshot(threadId: string): ChatThreadSnapshot;
|
|
82
|
+
getMessages(threadId: string, query?: MessageQuery): Promise<ChatThreadSnapshot>;
|
|
83
|
+
sendMessage(threadId: string, content: OutgoingContent, options?: SendOptions): Promise<SentMessage>;
|
|
84
|
+
retryMessage(messageId: string): Promise<SentMessage>;
|
|
85
|
+
subscribe(listener: (snapshot: ChatSnapshot) => void, options?: SubscribeOptions<ChatSnapshot>): () => void;
|
|
86
|
+
subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
|
|
87
|
+
private sessionFields;
|
|
88
|
+
private notify;
|
|
89
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<void>;
|
|
90
|
+
markRead(threadId: string): Promise<void>;
|
|
91
|
+
getReaders(threadId: string, messageId: string, page?: ReadersQuery, signal?: AbortSignal): Promise<import("../runtime/messageReaders.js").MessageReadersPage>;
|
|
92
|
+
watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
|
|
93
|
+
handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
|
|
94
|
+
getVoiceAttachment(threadId: string, messageId: string): Promise<import("../runtime/durableSender.js").VoiceAttachmentLease>;
|
|
95
|
+
getGroup(threadId: string, options?: {
|
|
96
|
+
refresh?: boolean;
|
|
97
|
+
}): Promise<Readonly<{
|
|
98
|
+
groupId: string;
|
|
99
|
+
teamId: string;
|
|
100
|
+
ownerId: string;
|
|
101
|
+
title: string;
|
|
102
|
+
avatar?: string;
|
|
103
|
+
memberIds: readonly string[];
|
|
104
|
+
revision: string;
|
|
105
|
+
}>>;
|
|
106
|
+
createGroup(input: {
|
|
107
|
+
groupId?: string;
|
|
108
|
+
title: string;
|
|
109
|
+
members: readonly string[];
|
|
110
|
+
}): Promise<{
|
|
111
|
+
threadId: string;
|
|
112
|
+
groupId: string;
|
|
113
|
+
teamId: string;
|
|
114
|
+
ownerId: string;
|
|
115
|
+
title: string;
|
|
116
|
+
avatar?: string;
|
|
117
|
+
memberIds: readonly string[];
|
|
118
|
+
revision: string;
|
|
119
|
+
}>;
|
|
120
|
+
updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<{
|
|
121
|
+
groupId: string;
|
|
122
|
+
teamId: string;
|
|
123
|
+
ownerId: string;
|
|
124
|
+
title: string;
|
|
125
|
+
avatar?: string;
|
|
126
|
+
memberIds: readonly string[];
|
|
127
|
+
revision: string;
|
|
128
|
+
}>>;
|
|
129
|
+
revokeAccess(): Promise<void>;
|
|
130
|
+
close(): Promise<void>;
|
|
131
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createMessagePresenter } from "../messageView.js";
|
|
2
|
+
import { createChat as createSession } from "../runtime/createChat.js";
|
|
3
|
+
import { asChatError } from "../runtime/chatError.js";
|
|
4
|
+
import { ChatError } from "../chatError.js";
|
|
5
|
+
export { ChatError } from "../chatError.js";
|
|
6
|
+
const emptyMessages = Object.freeze([]);
|
|
7
|
+
/** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
|
|
8
|
+
export class ChatEngine {
|
|
9
|
+
advanced;
|
|
10
|
+
options;
|
|
11
|
+
groups = new WeakMap();
|
|
12
|
+
group(value) {
|
|
13
|
+
if (!value)
|
|
14
|
+
return undefined;
|
|
15
|
+
let projected = this.groups.get(value);
|
|
16
|
+
if (!projected) {
|
|
17
|
+
const { versionNumber: _, ...visible } = value;
|
|
18
|
+
projected = visible;
|
|
19
|
+
this.groups.set(value, projected);
|
|
20
|
+
}
|
|
21
|
+
return projected;
|
|
22
|
+
}
|
|
23
|
+
present = createMessagePresenter();
|
|
24
|
+
redactedQuotes = new WeakMap();
|
|
25
|
+
messageLists = new WeakMap();
|
|
26
|
+
messages(items) {
|
|
27
|
+
let values = this.messageLists.get(items);
|
|
28
|
+
if (!values) {
|
|
29
|
+
const recalled = new Set(items.filter(item => item.recalled).map(item => item.id));
|
|
30
|
+
values = items.map(item => {
|
|
31
|
+
const value = this.present(item);
|
|
32
|
+
if (!value.quote?.messageId || !recalled.has(value.quote.messageId))
|
|
33
|
+
return value;
|
|
34
|
+
let hidden = this.redactedQuotes.get(value);
|
|
35
|
+
if (!hidden) {
|
|
36
|
+
hidden = { ...value, quote: { ...value.quote, content: "", recalled: true } };
|
|
37
|
+
this.redactedQuotes.set(value, hidden);
|
|
38
|
+
}
|
|
39
|
+
return hidden;
|
|
40
|
+
});
|
|
41
|
+
this.messageLists.set(items, values);
|
|
42
|
+
}
|
|
43
|
+
return values;
|
|
44
|
+
}
|
|
45
|
+
views = new Map();
|
|
46
|
+
summary;
|
|
47
|
+
targets = new Map();
|
|
48
|
+
errors = new Map();
|
|
49
|
+
listeners = new Map();
|
|
50
|
+
closed = false;
|
|
51
|
+
constructor(advanced, options) {
|
|
52
|
+
this.advanced = advanced;
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async operation(run) { try {
|
|
56
|
+
return await run();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw asChatError(error);
|
|
60
|
+
} }
|
|
61
|
+
async deny(threadId, failure) {
|
|
62
|
+
this.errors.set(threadId, failure);
|
|
63
|
+
this.targets.delete(threadId);
|
|
64
|
+
const retiring = this.advanced.revokeAccess();
|
|
65
|
+
this.notify(threadId);
|
|
66
|
+
try {
|
|
67
|
+
await retiring;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
failure.cause = new AggregateError([failure.cause, error], "Access withdrawn; cache cleanup incomplete");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
threadOperation(threadId, run) {
|
|
74
|
+
return this.operation(async () => {
|
|
75
|
+
const denied = threadId ? this.errors.get(threadId) : undefined;
|
|
76
|
+
if (denied?.code === "access-denied")
|
|
77
|
+
throw denied;
|
|
78
|
+
try {
|
|
79
|
+
return await run();
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const failure = asChatError(error);
|
|
83
|
+
if (threadId && failure.code === "access-denied")
|
|
84
|
+
await this.deny(threadId, failure);
|
|
85
|
+
throw failure;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
|
|
90
|
+
/** Resolve a scoped ID locally, registering its target when needed. No remote group is created. */
|
|
91
|
+
resolveThreadId(target) { return this.getConversation(target); }
|
|
92
|
+
/** A trusted recent window. Source selection and synchronization belong to the SDK. */
|
|
93
|
+
getThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId), options); }
|
|
94
|
+
loadEarlier(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { earlier: true }), options); }
|
|
95
|
+
refreshThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { refresh: true }), options); }
|
|
96
|
+
readForCaller(run, { signal }) {
|
|
97
|
+
if (signal?.aborted)
|
|
98
|
+
return Promise.reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError"));
|
|
99
|
+
if (!signal)
|
|
100
|
+
return run();
|
|
101
|
+
// A caller's cancellation never aborts another subscriber's shared read.
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); };
|
|
104
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
105
|
+
run().then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
threadSubscribers = new Map();
|
|
109
|
+
openQueue = new Set();
|
|
110
|
+
opening = 0;
|
|
111
|
+
drainOpens() {
|
|
112
|
+
while (!this.closed && this.opening < 4 && this.openQueue.size) {
|
|
113
|
+
const id = this.openQueue.values().next().value;
|
|
114
|
+
this.openQueue.delete(id);
|
|
115
|
+
if (!this.threadSubscribers.has(id))
|
|
116
|
+
continue;
|
|
117
|
+
this.opening++;
|
|
118
|
+
void this.getThread(id).catch(() => { }).finally(() => { this.opening--; this.drainOpens(); });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Listen first, then open the local window; concurrent consumers share the initial read. */
|
|
122
|
+
subscribeThread(threadId, listener, options = {}) {
|
|
123
|
+
const off = this.subscribe(threadId, listener, options), count = this.threadSubscribers.get(threadId) ?? 0;
|
|
124
|
+
this.threadSubscribers.set(threadId, count + 1);
|
|
125
|
+
if (!count) {
|
|
126
|
+
this.openQueue.add(threadId);
|
|
127
|
+
this.drainOpens();
|
|
128
|
+
}
|
|
129
|
+
let disposed = false;
|
|
130
|
+
return () => {
|
|
131
|
+
if (disposed)
|
|
132
|
+
return;
|
|
133
|
+
disposed = true;
|
|
134
|
+
off();
|
|
135
|
+
const remaining = (this.threadSubscribers.get(threadId) ?? 1) - 1;
|
|
136
|
+
if (remaining)
|
|
137
|
+
this.threadSubscribers.set(threadId, remaining);
|
|
138
|
+
else {
|
|
139
|
+
this.threadSubscribers.delete(threadId);
|
|
140
|
+
this.openQueue.delete(threadId);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** @deprecated Use resolveThreadId. Retained only for the migration window. */
|
|
145
|
+
getConversation(target) {
|
|
146
|
+
return this.operation(async () => {
|
|
147
|
+
const id = await this.advanced.getConversation(target);
|
|
148
|
+
const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
|
|
149
|
+
this.targets.delete(id);
|
|
150
|
+
this.targets.set(id, value);
|
|
151
|
+
if (this.targets.size > 256)
|
|
152
|
+
this.targets.delete(this.targets.keys().next().value);
|
|
153
|
+
this.notify(id);
|
|
154
|
+
return id;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
getSnapshot(threadId) {
|
|
158
|
+
if (threadId === undefined) {
|
|
159
|
+
const source = this.advanced.getSnapshot(), previous = this.summary;
|
|
160
|
+
const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: this.messages(this.advanced.getSnapshot(thread.threadId).timeline).at(-1), group: this.group(thread.group), unread: thread.unread, outgoingCount: thread.outgoingCount }));
|
|
161
|
+
const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
|
|
162
|
+
const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
|
|
163
|
+
if (previous && previous.connection === source.connection && previous.badgeCount === badgeCount && previous.unreadMessageCount === unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
|
|
164
|
+
return previous;
|
|
165
|
+
return this.summary = { threads, connection: source.connection, badgeCount, unreadMessageCount };
|
|
166
|
+
}
|
|
167
|
+
const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
|
|
168
|
+
const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
|
|
169
|
+
const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
|
|
170
|
+
const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
|
|
171
|
+
// Partial migrated rows never masquerade as a trusted recent window.
|
|
172
|
+
const next = { threadId, conversation, messages: !denied && source.complete ? this.messages(source.timeline) : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : this.group(source.group), readCounts: denied ? undefined : source.readCounts, error, status: !denied && source.complete ? "ready" : error ? "error" : "pending" };
|
|
173
|
+
if (previous && Object.keys(next).every(key => next[key] === previous[key]))
|
|
174
|
+
return previous;
|
|
175
|
+
this.views.set(threadId, next);
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
async getMessages(threadId, query = {}) {
|
|
179
|
+
try {
|
|
180
|
+
if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
|
|
181
|
+
throw new ChatError("invalid-input", "Unsupported message query");
|
|
182
|
+
const page = await this.advanced.getMessages(threadId, query);
|
|
183
|
+
if (!page.complete)
|
|
184
|
+
throw new ChatError("unavailable", "Messages are not ready; retry when connected");
|
|
185
|
+
this.errors.delete(threadId);
|
|
186
|
+
this.notify(threadId);
|
|
187
|
+
return this.getSnapshot(threadId);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const failure = asChatError(error);
|
|
191
|
+
if (!this.closed) {
|
|
192
|
+
if (failure.code === "access-denied")
|
|
193
|
+
await this.deny(threadId, failure);
|
|
194
|
+
else {
|
|
195
|
+
this.errors.set(threadId, failure);
|
|
196
|
+
this.notify(threadId);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
throw failure;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
sendMessage(threadId, content, options = {}) {
|
|
203
|
+
return this.threadOperation(threadId, async () => {
|
|
204
|
+
if (content && ("groupCreate" in content || "groupUpdate" in content))
|
|
205
|
+
throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
|
|
206
|
+
const result = await this.advanced.sendMessage(threadId, content, options);
|
|
207
|
+
return { id: result.hash, timestamp: result.t };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
|
|
211
|
+
subscribe(target, callback, options = {}) {
|
|
212
|
+
const global = typeof target === "function", key = global ? "" : target;
|
|
213
|
+
const config = global ? callback : options;
|
|
214
|
+
const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
|
|
215
|
+
let previous = read();
|
|
216
|
+
const emit = () => {
|
|
217
|
+
const value = read();
|
|
218
|
+
const fields = config?.fields;
|
|
219
|
+
const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
|
|
220
|
+
previous = value;
|
|
221
|
+
if (changed) {
|
|
222
|
+
try {
|
|
223
|
+
(global ? target : callback)(value);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
try {
|
|
227
|
+
this.options.onError(error);
|
|
228
|
+
}
|
|
229
|
+
catch { /* A consumer cannot break other consumers or cache publication. */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
let listeners = this.listeners.get(key);
|
|
234
|
+
if (!listeners) {
|
|
235
|
+
listeners = new Set();
|
|
236
|
+
this.listeners.set(key, listeners);
|
|
237
|
+
}
|
|
238
|
+
listeners.add(emit);
|
|
239
|
+
const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
|
|
240
|
+
const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
|
|
241
|
+
this.listeners.delete(key); };
|
|
242
|
+
if (config?.emitCurrent !== false) {
|
|
243
|
+
try {
|
|
244
|
+
(global ? target : callback)(previous);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
dispose();
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return dispose;
|
|
252
|
+
}
|
|
253
|
+
sessionFields(fields) {
|
|
254
|
+
return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" || field === "status" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
|
|
255
|
+
}
|
|
256
|
+
notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
|
|
257
|
+
observeMessages(threadId, ids, event) { return this.threadOperation(threadId, async () => { await this.advanced.observeMessages(threadId, ids, event); }); }
|
|
258
|
+
markRead(threadId) { return this.threadOperation(threadId, async () => { await this.advanced.markRead(threadId); }); }
|
|
259
|
+
getReaders(threadId, messageId, page = {}, signal) {
|
|
260
|
+
return this.threadOperation(threadId, async () => {
|
|
261
|
+
if (Object.keys(page).some(key => !["afterProfileId", "revision"].includes(key)))
|
|
262
|
+
throw new ChatError("invalid-input", "Unsupported reader query");
|
|
263
|
+
return this.advanced.getReaders(threadId, messageId, page, signal);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
|
|
267
|
+
handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
|
|
268
|
+
getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
|
|
269
|
+
getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
|
|
270
|
+
createGroup(input) {
|
|
271
|
+
return this.operation(async () => {
|
|
272
|
+
const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
|
|
273
|
+
const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
|
|
274
|
+
return { ...this.group(group), threadId };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
|
|
278
|
+
revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
|
|
279
|
+
close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.threadSubscribers.clear(); this.openQueue.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
|
|
280
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Chat } from "../chatContract.js";
|
|
2
|
+
import { type CreateChatOptions } from "../runtime/createChat.js";
|
|
3
|
+
/** Build only the public capabilities; no aliases or engine fields on the returned object. */
|
|
4
|
+
export declare function createPublicChat(options: CreateChatOptions): Chat;
|