@pmate/chat-sdk 0.9.10 → 0.10.0
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 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/runtime/activity.d.ts +2 -0
- package/lib/runtime/activity.js +7 -3
- package/lib/runtime/chatClient.d.ts +9 -0
- package/lib/runtime/chatClient.js +13 -5
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/readIndex.d.ts +11 -0
- package/lib/runtime/readIndex.js +31 -5
- package/lib/runtime/readSync.js +12 -0
- package/lib/runtime/readerClient.d.ts +4 -0
- package/lib/runtime/teamMigrations.d.ts +45 -0
- package/lib/runtime/teamMigrations.js +32 -0
- package/lib/runtime/teamScope.d.ts +5 -0
- package/lib/runtime/teamScope.js +53 -0
- package/lib/runtime/thread.d.ts +4 -3
- package/lib/runtime/thread.js +23 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,3 +72,16 @@ are explicit errors.
|
|
|
72
72
|
and withholds incomplete charts. A revision gap requires fetching a snapshot.
|
|
73
73
|
This reducer does not itself create a network stream; the sender must provide
|
|
74
74
|
updates and the receiver must preserve its existing identity/authorization checks.
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
## Team conversations
|
|
78
|
+
|
|
79
|
+
### Background
|
|
80
|
+
|
|
81
|
+
A profile can belong to several existing organizations. Pass the organization's stable ID as `teamId` to keep conversations separate; never use the team's display name as identity. A compatible Hub must validate membership before accepting Team messages.
|
|
82
|
+
|
|
83
|
+
`dmThreadId(appId, ownProfileId, peerProfileId, teamId)` and `groupThreadId(appId, groupId, teamId)` produce Team-specific IDs matching Rust social. Omitting `teamId` retains the legacy ID for migration lookup and existing unscoped apps. `ChatClient` accepts a default `teamId`; `sendDm`, `sendGroup`, group operations, contact actions and activity targets also accept explicit scope. Conflicting explicit and default scopes are rejected. Raw `sendChat` callers can use `withChatTeam(body, teamId)`.
|
|
84
|
+
|
|
85
|
+
`TeamMigrationClient.plan(peerId)` reads the authenticated caller's legacy history summary and common Team candidates. `assign(peerId, teamId)` submits a durable ownership decision. A `pending` result means the indexer has not confirmed the result: plan again, do not report success yet. `TeamMigrationError` with status 409 includes the winning assignment when concurrent selections conflict.
|
|
86
|
+
|
|
87
|
+
Migrated reading snapshots retain source `id`/sequence positions and add `conversationId`/`teamId`. Use `sourcesInConversation` and `unreadInConversation` to render the logical conversation. `recordReadIntent` resolves the message back to its durable source, preserving old offline pending reads and explicit voice reads. Do not merge different sources' sequence numbers into a single cursor.
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export type ChatActivityState = "working" | "typing" | "idle";
|
|
|
3
3
|
export type ChatActivityBody = {
|
|
4
4
|
kind: "activity";
|
|
5
5
|
appId: string;
|
|
6
|
+
teamId?: string;
|
|
6
7
|
threadKind: "dm" | "group";
|
|
7
8
|
activityId: string;
|
|
8
9
|
seq: number;
|
|
@@ -23,6 +24,7 @@ export type ChatActivity = ChatActivityBody & {
|
|
|
23
24
|
};
|
|
24
25
|
export type ActivityTarget = {
|
|
25
26
|
to: string;
|
|
27
|
+
teamId?: string;
|
|
26
28
|
threadKind?: "dm" | "group";
|
|
27
29
|
recipients?: string[];
|
|
28
30
|
replyTo?: string;
|
package/lib/runtime/activity.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { ChatMsgOp, createRuntimeMsg } from "./protocol.js";
|
|
2
|
-
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
2
|
+
import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
|
|
3
3
|
export const ACTIVITY_TTL_MS = 15_000;
|
|
4
4
|
export function createActivityMessage(input) {
|
|
5
|
+
if (input.teamId !== undefined && !isValidChatTeamId(input.teamId))
|
|
6
|
+
throw new Error("Invalid chat teamId");
|
|
5
7
|
const body = { kind: "activity", appId: input.appId, threadKind: input.threadKind ?? "dm", activityId: input.activityId, seq: input.seq, state: input.state, ttlMs: ACTIVITY_TTL_MS,
|
|
8
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
6
9
|
...(input.phase ? { phase: input.phase } : {}),
|
|
7
10
|
...(input.replyTo ? { replyTo: input.replyTo } : {}),
|
|
8
11
|
...(input.detail ? { detail: Array.from(input.detail).slice(0, 120).join("") } : {}) };
|
|
@@ -16,6 +19,7 @@ export function parseChatActivity(message, appId, profileId, now = Date.now()) {
|
|
|
16
19
|
if (message.opcode !== ChatMsgOp.CHAT || body?.kind !== "activity" || body.appId !== appId || !message.from || message.from === profileId || !message.to
|
|
17
20
|
|| (body.threadKind !== "dm" && body.threadKind !== "group")
|
|
18
21
|
|| (body.threadKind === "dm" ? message.to !== profileId : !message.recipients?.includes(profileId))
|
|
22
|
+
|| (body.teamId !== undefined && !isValidChatTeamId(body.teamId))
|
|
19
23
|
|| typeof body.activityId !== "string" || !body.activityId || body.activityId.length > 128
|
|
20
24
|
|| !Number.isSafeInteger(body.seq) || body.seq < 0
|
|
21
25
|
|| !["working", "typing", "idle"].includes(body.state ?? "")
|
|
@@ -26,7 +30,7 @@ export function parseChatActivity(message, appId, profileId, now = Date.now()) {
|
|
|
26
30
|
|| (body.detail !== undefined && (typeof body.detail !== "string" || Array.from(body.detail).length > 120)))
|
|
27
31
|
return null;
|
|
28
32
|
return { ...body, from: message.from, to: message.to,
|
|
29
|
-
threadId: body.threadKind === "dm" ? dmThreadId(appId, message.from, profileId) : groupThreadId(appId, message.to),
|
|
33
|
+
threadId: body.threadKind === "dm" ? dmThreadId(appId, message.from, profileId, body.teamId) : groupThreadId(appId, message.to, body.teamId),
|
|
30
34
|
updatedAt: message.t, expiresAt: Math.min(message.t + body.ttlMs, now + ACTIVITY_TTL_MS) };
|
|
31
35
|
}
|
|
32
36
|
/** Keeps ordering tombstones after completion so a late heartbeat cannot revive an activity. */
|
|
@@ -56,7 +60,7 @@ export class ChatActivityStore {
|
|
|
56
60
|
return;
|
|
57
61
|
let changed = false;
|
|
58
62
|
for (const [key, record] of this.records) {
|
|
59
|
-
if (record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && (record.replyTo && body.content?.replyTo ? record.replyTo === body.content.replyTo : record.updatedAt <= message.t) && record.state !== "idle") {
|
|
63
|
+
if (record.teamId === body.content?.teamId && record.from === message.from && record.to === message.to && record.threadKind === body.threadKind && (record.replyTo && body.content?.replyTo ? record.replyTo === body.content.replyTo : record.updatedAt <= message.t) && record.state !== "idle") {
|
|
60
64
|
this.records.set(key, { ...record, state: "idle" });
|
|
61
65
|
changed = true;
|
|
62
66
|
}
|
|
@@ -10,6 +10,7 @@ import { type ChatBody, type ChatRuntimeMessage, type RealtimeClient } from "./p
|
|
|
10
10
|
export type ChatConnectionState = "connecting" | "reconnecting" | "authenticating" | "connected" | "authentication_required" | "closed";
|
|
11
11
|
export type ChatClientOptions = {
|
|
12
12
|
appId: string;
|
|
13
|
+
teamId?: string;
|
|
13
14
|
clientInstanceId?: string;
|
|
14
15
|
onReach?: OnlineReachHandler;
|
|
15
16
|
onNotification?: OnlineNotificationHandler;
|
|
@@ -72,33 +73,40 @@ export declare class ChatClient {
|
|
|
72
73
|
startActivity(target: ActivityTarget, initial?: ChatActivityUpdate): ChatActivityPublisher;
|
|
73
74
|
requestFriend(input: {
|
|
74
75
|
to: string;
|
|
76
|
+
teamId?: string;
|
|
75
77
|
message?: string;
|
|
76
78
|
}): Promise<ChatRuntimeMessage>;
|
|
77
79
|
acceptFriend(input: {
|
|
78
80
|
to: string;
|
|
81
|
+
teamId?: string;
|
|
79
82
|
requestId?: string;
|
|
80
83
|
message?: string;
|
|
81
84
|
}): Promise<ChatRuntimeMessage>;
|
|
82
85
|
rejectFriend(input: {
|
|
83
86
|
to: string;
|
|
87
|
+
teamId?: string;
|
|
84
88
|
requestId?: string;
|
|
85
89
|
reason?: string;
|
|
86
90
|
}): Promise<ChatRuntimeMessage>;
|
|
87
91
|
removeFriend(input: {
|
|
88
92
|
to: string;
|
|
93
|
+
teamId?: string;
|
|
89
94
|
reason?: string;
|
|
90
95
|
}): Promise<ChatRuntimeMessage>;
|
|
91
96
|
sendDm(input: {
|
|
92
97
|
to: string;
|
|
93
98
|
text: string;
|
|
99
|
+
teamId?: string;
|
|
94
100
|
}): Promise<ChatRuntimeMessage>;
|
|
95
101
|
createGroup(input: {
|
|
96
102
|
groupId: string;
|
|
103
|
+
teamId?: string;
|
|
97
104
|
members: string[];
|
|
98
105
|
title?: string;
|
|
99
106
|
}): Promise<ChatRuntimeMessage>;
|
|
100
107
|
updateGroup(input: {
|
|
101
108
|
groupId: string;
|
|
109
|
+
teamId?: string;
|
|
102
110
|
expectedRevision: string;
|
|
103
111
|
addMembers?: string[];
|
|
104
112
|
removeMembers?: string[];
|
|
@@ -107,6 +115,7 @@ export declare class ChatClient {
|
|
|
107
115
|
}): Promise<ChatRuntimeMessage>;
|
|
108
116
|
sendGroup(input: {
|
|
109
117
|
groupId: string;
|
|
118
|
+
teamId?: string;
|
|
110
119
|
text: string;
|
|
111
120
|
recipients?: string[];
|
|
112
121
|
mentions?: ChatMention[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withChatTeam } from "./teamScope.js";
|
|
1
2
|
import { OnlineReachReceiver } from "./reach.js";
|
|
2
3
|
import { OnlineNotificationReceiver } from "./notification.js";
|
|
3
4
|
import { ChatPresenceClient } from "./presence.js";
|
|
@@ -175,7 +176,9 @@ export class ChatClient {
|
|
|
175
176
|
}
|
|
176
177
|
watchActivities(handler) { return this.activities.watch(handler); }
|
|
177
178
|
async sendActivity(input) {
|
|
178
|
-
|
|
179
|
+
if (input.teamId !== undefined && this.options.teamId !== undefined && input.teamId !== this.options.teamId)
|
|
180
|
+
throw new Error("Chat teamId does not match the active team");
|
|
181
|
+
const message = createActivityMessage({ ...input, teamId: this.options.teamId ?? input.teamId, from: this.profileId(), appId: this.options.appId });
|
|
179
182
|
await this.sendAndWaitForAck(message);
|
|
180
183
|
return message;
|
|
181
184
|
}
|
|
@@ -191,22 +194,26 @@ export class ChatClient {
|
|
|
191
194
|
async requestFriend(input) {
|
|
192
195
|
return this.sendContactAction(input.to, "friendRequest", {
|
|
193
196
|
message: input.message,
|
|
197
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
194
198
|
});
|
|
195
199
|
}
|
|
196
200
|
async acceptFriend(input) {
|
|
197
201
|
return this.sendContactAction(input.to, "friendAccept", {
|
|
202
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
198
203
|
requestId: input.requestId,
|
|
199
204
|
message: input.message,
|
|
200
205
|
});
|
|
201
206
|
}
|
|
202
207
|
async rejectFriend(input) {
|
|
203
208
|
return this.sendContactAction(input.to, "friendReject", {
|
|
209
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
204
210
|
requestId: input.requestId,
|
|
205
211
|
reason: input.reason,
|
|
206
212
|
});
|
|
207
213
|
}
|
|
208
214
|
async removeFriend(input) {
|
|
209
215
|
return this.sendContactAction(input.to, "friendRemove", {
|
|
216
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
210
217
|
reason: input.reason,
|
|
211
218
|
});
|
|
212
219
|
}
|
|
@@ -215,7 +222,7 @@ export class ChatClient {
|
|
|
215
222
|
kind: "message",
|
|
216
223
|
threadKind: "dm",
|
|
217
224
|
contentKind: "text",
|
|
218
|
-
content: { appId: this.options.appId, text: input.text },
|
|
225
|
+
content: { appId: this.options.appId, text: input.text, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}) },
|
|
219
226
|
});
|
|
220
227
|
}
|
|
221
228
|
async createGroup(input) {
|
|
@@ -226,6 +233,7 @@ export class ChatClient {
|
|
|
226
233
|
payload: {
|
|
227
234
|
appId: this.options.appId,
|
|
228
235
|
groupId: input.groupId,
|
|
236
|
+
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
229
237
|
members: input.members,
|
|
230
238
|
title: input.title,
|
|
231
239
|
},
|
|
@@ -234,7 +242,7 @@ export class ChatClient {
|
|
|
234
242
|
async updateGroup(input) {
|
|
235
243
|
return this.sendChat(input.groupId, {
|
|
236
244
|
kind: "group", threadKind: "group", action: "groupUpdate",
|
|
237
|
-
payload: { appId: this.options.appId, groupId: input.groupId, expectedRevision: input.expectedRevision,
|
|
245
|
+
payload: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), expectedRevision: input.expectedRevision,
|
|
238
246
|
addMembers: input.addMembers ?? [], removeMembers: input.removeMembers ?? [], ...(input.title !== undefined ? { title: input.title } : {}), ...(input.avatar !== undefined ? { avatar: input.avatar } : {}) },
|
|
239
247
|
});
|
|
240
248
|
}
|
|
@@ -243,11 +251,11 @@ export class ChatClient {
|
|
|
243
251
|
kind: "message",
|
|
244
252
|
threadKind: "group",
|
|
245
253
|
contentKind: "text",
|
|
246
|
-
content: { appId: this.options.appId, groupId: input.groupId, text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
|
|
254
|
+
content: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
|
|
247
255
|
}, input.recipients);
|
|
248
256
|
}
|
|
249
257
|
async sendChat(to, body, recipients) {
|
|
250
|
-
const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, body, ChatMsgKind.DM);
|
|
258
|
+
const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, withChatTeam(body, this.options.teamId), ChatMsgKind.DM);
|
|
251
259
|
if (recipients) {
|
|
252
260
|
msg.recipients = recipients;
|
|
253
261
|
}
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -18,7 +18,11 @@ export type ReadingOperation = {
|
|
|
18
18
|
explicitReadMessageIds: string[];
|
|
19
19
|
};
|
|
20
20
|
export type IndexedReadThread = {
|
|
21
|
+
/** Durable source ID: reading positions must remain attached to this ID. */
|
|
21
22
|
id: string;
|
|
23
|
+
teamId?: string;
|
|
24
|
+
/** Present when historical source data has been assigned to a team conversation. */
|
|
25
|
+
conversationId?: string;
|
|
22
26
|
kind: "dm" | "group";
|
|
23
27
|
messages: IndexedReadMessage[];
|
|
24
28
|
readThroughSeq: number;
|
|
@@ -86,3 +90,10 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
86
90
|
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
87
91
|
close(): Promise<void>;
|
|
88
92
|
}
|
|
93
|
+
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
|
94
|
+
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
95
|
+
export declare function unreadInConversation(state: ReadIndexState, conversationId: string): {
|
|
96
|
+
unreadMessageCount: number;
|
|
97
|
+
unreadMentionCount: number;
|
|
98
|
+
badgeCount: number;
|
|
99
|
+
};
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
1
2
|
export class ReadSyncGap extends Error {
|
|
2
3
|
cursor;
|
|
3
4
|
constructor(cursor) {
|
|
@@ -11,6 +12,10 @@ export function createReadIndex(appId, profileId) {
|
|
|
11
12
|
function validateThread(thread) {
|
|
12
13
|
if (!thread.id || !["dm", "group"].includes(thread.kind) || !Number.isSafeInteger(thread.readThroughSeq) || thread.readThroughSeq < 0)
|
|
13
14
|
throw new Error("INVALID_READ_THREAD");
|
|
15
|
+
if (thread.teamId !== undefined && !isValidChatTeamId(thread.teamId))
|
|
16
|
+
throw new Error("INVALID_READ_TEAM");
|
|
17
|
+
if (thread.conversationId !== undefined && (!thread.conversationId || !thread.teamId))
|
|
18
|
+
throw new Error("INVALID_READ_CONVERSATION");
|
|
14
19
|
const ids = new Set(), positions = new Set();
|
|
15
20
|
for (const message of thread.messages) {
|
|
16
21
|
if (!message.id || !Number.isSafeInteger(message.seq) || message.seq < 1 || ids.has(message.id) || positions.has(message.seq)
|
|
@@ -89,7 +94,7 @@ function resolveObservedReads(current) {
|
|
|
89
94
|
let next = current;
|
|
90
95
|
const remaining = [];
|
|
91
96
|
for (const intent of current.observedReadIntents ?? []) {
|
|
92
|
-
const message = next
|
|
97
|
+
const message = readSource(next, intent.threadId, intent.messageId)?.messages.find(message => message.id === intent.messageId);
|
|
93
98
|
if (!message) {
|
|
94
99
|
remaining.push(intent);
|
|
95
100
|
continue;
|
|
@@ -105,22 +110,22 @@ function resolveObservedReads(current) {
|
|
|
105
110
|
}
|
|
106
111
|
/** Called only for a message the visible viewport actually reached. */
|
|
107
112
|
export function recordVisibleRead(current, threadId, messageId, operationId) {
|
|
108
|
-
const thread = current
|
|
113
|
+
const thread = readSource(current, threadId, messageId);
|
|
109
114
|
const message = thread?.messages.find(m => m.id === messageId);
|
|
110
115
|
if (!thread || !message || message.readPolicy !== "visible")
|
|
111
116
|
throw new Error("INVALID_VISIBLE_READ");
|
|
112
117
|
if (message.seq <= thread.readThroughSeq)
|
|
113
118
|
return current;
|
|
114
|
-
return enqueue(current, { operationId, threadId, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
119
|
+
return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
115
120
|
}
|
|
116
121
|
export function recordExplicitRead(current, threadId, messageId, operationId) {
|
|
117
|
-
const thread = current
|
|
122
|
+
const thread = readSource(current, threadId, messageId);
|
|
118
123
|
const message = thread?.messages.find(m => m.id === messageId);
|
|
119
124
|
if (!thread || !message || message.readPolicy !== "explicit")
|
|
120
125
|
throw new Error("INVALID_EXPLICIT_READ");
|
|
121
126
|
if (thread.explicitReadMessageIds.includes(messageId))
|
|
122
127
|
return current;
|
|
123
|
-
return enqueue(current, { operationId, threadId, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
|
|
128
|
+
return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
|
|
124
129
|
}
|
|
125
130
|
function enqueue(current, operation) {
|
|
126
131
|
if (!operation.operationId || current.pending.some(p => p.operationId === operation.operationId))
|
|
@@ -207,3 +212,24 @@ export class IndexedDbReadIndex {
|
|
|
207
212
|
}
|
|
208
213
|
async close() { (await this.database).close(); }
|
|
209
214
|
}
|
|
215
|
+
export function sourcesInConversation(state, conversationId) {
|
|
216
|
+
return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
|
|
217
|
+
}
|
|
218
|
+
function readSource(state, conversationId, messageId) {
|
|
219
|
+
const sources = state.threads.filter(thread => (thread.id === conversationId || thread.conversationId === conversationId) && thread.messages.some(message => message.id === messageId));
|
|
220
|
+
if (sources.length > 1)
|
|
221
|
+
throw new Error("AMBIGUOUS_READ_SOURCE");
|
|
222
|
+
return sources[0];
|
|
223
|
+
}
|
|
224
|
+
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
225
|
+
export function unreadInConversation(state, conversationId) {
|
|
226
|
+
const sources = sourcesInConversation(state, conversationId);
|
|
227
|
+
if (!sources.length)
|
|
228
|
+
throw new Error("UNKNOWN_READ_CONVERSATION");
|
|
229
|
+
if (new Set(sources.map(source => source.teamId)).size > 1)
|
|
230
|
+
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
231
|
+
return sources.reduce((total, source) => {
|
|
232
|
+
const unread = unreadInThread(state, source.id);
|
|
233
|
+
return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
|
|
234
|
+
}, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
|
|
235
|
+
}
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -47,7 +47,19 @@ export class ReadSyncClient {
|
|
|
47
47
|
async run() {
|
|
48
48
|
const { appId, profileId, persistence } = this.options;
|
|
49
49
|
const current = await this.loadLocal();
|
|
50
|
+
// Ordinary reads are monotonic: only the furthest position per thread needs
|
|
51
|
+
// sending. Keep every original operation in storage until the projection
|
|
52
|
+
// confirms it, and never rewrite an operation ID's payload during retries.
|
|
53
|
+
const latest = new Map();
|
|
50
54
|
for (const operation of current.pending) {
|
|
55
|
+
if (operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0)
|
|
56
|
+
continue;
|
|
57
|
+
const previous = latest.get(operation.threadId);
|
|
58
|
+
if (!previous || operation.readThroughSeq > previous.readThroughSeq)
|
|
59
|
+
latest.set(operation.threadId, operation);
|
|
60
|
+
}
|
|
61
|
+
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
|
|
62
|
+
for (const operation of outgoing) {
|
|
51
63
|
const receipt = await this.request("/api/social/reading", {
|
|
52
64
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
|
|
53
65
|
});
|
|
@@ -32,6 +32,7 @@ export type ChatReaderThread = {
|
|
|
32
32
|
unread: number;
|
|
33
33
|
};
|
|
34
34
|
export type ChatReaderContact = {
|
|
35
|
+
teamId?: string;
|
|
35
36
|
id?: string;
|
|
36
37
|
accountId?: string;
|
|
37
38
|
peerId?: string;
|
|
@@ -46,6 +47,8 @@ export type ChatReaderContact = {
|
|
|
46
47
|
members?: string[];
|
|
47
48
|
};
|
|
48
49
|
export type ChatReaderSocialMessage = {
|
|
50
|
+
teamId?: string;
|
|
51
|
+
sourceThreadId?: string;
|
|
49
52
|
appId: string;
|
|
50
53
|
threadId: string;
|
|
51
54
|
threadKind: "dm" | "group";
|
|
@@ -64,6 +67,7 @@ export type ChatReaderMessagePage = {
|
|
|
64
67
|
data: ChatReaderMessage[];
|
|
65
68
|
};
|
|
66
69
|
export type ChatReaderUnread = {
|
|
70
|
+
teamId?: string;
|
|
67
71
|
appId: string;
|
|
68
72
|
accountId: string;
|
|
69
73
|
threadId: string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type TeamAssignment = {
|
|
2
|
+
appId: string;
|
|
3
|
+
sourceThreadId: string;
|
|
4
|
+
threadId: string;
|
|
5
|
+
teamId: string;
|
|
6
|
+
actorId: string;
|
|
7
|
+
assignmentId: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
};
|
|
10
|
+
export type TeamMigrationPlan = {
|
|
11
|
+
status: "assigned" | "noHistory" | "noCommonTeam" | "ready" | "needsSelection";
|
|
12
|
+
sourceThreadId: string;
|
|
13
|
+
candidates: {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
}[];
|
|
17
|
+
messageCount?: number;
|
|
18
|
+
assignment?: TeamAssignment;
|
|
19
|
+
};
|
|
20
|
+
export type TeamMigrationResult = {
|
|
21
|
+
status: "assigned";
|
|
22
|
+
assignment: TeamAssignment;
|
|
23
|
+
} | {
|
|
24
|
+
status: "pending";
|
|
25
|
+
sourceThreadId: string;
|
|
26
|
+
};
|
|
27
|
+
export declare class TeamMigrationError extends Error {
|
|
28
|
+
readonly status: number;
|
|
29
|
+
readonly assignment?: TeamAssignment | undefined;
|
|
30
|
+
constructor(status: number, message: string, assignment?: TeamAssignment | undefined);
|
|
31
|
+
}
|
|
32
|
+
/** Planning does not mutate history. Assignment uses the server's current membership and winner. */
|
|
33
|
+
export declare class TeamMigrationClient {
|
|
34
|
+
private readonly options;
|
|
35
|
+
constructor(options: {
|
|
36
|
+
appId: string;
|
|
37
|
+
profileId: string;
|
|
38
|
+
token: string;
|
|
39
|
+
hubHttpUrl?: string;
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
});
|
|
42
|
+
plan(peerId: string): Promise<TeamMigrationPlan>;
|
|
43
|
+
assign(peerId: string, teamId: string): Promise<TeamMigrationResult>;
|
|
44
|
+
private request;
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class TeamMigrationError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
assignment;
|
|
4
|
+
constructor(status, message, assignment) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.assignment = assignment;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Planning does not mutate history. Assignment uses the server's current membership and winner. */
|
|
11
|
+
export class TeamMigrationClient {
|
|
12
|
+
options;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async plan(peerId) {
|
|
17
|
+
const query = new URLSearchParams({ appId: this.options.appId, profileId: this.options.profileId, peerId });
|
|
18
|
+
return this.request(`/plan?${query}`);
|
|
19
|
+
}
|
|
20
|
+
async assign(peerId, teamId) {
|
|
21
|
+
return this.request("", { method: "POST", body: JSON.stringify({ appId: this.options.appId, profileId: this.options.profileId, peerId, teamId }) });
|
|
22
|
+
}
|
|
23
|
+
async request(path, init = {}) {
|
|
24
|
+
const response = await (this.options.fetch ?? fetch)(`${(this.options.hubHttpUrl ?? "https://hub.pmate.chat").replace(/\/$/, "")}/api/social/team-migrations${path}`, {
|
|
25
|
+
...init, signal: AbortSignal.timeout(15_000), headers: { "content-type": "application/json", authorization: `Bearer ${this.options.token}` }, redirect: "error",
|
|
26
|
+
});
|
|
27
|
+
const envelope = await response.json();
|
|
28
|
+
if (!response.ok || envelope.success !== true || envelope.data === undefined)
|
|
29
|
+
throw new TeamMigrationError(response.status, envelope.message ?? "TEAM_MIGRATION_FAILED", envelope.data?.assignment);
|
|
30
|
+
return envelope.data;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ChatBody } from "./protocol.js";
|
|
2
|
+
/** Missing scope identifies legacy data; malformed scope must never become legacy. */
|
|
3
|
+
export declare function chatTeamId(body: unknown): string | undefined;
|
|
4
|
+
/** Preserve a message's scope and reject accidental sends into another active team. */
|
|
5
|
+
export declare function withChatTeam(body: ChatBody, teamId?: string): ChatBody;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
function metadata(body) {
|
|
6
|
+
const value = record(body);
|
|
7
|
+
switch (value?.kind) {
|
|
8
|
+
case "message":
|
|
9
|
+
case "pending":
|
|
10
|
+
case "edit": return record(value.content);
|
|
11
|
+
case "quote": return record(record(value.content)?.content);
|
|
12
|
+
case "contact":
|
|
13
|
+
case "group":
|
|
14
|
+
case "hello": return record(value.payload);
|
|
15
|
+
case "activity":
|
|
16
|
+
case "recall":
|
|
17
|
+
case "delete": return value;
|
|
18
|
+
default: return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Missing scope identifies legacy data; malformed scope must never become legacy. */
|
|
22
|
+
export function chatTeamId(body) {
|
|
23
|
+
const value = metadata(body);
|
|
24
|
+
if (!value || !("teamId" in value))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (!isValidChatTeamId(value.teamId))
|
|
27
|
+
throw new Error("Invalid chat teamId");
|
|
28
|
+
return value.teamId;
|
|
29
|
+
}
|
|
30
|
+
/** Preserve a message's scope and reject accidental sends into another active team. */
|
|
31
|
+
export function withChatTeam(body, teamId) {
|
|
32
|
+
const previous = chatTeamId(body);
|
|
33
|
+
if (teamId === undefined)
|
|
34
|
+
return body;
|
|
35
|
+
if (!isValidChatTeamId(teamId))
|
|
36
|
+
throw new Error("Invalid chat teamId");
|
|
37
|
+
if (previous !== undefined && previous !== teamId)
|
|
38
|
+
throw new Error("Chat teamId does not match the active team");
|
|
39
|
+
const scoped = { ...metadata(body), teamId };
|
|
40
|
+
switch (body.kind) {
|
|
41
|
+
case "message":
|
|
42
|
+
case "pending":
|
|
43
|
+
case "edit": return { ...body, content: scoped };
|
|
44
|
+
case "quote": return { ...body, content: { ...body.content, content: scoped } };
|
|
45
|
+
case "contact":
|
|
46
|
+
case "group":
|
|
47
|
+
case "hello": return { ...body, payload: scoped };
|
|
48
|
+
case "activity": return { ...body, teamId };
|
|
49
|
+
// Rust's legacy recall/delete bodies do not carry scope. Current clients use
|
|
50
|
+
// durable message content for recalls; fail explicitly until these upgrade.
|
|
51
|
+
default: throw new Error(`Team-scoped ${body.kind} is not supported`);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/lib/runtime/thread.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export declare function pairId(appId: string, userA: string, userB: string): string;
|
|
2
|
-
export declare function dmThreadId(appId: string, userA: string, userB: string): string;
|
|
3
|
-
export declare function groupThreadId(appId: string, groupId: string): string;
|
|
1
|
+
export declare function pairId(appId: string, userA: string, userB: string, teamId?: string): string;
|
|
2
|
+
export declare function dmThreadId(appId: string, userA: string, userB: string, teamId?: string): string;
|
|
3
|
+
export declare function groupThreadId(appId: string, groupId: string, teamId?: string): string;
|
|
4
|
+
export declare function isValidChatTeamId(value: unknown): value is string;
|
package/lib/runtime/thread.js
CHANGED
|
@@ -1,15 +1,32 @@
|
|
|
1
1
|
import { blake3 } from "@noble/hashes/blake3.js";
|
|
2
2
|
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
3
|
-
export function pairId(appId, userA, userB) {
|
|
3
|
+
export function pairId(appId, userA, userB, teamId) {
|
|
4
4
|
const [left, right] = userA <= userB ? [userA, userB] : [userB, userA];
|
|
5
|
-
return
|
|
5
|
+
return teamId === undefined
|
|
6
|
+
? stableHash(`social:v1:pair:${appId}:${left}:${right}`)
|
|
7
|
+
: teamThreadHash("pair", [appId, teamId, left, right]);
|
|
6
8
|
}
|
|
7
|
-
export function dmThreadId(appId, userA, userB) {
|
|
8
|
-
return
|
|
9
|
+
export function dmThreadId(appId, userA, userB, teamId) {
|
|
10
|
+
return teamId === undefined
|
|
11
|
+
? stableHash(`social:v1:dm:${appId}:${pairId(appId, userA, userB)}`)
|
|
12
|
+
: teamThreadHash("dm", [appId, teamId, pairId(appId, userA, userB)]);
|
|
9
13
|
}
|
|
10
|
-
export function groupThreadId(appId, groupId) {
|
|
11
|
-
return
|
|
14
|
+
export function groupThreadId(appId, groupId, teamId) {
|
|
15
|
+
return teamId === undefined
|
|
16
|
+
? stableHash(`social:v1:group:${appId}:${groupId}`)
|
|
17
|
+
: teamThreadHash("group", [appId, teamId, groupId]);
|
|
18
|
+
}
|
|
19
|
+
/** JSON tuples avoid ambiguity when application/team IDs contain separators. */
|
|
20
|
+
function teamThreadHash(kind, parts) {
|
|
21
|
+
const teamId = parts[1];
|
|
22
|
+
if (!isValidChatTeamId(teamId)) {
|
|
23
|
+
throw new Error("Invalid chat teamId");
|
|
24
|
+
}
|
|
25
|
+
return stableHash(`social:v2:${kind}:${JSON.stringify(parts)}`);
|
|
12
26
|
}
|
|
13
27
|
function stableHash(value) {
|
|
14
28
|
return bytesToHex(blake3(new TextEncoder().encode(value)));
|
|
15
29
|
}
|
|
30
|
+
export function isValidChatTeamId(value) {
|
|
31
|
+
return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).length <= 512 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
32
|
+
}
|