@pmate/chat-sdk 0.12.0-next.73 → 0.12.0-next.75
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/chatContract.d.ts +3 -0
- package/lib/chatError.d.ts +3 -0
- package/lib/chatError.js +3 -0
- package/lib/chatTypes.d.ts +9 -3
- package/lib/internal/chatEngine.d.ts +16 -17
- package/lib/internal/chatEngine.js +7 -6
- package/lib/internal/createPublicChat.js +1 -1
- package/lib/messageView.js +1 -1
- package/lib/runtime/chatClient.d.ts +5 -0
- package/lib/runtime/chatClient.js +6 -0
- package/lib/runtime/chatError.d.ts +2 -0
- package/lib/runtime/chatError.js +6 -1
- package/lib/runtime/chatSession.d.ts +5 -0
- package/lib/runtime/chatSession.js +17 -2
- package/lib/runtime/createChat.js +2 -1
- package/lib/runtime/durableSender.js +1 -1
- package/lib/runtime/groupClient.d.ts +15 -2
- package/lib/runtime/groupClient.js +21 -6
- package/lib/runtime/outgoingContent.d.ts +5 -0
- package/lib/runtime/outgoingContent.js +6 -1
- package/lib/runtime/responseCache.d.ts +2 -0
- package/lib/runtime/responseCache.js +6 -0
- package/lib/runtime/socialReadClient.d.ts +5 -2
- package/package.json +1 -1
package/lib/chatContract.d.ts
CHANGED
package/lib/chatError.d.ts
CHANGED
|
@@ -4,8 +4,11 @@ export declare class ChatError extends Error {
|
|
|
4
4
|
readonly code: ChatErrorCode;
|
|
5
5
|
readonly retryable: boolean;
|
|
6
6
|
readonly status?: number;
|
|
7
|
+
/** Present only when access was denied for one conversation, not the Team. */
|
|
8
|
+
readonly threadId?: string;
|
|
7
9
|
constructor(code: ChatErrorCode, message: string, options?: {
|
|
8
10
|
cause?: unknown;
|
|
9
11
|
status?: number;
|
|
12
|
+
threadId?: string;
|
|
10
13
|
});
|
|
11
14
|
}
|
package/lib/chatError.js
CHANGED
|
@@ -3,11 +3,14 @@ export class ChatError extends Error {
|
|
|
3
3
|
code;
|
|
4
4
|
retryable;
|
|
5
5
|
status;
|
|
6
|
+
/** Present only when access was denied for one conversation, not the Team. */
|
|
7
|
+
threadId;
|
|
6
8
|
constructor(code, message, options = {}) {
|
|
7
9
|
super(message, { cause: options.cause });
|
|
8
10
|
this.code = code;
|
|
9
11
|
this.name = "ChatError";
|
|
10
12
|
this.status = options.status;
|
|
13
|
+
this.threadId = options.threadId;
|
|
11
14
|
this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
|
|
12
15
|
}
|
|
13
16
|
}
|
package/lib/chatTypes.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export type ChatMessage = Readonly<{
|
|
|
52
52
|
mentions?: readonly ChatMention[];
|
|
53
53
|
recipientCount?: number;
|
|
54
54
|
system?: Readonly<{
|
|
55
|
-
type: "group-created" | "group-updated" | "contact-updated";
|
|
55
|
+
type: "group-created" | "group-updated" | "group-dissolved" | "contact-updated";
|
|
56
56
|
title?: string;
|
|
57
57
|
}>;
|
|
58
58
|
}>;
|
|
@@ -60,11 +60,17 @@ export type ChatGroup = Readonly<{
|
|
|
60
60
|
groupId: string;
|
|
61
61
|
teamId: string;
|
|
62
62
|
ownerId: string;
|
|
63
|
+
revision: string;
|
|
64
|
+
} & ({
|
|
65
|
+
status: "active";
|
|
63
66
|
title: string;
|
|
64
67
|
avatar?: string;
|
|
65
68
|
memberIds: readonly string[];
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
} | {
|
|
70
|
+
status: "dissolved";
|
|
71
|
+
dissolvedBy?: string;
|
|
72
|
+
dissolvedAt?: number;
|
|
73
|
+
})>;
|
|
68
74
|
export type ConversationInfo = Readonly<{
|
|
69
75
|
kind: "dm" | "group";
|
|
70
76
|
targetId: string;
|
|
@@ -11,8 +11,10 @@ export type OutgoingContent = Exclude<RuntimeContent, {
|
|
|
11
11
|
groupCreate: unknown;
|
|
12
12
|
} | {
|
|
13
13
|
groupUpdate: unknown;
|
|
14
|
+
} | {
|
|
15
|
+
groupDissolve: unknown;
|
|
14
16
|
}>;
|
|
15
|
-
import type { ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
|
|
17
|
+
import type { ChatGroup, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
|
|
16
18
|
export type { ChatGroup, ConversationInfo, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
|
|
17
19
|
export type MessageQuery = {
|
|
18
20
|
earlier?: boolean;
|
|
@@ -94,15 +96,7 @@ export declare class ChatEngine {
|
|
|
94
96
|
getVoiceAttachment(threadId: string, messageId: string): Promise<import("../runtime/durableSender.js").VoiceAttachmentLease>;
|
|
95
97
|
getGroup(threadId: string, options?: {
|
|
96
98
|
refresh?: boolean;
|
|
97
|
-
}): Promise<
|
|
98
|
-
groupId: string;
|
|
99
|
-
teamId: string;
|
|
100
|
-
ownerId: string;
|
|
101
|
-
title: string;
|
|
102
|
-
avatar?: string;
|
|
103
|
-
memberIds: readonly string[];
|
|
104
|
-
revision: string;
|
|
105
|
-
}>>;
|
|
99
|
+
}): Promise<ChatGroup>;
|
|
106
100
|
createGroup(input: {
|
|
107
101
|
groupId?: string;
|
|
108
102
|
title: string;
|
|
@@ -112,20 +106,25 @@ export declare class ChatEngine {
|
|
|
112
106
|
groupId: string;
|
|
113
107
|
teamId: string;
|
|
114
108
|
ownerId: string;
|
|
109
|
+
revision: string;
|
|
110
|
+
status: "active";
|
|
115
111
|
title: string;
|
|
116
112
|
avatar?: string;
|
|
117
113
|
memberIds: readonly string[];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<{
|
|
114
|
+
} | {
|
|
115
|
+
threadId: string;
|
|
121
116
|
groupId: string;
|
|
122
117
|
teamId: string;
|
|
123
118
|
ownerId: string;
|
|
124
|
-
title: string;
|
|
125
|
-
avatar?: string;
|
|
126
|
-
memberIds: readonly string[];
|
|
127
119
|
revision: string;
|
|
128
|
-
|
|
120
|
+
status: "dissolved";
|
|
121
|
+
dissolvedBy?: string;
|
|
122
|
+
dissolvedAt?: number;
|
|
123
|
+
}>;
|
|
124
|
+
updateGroup(threadId: string, changes: GroupChanges): Promise<ChatGroup>;
|
|
125
|
+
dissolveGroup(threadId: string, options?: {
|
|
126
|
+
expectedRevision?: string;
|
|
127
|
+
}): Promise<ChatGroup>;
|
|
129
128
|
revokeAccess(): Promise<void>;
|
|
130
129
|
close(): Promise<void>;
|
|
131
130
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createMessagePresenter } from "../messageView.js";
|
|
2
2
|
import { createChat as createSession } from "../runtime/createChat.js";
|
|
3
|
-
import { asChatError } from "../runtime/chatError.js";
|
|
3
|
+
import { asChatError, threadChatError } from "../runtime/chatError.js";
|
|
4
4
|
import { ChatError } from "../chatError.js";
|
|
5
5
|
export { ChatError } from "../chatError.js";
|
|
6
6
|
const emptyMessages = Object.freeze([]);
|
|
@@ -61,7 +61,7 @@ export class ChatEngine {
|
|
|
61
61
|
async deny(threadId, failure) {
|
|
62
62
|
this.errors.set(threadId, failure);
|
|
63
63
|
this.targets.delete(threadId);
|
|
64
|
-
const retiring = this.advanced.
|
|
64
|
+
const retiring = this.advanced.revokeThread(threadId);
|
|
65
65
|
this.notify(threadId);
|
|
66
66
|
try {
|
|
67
67
|
await retiring;
|
|
@@ -79,7 +79,7 @@ export class ChatEngine {
|
|
|
79
79
|
return await run();
|
|
80
80
|
}
|
|
81
81
|
catch (error) {
|
|
82
|
-
const failure = asChatError(error);
|
|
82
|
+
const failure = threadId ? threadChatError(error, threadId) : asChatError(error);
|
|
83
83
|
if (threadId && failure.code === "access-denied")
|
|
84
84
|
await this.deny(threadId, failure);
|
|
85
85
|
throw failure;
|
|
@@ -187,7 +187,7 @@ export class ChatEngine {
|
|
|
187
187
|
return this.getSnapshot(threadId);
|
|
188
188
|
}
|
|
189
189
|
catch (error) {
|
|
190
|
-
const failure = asChatError(error);
|
|
190
|
+
const failure = this.advanced.active ? threadChatError(error, threadId) : asChatError(error);
|
|
191
191
|
if (!this.closed) {
|
|
192
192
|
if (failure.code === "access-denied")
|
|
193
193
|
await this.deny(threadId, failure);
|
|
@@ -201,8 +201,8 @@ export class ChatEngine {
|
|
|
201
201
|
}
|
|
202
202
|
sendMessage(threadId, content, options = {}) {
|
|
203
203
|
return this.threadOperation(threadId, async () => {
|
|
204
|
-
if (content && ("groupCreate" in content || "groupUpdate" in content))
|
|
205
|
-
throw new ChatError("invalid-input", "Use
|
|
204
|
+
if (content && ("groupCreate" in content || "groupUpdate" in content || "groupDissolve" in content))
|
|
205
|
+
throw new ChatError("invalid-input", "Use group lifecycle methods for group changes");
|
|
206
206
|
const result = await this.advanced.sendMessage(threadId, content, options);
|
|
207
207
|
return { id: result.hash, timestamp: result.t };
|
|
208
208
|
});
|
|
@@ -275,6 +275,7 @@ export class ChatEngine {
|
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
277
|
updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
|
|
278
|
+
dissolveGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.dissolveGroup(threadId, options))); }
|
|
278
279
|
revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
|
|
279
280
|
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
281
|
}
|
|
@@ -45,7 +45,7 @@ export function createPublicChat(options) {
|
|
|
45
45
|
},
|
|
46
46
|
getVoiceAttachment: (id, messageId, options = {}) => read(() => engine.getVoiceAttachment(id, messageId), options, lease => lease.release()),
|
|
47
47
|
getGroup: (id, options = {}) => read(() => engine.getGroup(id), options), refreshGroup: (id, options = {}) => read(() => engine.getGroup(id, { refresh: true }), options),
|
|
48
|
-
createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes),
|
|
48
|
+
createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes), dissolveGroup: (id, options) => engine.dissolveGroup(id, options),
|
|
49
49
|
});
|
|
50
50
|
registerChat(chat, engine);
|
|
51
51
|
return chat;
|
package/lib/messageView.js
CHANGED
|
@@ -51,7 +51,7 @@ function content(message) {
|
|
|
51
51
|
}
|
|
52
52
|
else if (body.kind === "group") {
|
|
53
53
|
result.kind = "system";
|
|
54
|
-
result.system = { type: body.action === "groupCreate" ? "group-created" : "group-updated", title: text(object(body.payload).title) || undefined };
|
|
54
|
+
result.system = { type: body.action === "groupCreate" ? "group-created" : body.action === "groupDissolve" ? "group-dissolved" : "group-updated", title: text(object(body.payload).title) || undefined };
|
|
55
55
|
}
|
|
56
56
|
else if (file) {
|
|
57
57
|
result.kind = "attachment";
|
|
@@ -123,6 +123,11 @@ export declare class ChatClient {
|
|
|
123
123
|
title?: string;
|
|
124
124
|
avatar?: string;
|
|
125
125
|
}): Promise<ChatRuntimeMessage>;
|
|
126
|
+
dissolveGroup(input: {
|
|
127
|
+
groupId: string;
|
|
128
|
+
teamId?: string;
|
|
129
|
+
expectedRevision: string;
|
|
130
|
+
}): Promise<ChatRuntimeMessage>;
|
|
126
131
|
sendGroup(input: {
|
|
127
132
|
groupId: string;
|
|
128
133
|
teamId?: string;
|
|
@@ -252,6 +252,12 @@ export class ChatClient {
|
|
|
252
252
|
addMembers: input.addMembers ?? [], removeMembers: input.removeMembers ?? [], ...(input.title !== undefined ? { title: input.title } : {}), ...(input.avatar !== undefined ? { avatar: input.avatar } : {}) },
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
|
+
async dissolveGroup(input) {
|
|
256
|
+
return this.sendChat(input.groupId, {
|
|
257
|
+
kind: "group", threadKind: "group", action: "groupDissolve",
|
|
258
|
+
payload: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), expectedRevision: input.expectedRevision },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
255
261
|
async sendGroup(input) {
|
|
256
262
|
return this.sendChat(input.groupId, {
|
|
257
263
|
kind: "message",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { ChatError } from "../chatError.js";
|
|
2
2
|
export { ChatError, type ChatErrorCode } from "../chatError.js";
|
|
3
3
|
export declare function asChatError(error: unknown): ChatError;
|
|
4
|
+
/** Keep a conversation denial distinct from loss of Team authority. */
|
|
5
|
+
export declare function threadChatError(error: unknown, threadId: string): ChatError;
|
package/lib/runtime/chatError.js
CHANGED
|
@@ -11,5 +11,10 @@ export function asChatError(error) {
|
|
|
11
11
|
: /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
|
|
12
12
|
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|CONVERSATION_UNKNOWN|NOT_OWNED/.test(message) ? "invalid-input"
|
|
13
13
|
: /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
|
|
14
|
-
return new ChatError(code, message, { cause: error, status });
|
|
14
|
+
return new ChatError(code, message, { cause: error, status, threadId: value?.threadId });
|
|
15
|
+
}
|
|
16
|
+
/** Keep a conversation denial distinct from loss of Team authority. */
|
|
17
|
+
export function threadChatError(error, threadId) {
|
|
18
|
+
const failure = asChatError(error);
|
|
19
|
+
return failure.code === "access-denied" ? new ChatError(failure.code, failure.message, { cause: failure, status: failure.status, threadId }) : failure;
|
|
15
20
|
}
|
|
@@ -149,6 +149,9 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
149
149
|
title?: string;
|
|
150
150
|
avatar?: string;
|
|
151
151
|
}): Promise<ChatGroup>;
|
|
152
|
+
dissolveGroup(target: ChatConversation | string, options?: {
|
|
153
|
+
expectedRevision?: string;
|
|
154
|
+
}): Promise<ChatGroup>;
|
|
152
155
|
sendMessage(threadId: string, input: OutgoingContent, options?: {
|
|
153
156
|
waitFor?: "accepted" | "delivered";
|
|
154
157
|
}): Promise<ChatRuntimeMessage>;
|
|
@@ -190,6 +193,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
190
193
|
private buildGlobal;
|
|
191
194
|
private publishGlobal;
|
|
192
195
|
private publish;
|
|
196
|
+
/** Withdraw one conversation without retiring sibling conversations. */
|
|
197
|
+
revokeThread(threadId: string): Promise<void>;
|
|
193
198
|
/** Permanently retire this identity/team session. UI withdrawal is immediate;
|
|
194
199
|
* completion means all durable stores have crossed the same authority epoch. */
|
|
195
200
|
revokeAccess(): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { threadChatError } from "./chatError.js";
|
|
1
2
|
import { ConversationDirectory } from "./conversationDirectory.js";
|
|
2
3
|
import { groupThreadId } from "./thread.js";
|
|
3
4
|
import { GroupClient } from "./groupClient.js";
|
|
@@ -82,7 +83,7 @@ export class ChatSession {
|
|
|
82
83
|
this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
|
|
83
84
|
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
84
85
|
if (options.loadGroup) {
|
|
85
|
-
this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: () => {
|
|
86
|
+
this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: async (error, threadId) => { await this.messages.revoke(threadId); options.onError(threadChatError(error, threadId)); } });
|
|
86
87
|
this.off.push(this.groups.subscribe(thread => this.publish(thread)));
|
|
87
88
|
}
|
|
88
89
|
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
|
|
@@ -403,6 +404,16 @@ export class ChatSession {
|
|
|
403
404
|
await this.sendMessage(thread, { groupUpdate: { ...changes, expectedRevision: changes.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
|
|
404
405
|
return this.groups.after(thread, previous.versionNumber + 1);
|
|
405
406
|
}
|
|
407
|
+
async dissolveGroup(target, options = {}) {
|
|
408
|
+
const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
|
|
409
|
+
const previous = await this.getGroup(thread);
|
|
410
|
+
if (previous.status === "dissolved")
|
|
411
|
+
return previous;
|
|
412
|
+
if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
|
|
413
|
+
throw new Error("INVALID_GROUP_VERSION");
|
|
414
|
+
await this.sendMessage(thread, { groupDissolve: { expectedRevision: options.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
|
|
415
|
+
return this.groups.after(thread, previous.versionNumber + 1);
|
|
416
|
+
}
|
|
406
417
|
sendMessage(thread, input, options = {}) {
|
|
407
418
|
this.assertOpen();
|
|
408
419
|
if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
|
|
@@ -434,8 +445,10 @@ export class ChatSession {
|
|
|
434
445
|
await this.conversations.put(thread);
|
|
435
446
|
this.assertOpen();
|
|
436
447
|
// Recipients are SDK-owned metadata, never an App-supplied roster.
|
|
437
|
-
if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
|
|
448
|
+
if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate && !input.groupDissolve) {
|
|
438
449
|
const group = await this.getGroup(thread);
|
|
450
|
+
if (group.status === "dissolved")
|
|
451
|
+
throw new Error("GROUP_DISSOLVED");
|
|
439
452
|
this.assertOpen();
|
|
440
453
|
thread = { ...thread, memberIds: group.memberIds };
|
|
441
454
|
}
|
|
@@ -824,6 +837,8 @@ export class ChatSession {
|
|
|
824
837
|
if (notifyGlobal)
|
|
825
838
|
this.publishGlobal();
|
|
826
839
|
}
|
|
840
|
+
/** Withdraw one conversation without retiring sibling conversations. */
|
|
841
|
+
revokeThread(threadId) { return this.messages.revoke(threadId); }
|
|
827
842
|
/** Permanently retire this identity/team session. UI withdrawal is immediate;
|
|
828
843
|
* completion means all durable stores have crossed the same authority epoch. */
|
|
829
844
|
revokeAccess() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { threadChatError } from "./chatError.js";
|
|
1
2
|
import { ChatCredentials } from "../credentials.js";
|
|
2
3
|
import { voiceUploader } from "./voiceUpload.js";
|
|
3
4
|
import { isOffline } from "./connectivity.js";
|
|
@@ -41,7 +42,7 @@ export function createChatSession(options) {
|
|
|
41
42
|
},
|
|
42
43
|
loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
|
|
43
44
|
activeThreadId: options.threadId,
|
|
44
|
-
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
45
|
+
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError: (error, thread) => onError(thread ? threadChatError(error, thread) : error) },
|
|
45
46
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: fetcher, onError },
|
|
46
47
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: fetcher,
|
|
47
48
|
getToken: credentials?.getToken,
|
|
@@ -876,7 +876,7 @@ export class DurableChatSender {
|
|
|
876
876
|
}
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
|
-
function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
|
|
879
|
+
function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate" || body.action === "groupDissolve"); }
|
|
880
880
|
function commandId(scope, version, threadId, message) {
|
|
881
881
|
return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
|
|
882
882
|
}
|
|
@@ -2,7 +2,7 @@ import { type ResponseCacheAccess } from "./responseCache.js";
|
|
|
2
2
|
import { type MessageScope } from "./messageStore.js";
|
|
3
3
|
import { type ChatConversation } from "./outgoingContent.js";
|
|
4
4
|
import type { SocialGroup } from "./socialReadClient.js";
|
|
5
|
-
export type
|
|
5
|
+
export type ActiveChatGroup = {
|
|
6
6
|
groupId: string;
|
|
7
7
|
teamId: string;
|
|
8
8
|
ownerId: string;
|
|
@@ -11,7 +11,19 @@ export type ChatGroup = {
|
|
|
11
11
|
memberIds: readonly string[];
|
|
12
12
|
revision: string;
|
|
13
13
|
versionNumber: number;
|
|
14
|
+
status: "active";
|
|
14
15
|
};
|
|
16
|
+
export type DissolvedChatGroup = {
|
|
17
|
+
groupId: string;
|
|
18
|
+
teamId: string;
|
|
19
|
+
ownerId: string;
|
|
20
|
+
revision: string;
|
|
21
|
+
versionNumber: number;
|
|
22
|
+
status: "dissolved";
|
|
23
|
+
dissolvedBy?: string;
|
|
24
|
+
dissolvedAt?: number;
|
|
25
|
+
};
|
|
26
|
+
export type ChatGroup = ActiveChatGroup | DissolvedChatGroup;
|
|
15
27
|
export type GroupSnapshot = {
|
|
16
28
|
data?: ChatGroup;
|
|
17
29
|
loading: boolean;
|
|
@@ -22,7 +34,7 @@ export type GroupClientOptions = {
|
|
|
22
34
|
access: ResponseCacheAccess;
|
|
23
35
|
load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
|
|
24
36
|
onError: (error: unknown) => void;
|
|
25
|
-
onDenied?: (error: unknown) => Promise<void> | void;
|
|
37
|
+
onDenied?: (error: unknown, threadId: string) => Promise<void> | void;
|
|
26
38
|
indexedDB?: IDBFactory;
|
|
27
39
|
name?: string;
|
|
28
40
|
};
|
|
@@ -44,6 +56,7 @@ export declare class GroupClient {
|
|
|
44
56
|
private queue;
|
|
45
57
|
private controllers;
|
|
46
58
|
private channel?;
|
|
59
|
+
private denied;
|
|
47
60
|
private withdrawn;
|
|
48
61
|
private closed;
|
|
49
62
|
private closing?;
|
|
@@ -23,6 +23,7 @@ export class GroupClient {
|
|
|
23
23
|
queue = [];
|
|
24
24
|
controllers = new Set();
|
|
25
25
|
channel;
|
|
26
|
+
denied = new Set();
|
|
26
27
|
withdrawn = false;
|
|
27
28
|
closed = false;
|
|
28
29
|
closing;
|
|
@@ -106,12 +107,19 @@ export class GroupClient {
|
|
|
106
107
|
}
|
|
107
108
|
normalize(value, entry) {
|
|
108
109
|
const scope = this.options.scope;
|
|
109
|
-
if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1
|
|
110
|
+
if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1)
|
|
110
111
|
throw new Error("INVALID_GROUP_METADATA");
|
|
111
|
-
|
|
112
|
+
if (value.status === "dissolved") {
|
|
113
|
+
if (value.dissolvedBy !== undefined && !validMessageIdentifier(value.dissolvedBy) || value.dissolvedAt !== undefined && (!Number.isSafeInteger(value.dissolvedAt) || value.dissolvedAt < 0))
|
|
114
|
+
throw new Error("INVALID_GROUP_METADATA");
|
|
115
|
+
return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, revision: value.revision, versionNumber: value.versionNumber, status: "dissolved", ...(value.dissolvedBy ? { dissolvedBy: value.dissolvedBy } : {}), ...(value.dissolvedAt === undefined ? {} : { dissolvedAt: value.dissolvedAt }) });
|
|
116
|
+
}
|
|
117
|
+
if (value.status !== undefined && value.status !== "active" || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
|
|
118
|
+
throw new Error("INVALID_GROUP_METADATA");
|
|
119
|
+
return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber, status: "active" });
|
|
112
120
|
}
|
|
113
121
|
cached(value, entry) {
|
|
114
|
-
return this.normalize({ appId: this.options.scope.appId,
|
|
122
|
+
return this.normalize(value.status === "dissolved" ? { appId: this.options.scope.appId, ...value } : { appId: this.options.scope.appId, ...value, members: value.memberIds }, entry);
|
|
115
123
|
}
|
|
116
124
|
local(entry) {
|
|
117
125
|
if (entry.loaded)
|
|
@@ -170,6 +178,8 @@ export class GroupClient {
|
|
|
170
178
|
}
|
|
171
179
|
async reload(entry) { entry.loaded = false; await this.local(entry); }
|
|
172
180
|
async get(thread, options = {}) {
|
|
181
|
+
if (this.denied.has(thread.id))
|
|
182
|
+
throw Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId: thread.id });
|
|
173
183
|
const entry = this.entry(thread);
|
|
174
184
|
await this.local(entry);
|
|
175
185
|
await this.checkAuthority();
|
|
@@ -183,7 +193,7 @@ export class GroupClient {
|
|
|
183
193
|
throw entry.snapshot.error ?? new Error("GROUP_METADATA_OFFLINE");
|
|
184
194
|
return this.request(entry);
|
|
185
195
|
}
|
|
186
|
-
schedule(entry) { if (!this.closed && !this.withdrawn && !isOffline())
|
|
196
|
+
schedule(entry) { if (!this.closed && !this.withdrawn && !this.denied.has(entry.thread.id) && !isOffline())
|
|
187
197
|
this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
|
|
188
198
|
wake() { for (const entry of this.entries.values())
|
|
189
199
|
this.schedule(entry); }
|
|
@@ -257,11 +267,16 @@ export class GroupClient {
|
|
|
257
267
|
return value;
|
|
258
268
|
}
|
|
259
269
|
catch (error) {
|
|
270
|
+
if (error?.status === 403)
|
|
271
|
+
Object.assign(error, { threadId: entry.thread.id });
|
|
260
272
|
if (!this.closed) {
|
|
261
273
|
const denied = [401, 403].includes(error?.status ?? 0);
|
|
262
274
|
this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
|
|
263
|
-
if (denied)
|
|
264
|
-
|
|
275
|
+
if (denied) {
|
|
276
|
+
this.denied.add(entry.thread.id);
|
|
277
|
+
await this.cache.remove(entry.thread.id);
|
|
278
|
+
await this.options.onDenied?.(error, entry.thread.id);
|
|
279
|
+
}
|
|
265
280
|
}
|
|
266
281
|
throw error;
|
|
267
282
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseChatFile } from "../fileMetadata.js";
|
|
2
2
|
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
3
|
-
const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
|
|
3
|
+
const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate", "groupDissolve"];
|
|
4
4
|
export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
|
|
5
5
|
function members(value) {
|
|
6
6
|
if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
|
|
@@ -63,6 +63,11 @@ export function outgoingContent(scope, thread, input) {
|
|
|
63
63
|
throw new Error("INVALID_GROUP_AVATAR");
|
|
64
64
|
return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupUpdate", payload: { ...identity, groupId: thread.targetId, expectedRevision: update.expectedRevision, addMembers: add, removeMembers: remove, ...(update.title === undefined ? {} : { title: update.title.trim() }), ...(update.avatar === undefined ? {} : { avatar: update.avatar }) } } };
|
|
65
65
|
}
|
|
66
|
+
if (input.groupDissolve) {
|
|
67
|
+
if (thread.kind !== "group" || !validMessageIdentifier(input.groupDissolve.expectedRevision))
|
|
68
|
+
throw new Error("INVALID_GROUP_DISSOLVE");
|
|
69
|
+
return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupDissolve", payload: { ...identity, groupId: thread.targetId, expectedRevision: input.groupDissolve.expectedRevision } } };
|
|
70
|
+
}
|
|
66
71
|
if (thread.kind === "group" && !Array.isArray(thread.memberIds))
|
|
67
72
|
throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
|
|
68
73
|
const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
|
|
@@ -51,6 +51,8 @@ export declare class IndexedDbResponseCache<T> {
|
|
|
51
51
|
} | undefined;
|
|
52
52
|
}>>;
|
|
53
53
|
write(key: string, value: T, epoch: number, expectedRevision?: string | null): Promise<void>;
|
|
54
|
+
/** Forget one response without invalidating unrelated cached conversations. */
|
|
55
|
+
remove(key: string): Promise<void>;
|
|
54
56
|
/** Idempotent shared-scope retirement: an old cleanup cannot erase windows
|
|
55
57
|
* created after the same authority version was already recovered. */
|
|
56
58
|
retire(version: number): Promise<void>;
|
|
@@ -105,6 +105,12 @@ export class IndexedDbResponseCache {
|
|
|
105
105
|
this.tasks.schedule("write", () => this.flush());
|
|
106
106
|
return promise;
|
|
107
107
|
}
|
|
108
|
+
/** Forget one response without invalidating unrelated cached conversations. */
|
|
109
|
+
async remove(key) {
|
|
110
|
+
await this.flush();
|
|
111
|
+
await this.synchronizeAccess();
|
|
112
|
+
await this.transact("rw", this.rows, () => this.rows.delete([this.scope, key]));
|
|
113
|
+
}
|
|
108
114
|
/** Idempotent shared-scope retirement: an old cleanup cannot erase windows
|
|
109
115
|
* created after the same authority version was already recovered. */
|
|
110
116
|
retire(version) {
|
|
@@ -18,11 +18,14 @@ export type SocialGroup = {
|
|
|
18
18
|
groupId: string;
|
|
19
19
|
teamId?: string | null;
|
|
20
20
|
ownerId: string;
|
|
21
|
-
members
|
|
22
|
-
title
|
|
21
|
+
members?: readonly string[];
|
|
22
|
+
title?: string;
|
|
23
23
|
avatar?: string | null;
|
|
24
24
|
revision: string;
|
|
25
25
|
versionNumber?: number;
|
|
26
|
+
status?: "active" | "dissolved";
|
|
27
|
+
dissolvedBy?: string;
|
|
28
|
+
dissolvedAt?: number;
|
|
26
29
|
};
|
|
27
30
|
export type SocialRelationship = {
|
|
28
31
|
appId: string;
|
package/package.json
CHANGED