@pmate/chat-sdk 0.12.0-next.55 → 0.12.0-next.57
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 +13 -2
- package/lib/runtime/chatSession.d.ts +2 -0
- package/lib/runtime/chatSession.js +5 -2
- package/lib/runtime/durableSender.d.ts +9 -0
- package/lib/runtime/durableSender.js +105 -44
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/outgoingContent.d.ts +47 -0
- package/lib/runtime/outgoingContent.js +58 -0
- package/lib/runtime/voiceUpload.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ A dropped socket (including a network change or a suspended laptop) previously r
|
|
|
39
39
|
|
|
40
40
|
`client.watchConnection(handler)` immediately reports the current state and returns an unsubscribe function. States are `connecting`, `reconnecting`, `authenticating`, `connected`, `authentication_required`, and `closed`. Only `connected` means the Hub acknowledged authentication. A rejected authentication stops retries; supply a fresh login session using a new client. `close()` cancels retries, heartbeat timers and browser wake listeners.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
The low-level `ChatClient.sendChat` rejects unacknowledged sends on disconnect and does not provide a durable outbox. Browser applications should use `createChat().sendMessage` below, which persists the exact wire in IndexedDB and owns delivery state and retries. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
|
|
43
43
|
|
|
44
44
|
## Versioned Markdown and chart content
|
|
45
45
|
|
|
@@ -155,7 +155,10 @@ const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
|
155
155
|
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
156
156
|
// Report only the actual audio `playing` event.
|
|
157
157
|
await chat.observeMessages(threadId, [voiceId], "played")
|
|
158
|
-
|
|
158
|
+
const conversation = { id: threadId, kind: "dm" as const, targetId: peerId, teamId }
|
|
159
|
+
await chat.sendMessage(conversation, { text: "Hello" })
|
|
160
|
+
await chat.sendMessage(conversation, { image: { url: uploadedUrl, name: "photo.png" } })
|
|
161
|
+
await chat.sendMessage(conversation, { voice: { blob: recording, durationMs: 1200 } })
|
|
159
162
|
const readers = await chat.getReaders(threadId, messageId)
|
|
160
163
|
off(); offSummary()
|
|
161
164
|
// Ordinary navigation/shutdown preserves offline data.
|
|
@@ -169,6 +172,14 @@ await chat.close()
|
|
|
169
172
|
await chat.revokeAccess()
|
|
170
173
|
```
|
|
171
174
|
|
|
175
|
+
Conversation descriptors identify the target and optional group `memberIds`; the SDK
|
|
176
|
+
checks the descriptor against its identity and team before acceptance. It builds
|
|
177
|
+
text, image, quote and voice envelopes and expands group `@all`/`@所有人` to concrete
|
|
178
|
+
members. Content callers do not provide `appId`, protocol `body` or `recipients`.
|
|
179
|
+
The existing `sendMessage(threadId, { to, body, recipients })` form remains available
|
|
180
|
+
for protocol integrations. Blob voice uploads stay in the durable sender; offline
|
|
181
|
+
uploads wait for retry instead of starting HTTP.
|
|
182
|
+
|
|
172
183
|
The facade owns all clients below. Message reads transparently restore IndexedDB
|
|
173
184
|
windows and fetch missing ranges through the authenticated Hub. Its normalized
|
|
174
185
|
messages retain the protocol body; product rendering remains with the host.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
|
|
1
2
|
import { type TimelineItem } from "./messageTimeline.js";
|
|
2
3
|
import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
|
|
3
4
|
import type { ConversationReadMessage } from "./conversationRead.js";
|
|
@@ -120,6 +121,7 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
120
121
|
private rememberHistoryEdge;
|
|
121
122
|
private readEarlier;
|
|
122
123
|
private readAll;
|
|
124
|
+
sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
|
|
123
125
|
sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
|
|
124
126
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
125
127
|
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { outgoingContent } from "./outgoingContent.js";
|
|
1
2
|
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
2
3
|
import { projectConversationReading } from "./conversationReading.js";
|
|
3
4
|
import { ReadCountsClient } from "./readCounts.js";
|
|
@@ -359,9 +360,11 @@ export class ChatSession {
|
|
|
359
360
|
}
|
|
360
361
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
361
362
|
}
|
|
362
|
-
sendMessage(
|
|
363
|
+
sendMessage(thread, input) {
|
|
363
364
|
this.assertOpen();
|
|
364
|
-
|
|
365
|
+
const threadId = typeof thread === "string" ? thread : thread.id;
|
|
366
|
+
const wire = typeof thread === "string" ? input : outgoingContent(this.options.messages.scope, thread, input);
|
|
367
|
+
return this.sender.enqueue(threadId, wire).then(next => { if (this.withdrawn)
|
|
365
368
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
366
369
|
}
|
|
367
370
|
async getReaders(threadId, messageId, page = {}, signal) {
|
|
@@ -46,6 +46,11 @@ export declare class DurableChatSender {
|
|
|
46
46
|
private uploads;
|
|
47
47
|
private reconciliations;
|
|
48
48
|
private firstDelivery;
|
|
49
|
+
private ioQueue;
|
|
50
|
+
private ioTimer?;
|
|
51
|
+
private ioRunning?;
|
|
52
|
+
private ioCount;
|
|
53
|
+
private ioBytes;
|
|
49
54
|
private acceptanceQueue;
|
|
50
55
|
private acceptanceTimer?;
|
|
51
56
|
private acceptanceRunning?;
|
|
@@ -104,6 +109,10 @@ export declare class DurableChatSender {
|
|
|
104
109
|
private localView;
|
|
105
110
|
private prepareVoice;
|
|
106
111
|
private save;
|
|
112
|
+
private queueIO;
|
|
113
|
+
private scheduleIO;
|
|
114
|
+
private failIO;
|
|
115
|
+
private drainIO;
|
|
107
116
|
/** Confirm only after the authoritative message has been durably merged.
|
|
108
117
|
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
109
118
|
confirm(hashes: string[]): Promise<void>;
|
|
@@ -16,6 +16,11 @@ export class DurableChatSender {
|
|
|
16
16
|
uploads = new Set();
|
|
17
17
|
reconciliations = new Set();
|
|
18
18
|
firstDelivery = new Set();
|
|
19
|
+
ioQueue = [];
|
|
20
|
+
ioTimer;
|
|
21
|
+
ioRunning;
|
|
22
|
+
ioCount = 0;
|
|
23
|
+
ioBytes = 0;
|
|
19
24
|
acceptanceQueue = [];
|
|
20
25
|
acceptanceTimer;
|
|
21
26
|
acceptanceRunning;
|
|
@@ -194,6 +199,7 @@ export class DurableChatSender {
|
|
|
194
199
|
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
195
200
|
withdraw() {
|
|
196
201
|
this.withdrawn = true;
|
|
202
|
+
this.failIO(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
197
203
|
this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
198
204
|
this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
199
205
|
this.uploads.forEach(controller => controller.abort());
|
|
@@ -257,7 +263,15 @@ export class DurableChatSender {
|
|
|
257
263
|
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
258
264
|
if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
|
|
259
265
|
return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
|
|
260
|
-
|
|
266
|
+
let bytes;
|
|
267
|
+
try {
|
|
268
|
+
bytes = JSON.stringify([input.to, input.body, input.recipients, this.identity]).length * 3 + 1024 + (input.voiceBlob?.size ?? 0);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return Promise.reject(error);
|
|
272
|
+
}
|
|
273
|
+
if (bytes > 20 * 1024 * 1024)
|
|
274
|
+
return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
|
|
261
275
|
if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
|
|
262
276
|
return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
|
|
263
277
|
this.acceptanceCount++;
|
|
@@ -278,7 +292,7 @@ export class DurableChatSender {
|
|
|
278
292
|
}
|
|
279
293
|
const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
|
|
280
294
|
const durable = await new Promise((resolve, reject) => {
|
|
281
|
-
this.acceptanceQueue.push({ record, resolve, reject });
|
|
295
|
+
this.acceptanceQueue.push({ record, bytes, resolve, reject });
|
|
282
296
|
this.scheduleAcceptances();
|
|
283
297
|
});
|
|
284
298
|
return durable.message;
|
|
@@ -301,8 +315,8 @@ export class DurableChatSender {
|
|
|
301
315
|
const task = (async () => {
|
|
302
316
|
let bytes = 0, count = 0;
|
|
303
317
|
for (const item of this.acceptanceQueue) {
|
|
304
|
-
const size = item.
|
|
305
|
-
if (count >= 128 || (count > 0 && bytes + size >
|
|
318
|
+
const size = item.bytes;
|
|
319
|
+
if (count >= 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
|
|
306
320
|
break;
|
|
307
321
|
bytes += size;
|
|
308
322
|
count++;
|
|
@@ -373,7 +387,7 @@ export class DurableChatSender {
|
|
|
373
387
|
}
|
|
374
388
|
async transmit(hash) {
|
|
375
389
|
await this.ready;
|
|
376
|
-
let record = await this.
|
|
390
|
+
let record = await this.queueIO(hash);
|
|
377
391
|
if (!record || this.closed || this.withdrawn)
|
|
378
392
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
379
393
|
if (record.status === "sent" || record.confirmed) {
|
|
@@ -411,14 +425,7 @@ export class DurableChatSender {
|
|
|
411
425
|
transmitting = true;
|
|
412
426
|
const message = await this.client.sendPrepared(record.message);
|
|
413
427
|
const sent = { ...record, message, status: "sent" };
|
|
414
|
-
|
|
415
|
-
if (!this.closed && !this.withdrawn) {
|
|
416
|
-
if (committed?.confirmed || !committed)
|
|
417
|
-
this.values.delete(hash);
|
|
418
|
-
else
|
|
419
|
-
this.values.set(hash, committed);
|
|
420
|
-
this.emit();
|
|
421
|
-
}
|
|
428
|
+
await this.save(sent);
|
|
422
429
|
return message;
|
|
423
430
|
}
|
|
424
431
|
catch (error) {
|
|
@@ -438,16 +445,8 @@ export class DurableChatSender {
|
|
|
438
445
|
if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
|
|
439
446
|
const failed = { ...record, status: "failed" };
|
|
440
447
|
const committed = await this.save(failed);
|
|
441
|
-
if (committed?.confirmed || committed?.status === "sent")
|
|
442
|
-
this.values.delete(hash);
|
|
443
|
-
this.emit();
|
|
448
|
+
if (committed?.confirmed || committed?.status === "sent")
|
|
444
449
|
return committed.message;
|
|
445
|
-
}
|
|
446
|
-
if (committed)
|
|
447
|
-
this.values.set(hash, committed);
|
|
448
|
-
else
|
|
449
|
-
this.values.delete(hash);
|
|
450
|
-
this.emit();
|
|
451
450
|
}
|
|
452
451
|
throw error;
|
|
453
452
|
}
|
|
@@ -511,7 +510,7 @@ export class DurableChatSender {
|
|
|
511
510
|
await this.checkAccess();
|
|
512
511
|
if (this.closing)
|
|
513
512
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
514
|
-
const latest = await this.
|
|
513
|
+
const latest = await this.queueIO(record.hash);
|
|
515
514
|
if (!latest)
|
|
516
515
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
517
516
|
if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
|
|
@@ -536,18 +535,9 @@ export class DurableChatSender {
|
|
|
536
535
|
if (this.closing)
|
|
537
536
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
538
537
|
const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
|
|
539
|
-
const committed = await this.
|
|
540
|
-
const current = await this.outbox.get([this.scope, record.hash]);
|
|
541
|
-
if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
|
|
542
|
-
return current;
|
|
543
|
-
const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
|
|
544
|
-
await this.outbox.put(next);
|
|
545
|
-
return next;
|
|
546
|
-
});
|
|
538
|
+
const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
|
|
547
539
|
if (!committed)
|
|
548
540
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
549
|
-
this.values.set(record.hash, committed);
|
|
550
|
-
this.emit();
|
|
551
541
|
return committed;
|
|
552
542
|
}
|
|
553
543
|
finally {
|
|
@@ -555,17 +545,88 @@ export class DurableChatSender {
|
|
|
555
545
|
release();
|
|
556
546
|
}
|
|
557
547
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return
|
|
568
|
-
|
|
548
|
+
save(record) { return this.queueIO(record.hash, record); }
|
|
549
|
+
queueIO(hash, record, uploaded = false) {
|
|
550
|
+
if (this.closed || this.withdrawn)
|
|
551
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
552
|
+
const value = record ?? this.values.get(hash);
|
|
553
|
+
const bytes = value ? JSON.stringify(value.message).length * 3 + (value.voiceData?.bytes.byteLength ?? value.voiceBlob?.size ?? 0) : 0;
|
|
554
|
+
if (bytes > 20 * 1024 * 1024)
|
|
555
|
+
return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
|
|
556
|
+
if (this.ioCount >= 4096 || this.ioBytes + bytes > 64 * 1024 * 1024)
|
|
557
|
+
return Promise.reject(new Error("OUTBOX_IO_BACKPRESSURE"));
|
|
558
|
+
this.ioCount++;
|
|
559
|
+
this.ioBytes += bytes;
|
|
560
|
+
return new Promise((resolve, reject) => {
|
|
561
|
+
this.ioQueue.push({ hash, record, uploaded, bytes, resolve, reject });
|
|
562
|
+
this.scheduleIO();
|
|
563
|
+
}).finally(() => { this.ioCount--; this.ioBytes -= bytes; });
|
|
564
|
+
}
|
|
565
|
+
scheduleIO() {
|
|
566
|
+
if (this.ioTimer || this.ioRunning || !this.ioQueue.length)
|
|
567
|
+
return;
|
|
568
|
+
this.ioTimer = setTimeout(() => { this.ioTimer = undefined; void this.drainIO(); }, 16);
|
|
569
|
+
}
|
|
570
|
+
failIO(error) {
|
|
571
|
+
clearTimeout(this.ioTimer);
|
|
572
|
+
this.ioTimer = undefined;
|
|
573
|
+
for (const item of this.ioQueue.splice(0))
|
|
574
|
+
item.reject(error);
|
|
575
|
+
}
|
|
576
|
+
drainIO() {
|
|
577
|
+
const task = (async () => {
|
|
578
|
+
let count = 0, bytes = 0;
|
|
579
|
+
for (const item of this.ioQueue) {
|
|
580
|
+
if (count >= 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
|
|
581
|
+
break;
|
|
582
|
+
count++;
|
|
583
|
+
bytes += item.bytes;
|
|
584
|
+
}
|
|
585
|
+
const batch = this.ioQueue.splice(0, count);
|
|
586
|
+
try {
|
|
587
|
+
const records = await this.withEpoch(batch.some(item => item.record) ? "rw" : "r", async () => {
|
|
588
|
+
const hashes = [...new Set(batch.map(item => item.hash))];
|
|
589
|
+
const rows = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
590
|
+
const current = new Map(hashes.map((hash, index) => [hash, rows[index]]));
|
|
591
|
+
const writes = new Map(), result = [];
|
|
592
|
+
for (const item of batch) {
|
|
593
|
+
const previous = current.get(item.hash);
|
|
594
|
+
// Preserve another document's confirmation, sent status or uploaded URL.
|
|
595
|
+
if (item.record && previous && !previous.confirmed && previous.status !== "sent" && !(hasVoice(item.record) && !hasVoice(previous)) && !(item.uploaded && !hasVoice(previous))) {
|
|
596
|
+
current.set(item.hash, item.record);
|
|
597
|
+
writes.set(item.hash, item.record);
|
|
598
|
+
}
|
|
599
|
+
result.push(current.get(item.hash));
|
|
600
|
+
}
|
|
601
|
+
if (writes.size)
|
|
602
|
+
await this.outbox.bulkPut([...writes.values()]);
|
|
603
|
+
return result;
|
|
604
|
+
});
|
|
605
|
+
await this.checkAccess();
|
|
606
|
+
let changed = false;
|
|
607
|
+
batch.forEach((item, index) => {
|
|
608
|
+
if (!item.record)
|
|
609
|
+
return;
|
|
610
|
+
const record = records[index], old = this.values.get(item.hash);
|
|
611
|
+
if (!record || record.confirmed) {
|
|
612
|
+
changed = this.values.delete(item.hash) || changed;
|
|
613
|
+
this.releaseUrl(item.hash);
|
|
614
|
+
}
|
|
615
|
+
else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
|
|
616
|
+
this.values.set(item.hash, record);
|
|
617
|
+
changed = true;
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
if (changed)
|
|
621
|
+
this.emit();
|
|
622
|
+
batch.forEach((item, index) => item.resolve(records[index]));
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
batch.forEach(item => item.reject(error));
|
|
626
|
+
}
|
|
627
|
+
})().finally(() => { this.ioRunning = undefined; this.scheduleIO(); });
|
|
628
|
+
this.ioRunning = task;
|
|
629
|
+
return task;
|
|
569
630
|
}
|
|
570
631
|
/** Confirm only after the authoritative message has been durably merged.
|
|
571
632
|
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -33,3 +33,4 @@ export * from "./conversationRead.js";
|
|
|
33
33
|
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
34
|
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
35
|
export * from "./messageLaunch.js";
|
|
36
|
+
export * from "./outgoingContent.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ChatMention } from "../mentions/model.js";
|
|
2
|
+
import type { MessageScope } from "./messageStore.js";
|
|
3
|
+
import type { SendMessageInput } from "./durableSender.js";
|
|
4
|
+
export type ChatConversation = {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: "dm" | "group";
|
|
7
|
+
targetId: string;
|
|
8
|
+
teamId?: string;
|
|
9
|
+
memberIds?: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
export type MessageQuote = {
|
|
12
|
+
messageId?: string;
|
|
13
|
+
senderName: string;
|
|
14
|
+
content: string;
|
|
15
|
+
};
|
|
16
|
+
export type OutgoingContent = {
|
|
17
|
+
text: string;
|
|
18
|
+
mentions?: ChatMention[];
|
|
19
|
+
quote?: MessageQuote;
|
|
20
|
+
image?: never;
|
|
21
|
+
voice?: never;
|
|
22
|
+
} | {
|
|
23
|
+
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;
|
|
42
|
+
};
|
|
43
|
+
export declare const ALL_MENTION_ID = "__pmate_mention_all__";
|
|
44
|
+
/** Expand group mentions into concrete members understood by all protocol readers. */
|
|
45
|
+
export declare function expandGroupMentions(text: string, mentions: ChatMention[] | undefined, memberIds: readonly string[], senderId: string): ChatMention[];
|
|
46
|
+
/** Build the existing wire protocol inside the owner, before durable acceptance. */
|
|
47
|
+
export declare function outgoingContent(scope: MessageScope, thread: ChatConversation, input: OutgoingContent): SendMessageInput;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
2
|
+
export const ALL_MENTION_ID = "__pmate_mention_all__";
|
|
3
|
+
/** Expand group mentions into concrete members understood by all protocol readers. */
|
|
4
|
+
export function expandGroupMentions(text, mentions, memberIds, senderId) {
|
|
5
|
+
const validMembers = [...new Set(memberIds)], members = validMembers.filter(id => id !== senderId), entries = [...(mentions ?? [])];
|
|
6
|
+
const pattern = /(?:^|[\s([{,。!?、:;])(@(?:all|所有人))(?=$|[\s,.;:!?,。!?、:;)\]}])/gu;
|
|
7
|
+
for (const match of text.matchAll(pattern)) {
|
|
8
|
+
const start = match.index + match[0].length - match[1].length;
|
|
9
|
+
if (!entries.some(entry => entry.start === start && entry.end === start + match[1].length))
|
|
10
|
+
entries.push({ id: ALL_MENTION_ID, name: match[1].slice(1), start, end: start + match[1].length });
|
|
11
|
+
}
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return entries.flatMap(entry => {
|
|
14
|
+
if (text.slice(entry.start, entry.end) !== `@${entry.name}`)
|
|
15
|
+
return [];
|
|
16
|
+
if (entry.id === ALL_MENTION_ID)
|
|
17
|
+
return ["all", "所有人"].includes(entry.name) ? members.map(id => ({ ...entry, id })) : [];
|
|
18
|
+
return validMembers.includes(entry.id) ? [entry] : [];
|
|
19
|
+
}).filter(entry => { const key = `${entry.id}:${entry.start}:${entry.end}`; if (seen.has(key))
|
|
20
|
+
return false; seen.add(key); return true; });
|
|
21
|
+
}
|
|
22
|
+
/** Build the existing wire protocol inside the owner, before durable acceptance. */
|
|
23
|
+
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");
|
|
29
|
+
const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
|
|
30
|
+
if (recipients && (!recipients.length || recipients.some(id => typeof id !== "string" || !id)))
|
|
31
|
+
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");
|
|
35
|
+
let content, contentKind = "text", voiceBlob;
|
|
36
|
+
if ("text" in input) {
|
|
37
|
+
if (typeof input.text !== "string")
|
|
38
|
+
throw new Error("INVALID_MESSAGE_TEXT");
|
|
39
|
+
const mentions = thread.kind === "group" ? expandGroupMentions(input.text, input.mentions, recipients, scope.profileId) : input.mentions;
|
|
40
|
+
content = { ...base, text: input.text, ...(input.quote ? { quote: input.quote } : {}), ...(mentions?.length ? { mentions } : {}) };
|
|
41
|
+
}
|
|
42
|
+
else if ("image" in input && input.image) {
|
|
43
|
+
if (typeof input.image.url !== "string" || !input.image.url)
|
|
44
|
+
throw new Error("INVALID_MESSAGE_IMAGE");
|
|
45
|
+
contentKind = "image";
|
|
46
|
+
content = { ...base, url: input.image.url, ...(input.image.name === undefined ? {} : { name: input.image.name }) };
|
|
47
|
+
}
|
|
48
|
+
else if ("voice" in input && input.voice) {
|
|
49
|
+
const voice = input.voice;
|
|
50
|
+
if (!Number.isFinite(voice.durationMs) || voice.durationMs <= 0 || !("blob" in voice ? voice.blob instanceof Blob : typeof voice.url === "string" && voice.url.length > 0))
|
|
51
|
+
throw new Error("INVALID_MESSAGE_VOICE");
|
|
52
|
+
voiceBlob = voice.blob;
|
|
53
|
+
content = { ...base, text: "[语音]", voice: { url: voiceBlob ? "" : voice.url, durationMs: voice.durationMs, ...(voice.transcript ? { transcript: voice.transcript } : {}) } };
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
throw new Error("INVALID_MESSAGE_CONTENT");
|
|
57
|
+
return { to: thread.targetId, body: { kind: "message", threadKind: thread.kind, contentKind, content }, ...(recipients ? { recipients } : {}), ...(voiceBlob ? { voiceBlob } : {}) };
|
|
58
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
1
2
|
/** The SDK owns upload I/O; hosts configure only their service endpoint. */
|
|
2
3
|
export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.chat", fetcher = globalThis.fetch) {
|
|
3
4
|
return async (blob, signal) => {
|
|
5
|
+
if (signal.aborted)
|
|
6
|
+
throw new DOMException("Upload aborted", "AbortError");
|
|
7
|
+
if (isOffline())
|
|
8
|
+
throw new Error("VOICE_UPLOAD_OFFLINE");
|
|
4
9
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
5
10
|
if (signal.aborted)
|
|
6
11
|
throw new DOMException("Upload aborted", "AbortError");
|
|
@@ -9,6 +14,8 @@ export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.cha
|
|
|
9
14
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
10
15
|
const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("mpeg") ? "mp3" : "webm";
|
|
11
16
|
const type = extension === "m4a" ? "audio/mp4" : extension === "mp3" ? "audio/mpeg" : "audio/webm";
|
|
17
|
+
if (isOffline())
|
|
18
|
+
throw new Error("VOICE_UPLOAD_OFFLINE");
|
|
12
19
|
const response = await fetcher(`${endpoint.replace(/\/+$/, "")}/file`, { method: "POST", signal, headers: { "Content-Type": "application/json", "x-file-content-type": type }, body: JSON.stringify({ user: profileId, base64: btoa(binary), filename: `voice-${Date.now()}.${extension}` }) });
|
|
13
20
|
if (!response.ok)
|
|
14
21
|
throw new Error("VOICE_UPLOAD_FAILED");
|
package/package.json
CHANGED