@pmate/chat-sdk 0.12.0-next.59 → 0.12.0-next.60

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.
@@ -0,0 +1,325 @@
1
+ import { IndexedDbResponseCache } from "./responseCache.js";
2
+ import { WindowedTasks } from "./coalescer.js";
3
+ import { isOffline } from "./connectivity.js";
4
+ import { messageScopeKey } from "./messageStore.js";
5
+ import { validateConversation, validMessageIdentifier } from "./outgoingContent.js";
6
+ const empty = Object.freeze({ loading: false });
7
+ const token = (value) => `${value?.versionNumber}:${value?.revision}`;
8
+ /** SDK-owned roster cache. Read sources, concurrency and reconciliation stay private. */
9
+ export class GroupClient {
10
+ options;
11
+ cache;
12
+ entries = new Map();
13
+ pins = new Map();
14
+ listeners = new Set();
15
+ localQueue = [];
16
+ localScheduled = false;
17
+ localRunning = new Set();
18
+ tasks;
19
+ authority;
20
+ version;
21
+ clock = 0;
22
+ active = 0;
23
+ queue = [];
24
+ controllers = new Set();
25
+ channel;
26
+ withdrawn = false;
27
+ closed = false;
28
+ closing;
29
+ constructor(options) {
30
+ this.options = options;
31
+ const scope = JSON.stringify(["groups", messageScopeKey(options.scope)]);
32
+ this.options = { ...options, scope: { ...options.scope } };
33
+ this.cache = new IndexedDbResponseCache(scope, { indexedDB: options.indexedDB, name: options.name, access: () => this.checkAuthority(), entries: 64, batchRecords: 32, recordBytes: 131072, revision: token });
34
+ this.tasks = new WindowedTasks(200, error => { if (!this.closed)
35
+ options.onError(error); });
36
+ if (typeof BroadcastChannel !== "undefined") {
37
+ this.channel = new BroadcastChannel(`pmate-chat-groups:${scope}:${options.name ?? "default"}`);
38
+ this.channel.onmessage = event => {
39
+ if (event.data?.authority !== this.version || typeof event.data?.threadId !== "string")
40
+ return;
41
+ const entry = this.entries.get(event.data.threadId);
42
+ if (entry)
43
+ void this.reload(entry).catch(error => { if (!this.closed)
44
+ options.onError(error); });
45
+ };
46
+ }
47
+ }
48
+ assertOpen() { if (this.closed)
49
+ throw new Error("GROUP_CLIENT_CLOSED"); if (this.withdrawn)
50
+ throw new Error("GROUP_ACCESS_REVOKED"); }
51
+ checkAuthority() {
52
+ if (this.withdrawn)
53
+ throw new Error("GROUP_ACCESS_REVOKED");
54
+ if (this.authority)
55
+ return this.authority;
56
+ return this.authority = this.options.access().then(access => {
57
+ if (this.withdrawn)
58
+ throw new Error("GROUP_ACCESS_REVOKED");
59
+ if (!access.allowed) {
60
+ this.withdraw();
61
+ throw new Error("GROUP_ACCESS_REVOKED");
62
+ }
63
+ if (this.version !== undefined && this.version !== access.version) {
64
+ this.withdraw();
65
+ throw new Error("GROUP_SCOPE_CHANGED");
66
+ }
67
+ this.version = access.version;
68
+ return access;
69
+ }).finally(() => { this.authority = undefined; });
70
+ }
71
+ entry(thread) {
72
+ this.assertOpen();
73
+ validateConversation(this.options.scope, thread);
74
+ if (thread.kind !== "group")
75
+ throw new Error("INVALID_GROUP_CONVERSATION");
76
+ let entry = this.entries.get(thread.id);
77
+ if (!entry) {
78
+ if (this.entries.size >= 256) {
79
+ const old = [...this.entries.values()].filter(value => !value.local && !value.request && !this.pins.has(value.thread.id)).sort((a, b) => a.used - b.used)[0];
80
+ if (!old)
81
+ throw new Error("GROUP_CACHE_BACKPRESSURE");
82
+ this.entries.delete(old.thread.id);
83
+ }
84
+ entry = { thread: { id: thread.id, kind: "group", targetId: thread.targetId, teamId: thread.teamId }, snapshot: empty, epoch: 0, token: null, loaded: false, checked: false, generation: 0, wanted: 0, attempts: 0, used: 0 };
85
+ this.entries.set(thread.id, entry);
86
+ }
87
+ entry.used = ++this.clock;
88
+ return entry;
89
+ }
90
+ snapshot(threadId) { return this.closed || this.withdrawn ? empty : this.entries.get(threadId)?.snapshot ?? empty; }
91
+ subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
92
+ pin(threadId) { this.pins.set(threadId, (this.pins.get(threadId) ?? 0) + 1); return () => { const count = (this.pins.get(threadId) ?? 1) - 1; if (count)
93
+ this.pins.set(threadId, count);
94
+ else
95
+ this.pins.delete(threadId); }; }
96
+ publish(entry, next) {
97
+ if (this.closed)
98
+ return;
99
+ const previous = entry.snapshot;
100
+ if (previous.data && next.data && JSON.stringify(previous.data) === JSON.stringify(next.data))
101
+ next = { ...next, data: previous.data };
102
+ if (previous.data === next.data && previous.loading === next.loading && previous.error === next.error)
103
+ return;
104
+ entry.snapshot = Object.freeze(next);
105
+ this.listeners.forEach(listener => listener(entry.thread.id));
106
+ }
107
+ normalize(value, entry) {
108
+ 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 || !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")
110
+ throw new Error("INVALID_GROUP_METADATA");
111
+ 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 });
112
+ }
113
+ cached(value, entry) {
114
+ return this.normalize({ appId: this.options.scope.appId, groupId: value?.groupId, teamId: value?.teamId, ownerId: value?.ownerId, title: value?.title, avatar: value?.avatar, members: value?.memberIds, revision: value?.revision, versionNumber: value?.versionNumber }, entry);
115
+ }
116
+ local(entry) {
117
+ if (entry.loaded)
118
+ return Promise.resolve();
119
+ if (entry.local)
120
+ return entry.local;
121
+ this.publish(entry, { ...entry.snapshot, loading: !entry.snapshot.data });
122
+ entry.local = new Promise((resolve, reject) => this.localQueue.push({ entry, resolve, reject })).finally(() => { entry.local = undefined; });
123
+ if (!this.localScheduled) {
124
+ this.localScheduled = true;
125
+ queueMicrotask(() => { this.localScheduled = false; void this.flushLocal(); });
126
+ }
127
+ return entry.local;
128
+ }
129
+ flushLocal() {
130
+ const task = (async () => {
131
+ while (this.localQueue.length) {
132
+ const batch = this.localQueue.splice(0, 128);
133
+ try {
134
+ this.assertOpen();
135
+ await this.checkAuthority();
136
+ const rows = await this.cache.readMany(batch.map(item => item.entry.thread.id));
137
+ await this.checkAuthority();
138
+ this.assertOpen();
139
+ for (const item of batch) {
140
+ try {
141
+ const row = rows.get(item.entry.thread.id), entry = item.entry;
142
+ let data = row.cached ? this.cached(row.cached.value, entry) : undefined;
143
+ const old = entry.snapshot.data;
144
+ if (old && data && old.versionNumber > data.versionNumber)
145
+ data = old;
146
+ if (old && data && old.versionNumber === data.versionNumber && JSON.stringify(old) !== JSON.stringify(data))
147
+ throw new Error("GROUP_REVISION_CONFLICT");
148
+ entry.epoch = row.epoch;
149
+ entry.token = row.cached ? token(row.cached.value) : null;
150
+ entry.loaded = true;
151
+ this.publish(entry, { data: data ?? old, loading: false });
152
+ item.resolve();
153
+ }
154
+ catch (error) {
155
+ this.publish(item.entry, { ...item.entry.snapshot, loading: false, error });
156
+ item.reject(error);
157
+ }
158
+ }
159
+ }
160
+ catch (error) {
161
+ for (const item of batch) {
162
+ this.publish(item.entry, { loading: false, error });
163
+ item.reject(error);
164
+ }
165
+ }
166
+ }
167
+ })().finally(() => this.localRunning.delete(task));
168
+ this.localRunning.add(task);
169
+ return task;
170
+ }
171
+ async reload(entry) { entry.loaded = false; await this.local(entry); }
172
+ async get(thread, options = {}) {
173
+ const entry = this.entry(thread);
174
+ await this.local(entry);
175
+ await this.checkAuthority();
176
+ this.assertOpen();
177
+ if (entry.snapshot.data && !options.refresh) {
178
+ if (!entry.checked && !isOffline())
179
+ this.schedule(entry);
180
+ return entry.snapshot.data;
181
+ }
182
+ if (isOffline())
183
+ throw entry.snapshot.error ?? new Error("GROUP_METADATA_OFFLINE");
184
+ return this.request(entry);
185
+ }
186
+ schedule(entry) { if (!this.closed && !this.withdrawn && !isOffline())
187
+ this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
188
+ wake() { for (const entry of this.entries.values())
189
+ this.schedule(entry); }
190
+ hint(thread, versionNumber) {
191
+ const entry = this.entries.get(thread.id);
192
+ if (!entry || this.closed || this.withdrawn || versionNumber !== undefined && versionNumber <= (entry.snapshot.data?.versionNumber ?? 0))
193
+ return;
194
+ entry.generation++;
195
+ entry.attempts = 0;
196
+ entry.wanted = Math.max(entry.wanted, versionNumber ?? 0);
197
+ this.schedule(entry);
198
+ }
199
+ slot(run) {
200
+ if (this.queue.length >= 128)
201
+ return Promise.reject(new Error("GROUP_NETWORK_BACKPRESSURE"));
202
+ return new Promise((resolve, reject) => { this.queue.push({ run, resolve, reject }); this.drain(); });
203
+ }
204
+ drain() {
205
+ while (!this.closed && this.active < 4 && this.queue.length) {
206
+ const work = this.queue.shift();
207
+ this.active++;
208
+ void work.run().then(work.resolve, work.reject).finally(() => { this.active--; this.drain(); });
209
+ }
210
+ }
211
+ request(entry) {
212
+ if (entry.request)
213
+ return entry.request;
214
+ const task = this.slot(async () => {
215
+ this.assertOpen();
216
+ await this.checkAuthority();
217
+ const base = entry.token, generation = entry.generation, controller = new AbortController();
218
+ this.controllers.add(controller);
219
+ const timer = setTimeout(() => controller.abort(), 5000);
220
+ this.publish(entry, { ...entry.snapshot, loading: !entry.snapshot.data, error: undefined });
221
+ try {
222
+ const value = this.normalize(await this.options.load(entry.thread.targetId, controller.signal), entry);
223
+ await this.checkAuthority();
224
+ this.assertOpen();
225
+ if (entry.snapshot.data && value.versionNumber <= entry.snapshot.data.versionNumber)
226
+ await this.reload(entry);
227
+ const old = entry.snapshot.data;
228
+ if (old && value.versionNumber < old.versionNumber) {
229
+ this.publish(entry, { data: old, loading: false });
230
+ return old;
231
+ }
232
+ if (old && value.versionNumber === old.versionNumber) {
233
+ if (value.revision !== old.revision || JSON.stringify(value) !== JSON.stringify(old))
234
+ throw new Error("GROUP_REVISION_CONFLICT");
235
+ entry.checked = true;
236
+ this.publish(entry, { data: old, loading: false });
237
+ return old;
238
+ }
239
+ try {
240
+ await this.cache.write(entry.thread.id, value, entry.epoch, base);
241
+ }
242
+ catch (error) {
243
+ if (!String(error).includes("RESPONSE_CACHE_CHANGED"))
244
+ throw error;
245
+ await this.reload(entry);
246
+ this.schedule(entry);
247
+ if (entry.snapshot.data)
248
+ return entry.snapshot.data;
249
+ throw error;
250
+ }
251
+ await this.checkAuthority();
252
+ this.assertOpen();
253
+ entry.token = token(value);
254
+ entry.checked = true;
255
+ this.publish(entry, { data: value, loading: false });
256
+ this.channel?.postMessage({ threadId: entry.thread.id, authority: this.version });
257
+ return value;
258
+ }
259
+ catch (error) {
260
+ if (!this.closed) {
261
+ const denied = [401, 403].includes(error?.status ?? 0);
262
+ this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
263
+ if (denied)
264
+ await this.options.onDenied?.(error);
265
+ }
266
+ throw error;
267
+ }
268
+ finally {
269
+ clearTimeout(timer);
270
+ this.controllers.delete(controller);
271
+ if (!this.closed && (generation !== entry.generation || entry.wanted > (entry.snapshot.data?.versionNumber ?? 0)) && entry.attempts++ < 4)
272
+ this.schedule(entry);
273
+ }
274
+ }).finally(() => { if (entry.request === task)
275
+ entry.request = undefined; });
276
+ entry.request = task;
277
+ return task;
278
+ }
279
+ async after(thread, minimumVersion) {
280
+ if (!Number.isSafeInteger(minimumVersion) || minimumVersion < 1)
281
+ throw new Error("INVALID_GROUP_VERSION");
282
+ const entry = this.entry(thread);
283
+ for (const delay of [0, 200, 400, 800, 1600]) {
284
+ if (entry.snapshot.data && entry.snapshot.data.versionNumber >= minimumVersion)
285
+ return entry.snapshot.data;
286
+ if (delay)
287
+ await new Promise(resolve => setTimeout(resolve, delay));
288
+ this.assertOpen();
289
+ try {
290
+ const value = await this.get(thread, { refresh: true });
291
+ if (value.versionNumber >= minimumVersion)
292
+ return value;
293
+ }
294
+ catch (error) {
295
+ if (this.closed || this.withdrawn || isOffline() || ![503].includes(error?.status ?? 0))
296
+ throw error;
297
+ }
298
+ }
299
+ throw new Error("GROUP_SYNC_PENDING");
300
+ }
301
+ withdraw() { this.withdrawn = true; this.tasks.close(); this.controllers.forEach(controller => controller.abort()); for (const entry of this.entries.values())
302
+ this.publish(entry, { loading: false, error: new Error("GROUP_ACCESS_REVOKED") }); }
303
+ retire(version) { this.withdraw(); return this.cache.retire(version); }
304
+ close() {
305
+ if (this.closing)
306
+ return this.closing;
307
+ this.closed = true;
308
+ this.tasks.close();
309
+ this.channel?.close();
310
+ this.controllers.forEach(controller => controller.abort());
311
+ this.listeners.clear();
312
+ const error = new Error("GROUP_CLIENT_CLOSED");
313
+ this.queue.splice(0).forEach(work => work.reject(error));
314
+ this.localQueue.splice(0).forEach(work => work.reject(error));
315
+ return this.closing = (async () => { try {
316
+ await Promise.allSettled([...this.entries.values()].flatMap(entry => entry.request ? [entry.request] : []));
317
+ await Promise.allSettled([...this.localRunning]);
318
+ await this.cache.close();
319
+ }
320
+ finally {
321
+ this.entries.clear();
322
+ this.pins.clear();
323
+ } })();
324
+ }
325
+ }
@@ -34,3 +34,4 @@ export { projectConversationReading, type ReadingCandidate, type MessageReadingS
34
34
  export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
35
35
  export * from "./messageLaunch.js";
36
36
  export * from "./outgoingContent.js";
37
+ export type { ChatGroup } from "./groupClient.js";
@@ -54,5 +54,6 @@ export function outboxDatabase(factory, name) {
54
54
  db.version(2).stores({ scopeEpochs: "scope" });
55
55
  db.version(3).stores({ outbox: messageSchema });
56
56
  db.version(4).stores({ outbox: "unsupportedOutboxFormat", outgoingMessages: messageSchema, outgoingAttachments: "[scope+hash],scope" });
57
+ db.version(5).stores({ outgoingControls: "[scope+threadId+target+emoji],scope" });
57
58
  return db;
58
59
  }
@@ -13,35 +13,73 @@ export type MessageQuote = {
13
13
  senderName: string;
14
14
  content: string;
15
15
  };
16
- export type OutgoingContent = {
17
- text: string;
18
- mentions?: ChatMention[];
19
- quote?: MessageQuote;
20
- image?: never;
21
- voice?: never;
22
- } | {
16
+ type ContentChoices = {
17
+ text: {
18
+ text: string;
19
+ mentions?: ChatMention[];
20
+ quote?: MessageQuote;
21
+ };
23
22
  image: {
24
- url: string;
25
- name?: string;
26
- };
27
- text?: never;
28
- voice?: never;
29
- } | {
30
- voice: ({
31
- blob: Blob;
32
- url?: never;
33
- } | {
34
- url: string;
35
- blob?: never;
36
- }) & {
37
- durationMs: number;
38
- transcript?: string;
39
- };
40
- text?: never;
41
- image?: never;
23
+ image: {
24
+ url: string;
25
+ name?: string;
26
+ };
27
+ };
28
+ voice: {
29
+ voice: ({
30
+ blob: Blob;
31
+ url?: never;
32
+ } | {
33
+ url: string;
34
+ blob?: never;
35
+ }) & {
36
+ durationMs: number;
37
+ transcript?: string;
38
+ };
39
+ };
40
+ recall: {
41
+ recall: {
42
+ messageId: string;
43
+ };
44
+ };
45
+ reaction: {
46
+ reaction: {
47
+ messageId: string;
48
+ emojiId: string;
49
+ active: boolean;
50
+ };
51
+ };
52
+ invitation: {
53
+ invitation: {
54
+ message?: string;
55
+ };
56
+ };
57
+ groupCreate: {
58
+ groupCreate: {
59
+ title: string;
60
+ members: readonly string[];
61
+ };
62
+ };
63
+ groupUpdate: {
64
+ groupUpdate: {
65
+ expectedRevision: string;
66
+ addMembers?: readonly string[];
67
+ removeMembers?: readonly string[];
68
+ title?: string;
69
+ avatar?: string;
70
+ };
71
+ };
42
72
  };
73
+ export type OutgoingContent = {
74
+ [K in keyof ContentChoices]: ContentChoices[K] & {
75
+ [P in Exclude<keyof ContentChoices, K>]?: never;
76
+ };
77
+ }[keyof ContentChoices];
78
+ export declare function validMessageIdentifier(value: unknown): value is string;
79
+ export declare function validateConversation(scope: MessageScope, thread: ChatConversation): void;
43
80
  export declare const ALL_MENTION_ID = "__pmate_mention_all__";
44
81
  /** Expand group mentions into concrete members understood by all protocol readers. */
45
82
  export declare function expandGroupMentions(text: string, mentions: ChatMention[] | undefined, memberIds: readonly string[], senderId: string): ChatMention[];
46
83
  /** Build the existing wire protocol inside the owner, before durable acceptance. */
47
84
  export declare function outgoingContent(scope: MessageScope, thread: ChatConversation, input: OutgoingContent): SendMessageInput;
85
+ export {};
@@ -1,4 +1,18 @@
1
1
  import { dmThreadId, groupThreadId } from "./thread.js";
2
+ const contentKeys = ["text", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
+ export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
4
+ function members(value) {
5
+ if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
6
+ throw new Error("INVALID_GROUP_MEMBERS");
7
+ return [...new Set(value)].sort();
8
+ }
9
+ export function validateConversation(scope, thread) {
10
+ if (!thread || !["dm", "group"].includes(thread.kind) || !validMessageIdentifier(thread.targetId) || thread.teamId !== undefined && thread.teamId !== scope.teamId)
11
+ throw new Error("INVALID_MESSAGE_CONVERSATION");
12
+ const expected = thread.kind === "group" ? groupThreadId(scope.appId, thread.targetId, scope.teamId) : dmThreadId(scope.appId, scope.profileId, thread.targetId, scope.teamId);
13
+ if (thread.id !== expected)
14
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
15
+ }
2
16
  export const ALL_MENTION_ID = "__pmate_mention_all__";
3
17
  /** Expand group mentions into concrete members understood by all protocol readers. */
4
18
  export function expandGroupMentions(text, mentions, memberIds, senderId) {
@@ -21,17 +35,51 @@ export function expandGroupMentions(text, mentions, memberIds, senderId) {
21
35
  }
22
36
  /** Build the existing wire protocol inside the owner, before durable acceptance. */
23
37
  export function outgoingContent(scope, thread, input) {
24
- if (!thread || !["dm", "group"].includes(thread.kind) || typeof thread.targetId !== "string" || !thread.targetId || thread.teamId !== undefined && thread.teamId !== scope.teamId)
25
- throw new Error("INVALID_MESSAGE_CONVERSATION");
26
- const expected = thread.kind === "group" ? groupThreadId(scope.appId, thread.targetId, scope.teamId) : dmThreadId(scope.appId, scope.profileId, thread.targetId, scope.teamId);
27
- if (thread.id !== expected)
28
- throw new Error("MESSAGE_CONVERSATION_MISMATCH");
38
+ validateConversation(scope, thread);
39
+ if (!input || typeof input !== "object" || contentKeys.filter(key => Object.hasOwn(input, key)).length !== 1)
40
+ throw new Error("INVALID_MESSAGE_CONTENT");
41
+ const identity = { appId: scope.appId, teamId: scope.teamId };
42
+ if (input.invitation) {
43
+ if (typeof input.invitation !== "object" || Array.isArray(input.invitation) || thread.kind !== "dm" || input.invitation.message !== undefined && typeof input.invitation.message !== "string")
44
+ throw new Error("INVALID_MESSAGE_INVITATION");
45
+ return { to: thread.targetId, body: { kind: "contact", threadKind: "dm", action: "friendRequest", payload: { ...identity, message: input.invitation.message ?? "" } } };
46
+ }
47
+ if (input.groupCreate) {
48
+ if (thread.kind !== "group" || typeof input.groupCreate.title !== "string")
49
+ throw new Error("INVALID_GROUP_CREATE");
50
+ const recipients = members(input.groupCreate.members);
51
+ if (new Set([scope.profileId, ...recipients]).size > 256)
52
+ throw new Error("INVALID_GROUP_MEMBERS");
53
+ return { to: thread.targetId, recipients, body: { kind: "group", threadKind: "group", action: "groupCreate", payload: { ...identity, groupId: thread.targetId, title: input.groupCreate.title, members: recipients } } };
54
+ }
55
+ if (input.groupUpdate) {
56
+ const update = input.groupUpdate, add = members(update.addMembers ?? []), remove = members(update.removeMembers ?? []);
57
+ if (thread.kind !== "group" || !validMessageIdentifier(update.expectedRevision) || Number(add.length > 0) + Number(remove.length > 0) + Number(update.title !== undefined) + Number(update.avatar !== undefined) !== 1)
58
+ throw new Error("INVALID_GROUP_UPDATE");
59
+ if (update.title !== undefined && (typeof update.title !== "string" || !update.title.trim() || [...update.title.trim()].length > 120))
60
+ throw new Error("INVALID_GROUP_TITLE");
61
+ if (update.avatar !== undefined && (typeof update.avatar !== "string" || !update.avatar.startsWith("https://") || update.avatar.length > 2048 || /\s/u.test(update.avatar)))
62
+ throw new Error("INVALID_GROUP_AVATAR");
63
+ 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 }) } } };
64
+ }
65
+ if (thread.kind === "group" && !Array.isArray(thread.memberIds))
66
+ throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
29
67
  const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
30
- if (recipients && (!recipients.length || recipients.some(id => typeof id !== "string" || !id)))
68
+ if (recipients && (!recipients.length || recipients.length > 256 || !recipients.every(validMessageIdentifier)))
31
69
  throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
32
- const base = { appId: scope.appId, ...(scope.teamId === undefined ? {} : { teamId: scope.teamId }), ...(thread.kind === "group" ? { groupId: thread.targetId } : {}) };
33
- if (!input || typeof input !== "object" || ["text", "image", "voice"].filter(key => Object.hasOwn(input, key)).length !== 1)
34
- throw new Error("INVALID_MESSAGE_CONTENT");
70
+ const base = { ...identity, ...(thread.kind === "group" ? { groupId: thread.targetId } : {}) };
71
+ if (input.recall) {
72
+ if (!validMessageIdentifier(input.recall.messageId))
73
+ throw new Error("INVALID_MESSAGE_RECALL");
74
+ return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...identity, text: "[消息已撤回]", recall: { messageId: input.recall.messageId } } } };
75
+ }
76
+ if (input.reaction) {
77
+ const reaction = input.reaction;
78
+ if (!validMessageIdentifier(reaction.messageId) || !validMessageIdentifier(reaction.emojiId) || typeof reaction.active !== "boolean")
79
+ throw new Error("INVALID_MESSAGE_REACTION");
80
+ // The durable sender assigns the revision in the acceptance transaction.
81
+ return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...identity, text: "[表情回应]", reaction: { version: 1, messageId: reaction.messageId, emojiId: reaction.emojiId, active: reaction.active, revision: 1 } } } };
82
+ }
35
83
  let content, contentKind = "text", voiceBlob;
36
84
  if ("text" in input) {
37
85
  if (typeof input.text !== "string")
@@ -4,6 +4,15 @@ export type ResponseCacheAccess = () => Promise<{
4
4
  }>;
5
5
  export declare function isResponseCacheAuthorityError(error: unknown): boolean;
6
6
  export declare function assertResponseCacheAccess(access?: ResponseCacheAccess, expectedVersion?: number): Promise<number | undefined>;
7
+ export type ResponseCacheOptions<T> = {
8
+ indexedDB?: IDBFactory;
9
+ name?: string;
10
+ access?: ResponseCacheAccess;
11
+ recordBytes?: number;
12
+ entries?: number;
13
+ batchRecords?: number;
14
+ revision?: (value: T) => string;
15
+ };
7
16
  /** Small response windows, isolated by identity. Epochs fence late writes after
8
17
  * access withdrawal. No localStorage or synchronous writes on the read path. */
9
18
  export declare class IndexedDbResponseCache<T> {
@@ -18,11 +27,7 @@ export declare class IndexedDbResponseCache<T> {
18
27
  private invalidations;
19
28
  private closed;
20
29
  private closing?;
21
- constructor(scope: string, options?: {
22
- indexedDB?: IDBFactory;
23
- name?: string;
24
- access?: ResponseCacheAccess;
25
- });
30
+ constructor(scope: string, options?: ResponseCacheOptions<T>);
26
31
  /** Resolve shared message-scope authority before opening our transaction.
27
32
  * A newer authority version retires response windows exactly once; delayed
28
33
  * callers can never roll the authority backwards or delete recovered data. */
@@ -34,7 +39,15 @@ export declare class IndexedDbResponseCache<T> {
34
39
  time: number;
35
40
  } | undefined;
36
41
  }>;
37
- write(key: string, value: T, epoch: number): Promise<void>;
42
+ /** Key-only callers can share one bounded metadata read transaction. */
43
+ readMany(keys: readonly string[]): Promise<Map<string, {
44
+ epoch: number;
45
+ cached: {
46
+ value: T;
47
+ time: number;
48
+ } | undefined;
49
+ }>>;
50
+ write(key: string, value: T, epoch: number, expectedRevision?: string | null): Promise<void>;
38
51
  /** Idempotent shared-scope retirement: an old cleanup cannot erase windows
39
52
  * created after the same authority version was already recovered. */
40
53
  retire(version: number): Promise<void>;