@atalk/sdk 0.1.0-alpha.10 → 0.1.0-alpha.12
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 +80 -3
- package/dist/agent.d.ts +66 -3
- package/dist/agent.js +723 -113
- package/dist/agent.js.map +1 -1
- package/dist/credential-store.d.ts +18 -0
- package/dist/credential-store.js +7 -3
- package/dist/credential-store.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime-state-store.d.ts +65 -0
- package/dist/runtime-state-store.js +107 -0
- package/dist/runtime-state-store.js.map +1 -0
- package/dist/workrooms.d.ts +329 -0
- package/dist/workrooms.js +1380 -0
- package/dist/workrooms.js.map +1 -0
- package/package.json +3 -3
package/dist/agent.js
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, extname, resolve } from "node:path";
|
|
4
|
-
import { decodeAttachmentMessage, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
|
|
4
|
+
import { decodeAttachmentMessage, createChunkedAttachmentDescriptor, decryptAttachmentChunk, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, encryptAttachmentChunk, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
|
|
5
5
|
import WebSocket from "ws";
|
|
6
|
-
import { FileCredentialStore } from "./credential-store.js";
|
|
6
|
+
import { FileCredentialStore, } from "./credential-store.js";
|
|
7
7
|
import { decryptTextNative, encryptTextNative, generateIdentityKeysNative } from "./native-core.js";
|
|
8
|
+
import { emptyRuntimeState, FileRuntimeStateStore, MemoryRuntimeStateStore, } from "./runtime-state-store.js";
|
|
9
|
+
import { WorkroomClient } from "./workrooms.js";
|
|
10
|
+
const MAX_PROCESSED_INCOMING = 10_000;
|
|
11
|
+
const DEFAULT_REFRESH_LEEWAY_MS = 5 * 60_000;
|
|
12
|
+
const FATAL_SESSION_CODES = new Set([
|
|
13
|
+
"AUTH_REQUIRED",
|
|
14
|
+
"INVALID_REFRESH_TOKEN",
|
|
15
|
+
"INVALID_SESSION",
|
|
16
|
+
"PEER_INACTIVE",
|
|
17
|
+
]);
|
|
8
18
|
export class Agent {
|
|
19
|
+
/** Durable, E2EE task/workroom API for this agent identity. */
|
|
20
|
+
workrooms;
|
|
9
21
|
baseUrl;
|
|
10
22
|
activationToken;
|
|
11
23
|
credentialStore;
|
|
24
|
+
runtimeStateStore;
|
|
25
|
+
credentialRefresher;
|
|
26
|
+
usesDefaultCredentialRefresher;
|
|
27
|
+
refreshLeewayMs;
|
|
12
28
|
supervisionEnabled;
|
|
13
29
|
credentials;
|
|
30
|
+
runtimeState = emptyRuntimeState();
|
|
31
|
+
stateMutation = Promise.resolve();
|
|
32
|
+
refreshPromise;
|
|
33
|
+
outboxDrain;
|
|
34
|
+
inboxDrain;
|
|
35
|
+
inboxRetryTimer;
|
|
36
|
+
inboxRetryAttempt = 0;
|
|
37
|
+
sentThisConnection = new Set();
|
|
14
38
|
socket;
|
|
15
39
|
ready = false;
|
|
16
40
|
reconnectAttempt = 0;
|
|
@@ -19,11 +43,31 @@ export class Agent {
|
|
|
19
43
|
errorHandler;
|
|
20
44
|
supervisors = [];
|
|
21
45
|
counterparties = new Map();
|
|
46
|
+
processingIncoming = new Map();
|
|
22
47
|
constructor(options) {
|
|
23
48
|
this.activationToken = options.token;
|
|
24
49
|
this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:4001").replace(/\/$/u, "");
|
|
25
50
|
this.credentialStore = options.credentialStore ?? new FileCredentialStore(options.token, options.credentialPath);
|
|
51
|
+
this.runtimeStateStore = options.runtimeStateStore
|
|
52
|
+
?? (options.runtimeStatePath
|
|
53
|
+
? new FileRuntimeStateStore(options.runtimeStatePath)
|
|
54
|
+
: this.credentialStore instanceof FileCredentialStore
|
|
55
|
+
? new FileRuntimeStateStore(`${this.credentialStore.path}.runtime.json`)
|
|
56
|
+
: new MemoryRuntimeStateStore());
|
|
57
|
+
this.usesDefaultCredentialRefresher = !options.refreshCredentials;
|
|
58
|
+
this.credentialRefresher = options.refreshCredentials ?? refreshAtalkCredentials;
|
|
59
|
+
this.refreshLeewayMs = Math.max(0, options.refreshLeewayMs ?? DEFAULT_REFRESH_LEEWAY_MS);
|
|
26
60
|
this.supervisionEnabled = options.supervision ?? true;
|
|
61
|
+
this.workrooms = new WorkroomClient({
|
|
62
|
+
request: (path, init) => this.request(path, init),
|
|
63
|
+
credentials: () => this.requireCredentials(),
|
|
64
|
+
runtimeState: () => this.runtimeState,
|
|
65
|
+
mutateRuntimeState: (mutator) => this.mutateRuntimeState(mutator),
|
|
66
|
+
uploadPart: (scope, id, bytes, transfer) => this.uploadAttachment(scope, id, bytes, transfer),
|
|
67
|
+
deletePart: (id) => this.deleteAttachmentPart(id),
|
|
68
|
+
downloadAttachment: (descriptor) => this.downloadAttachment(descriptor),
|
|
69
|
+
downloadAttachmentTo: (descriptor, path, transfer) => this.downloadAttachmentTo(descriptor, path, transfer),
|
|
70
|
+
});
|
|
27
71
|
}
|
|
28
72
|
get connected() {
|
|
29
73
|
return this.ready && this.socket?.readyState === WebSocket.OPEN;
|
|
@@ -40,18 +84,37 @@ export class Agent {
|
|
|
40
84
|
}
|
|
41
85
|
async start() {
|
|
42
86
|
this.stopped = false;
|
|
43
|
-
this.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
87
|
+
this.inboxRetryAttempt = 0;
|
|
88
|
+
this.runtimeState = (await this.runtimeStateStore.load()) ?? emptyRuntimeState();
|
|
89
|
+
const persistedCredentials = await this.credentialStore.load();
|
|
90
|
+
this.credentials = persistedCredentials ?? (await this.activate());
|
|
91
|
+
this.counterparties.clear();
|
|
92
|
+
for (const [conversationId, peer] of Object.entries(this.runtimeState.counterparties)) {
|
|
93
|
+
this.counterparties.set(conversationId, peer);
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await this.prepareAndConnect();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// A newly issued connection code is also the recovery path after an
|
|
100
|
+
// owner revoked this runtime's sessions. Reuse the locally persisted
|
|
101
|
+
// identity keys: silently generating a new pair would strand encrypted
|
|
102
|
+
// Task history and change the agent's cryptographic identity.
|
|
103
|
+
if (!persistedCredentials || !this.activationToken || !isSessionError(error))
|
|
104
|
+
throw error;
|
|
105
|
+
this.credentials = await this.activate(persistedCredentials.keys);
|
|
106
|
+
await this.prepareAndConnect();
|
|
47
107
|
}
|
|
48
|
-
await this.connect();
|
|
49
108
|
}
|
|
50
109
|
async stop() {
|
|
51
110
|
this.stopped = true;
|
|
52
111
|
this.ready = false;
|
|
53
112
|
this.reconnectAttempt = 0;
|
|
113
|
+
if (this.inboxRetryTimer)
|
|
114
|
+
clearTimeout(this.inboxRetryTimer);
|
|
115
|
+
this.inboxRetryTimer = undefined;
|
|
54
116
|
this.socket?.close(1000, "Agent stopped");
|
|
117
|
+
await this.stateMutation;
|
|
55
118
|
}
|
|
56
119
|
async send(recipientHandle, text) {
|
|
57
120
|
return (await this.sendWithDetails(recipientHandle, text)).conversationId;
|
|
@@ -78,31 +141,82 @@ export class Agent {
|
|
|
78
141
|
return (await this.sendAttachmentFileWithDetails(recipientHandle, input)).conversationId;
|
|
79
142
|
}
|
|
80
143
|
async sendAttachmentFileWithDetails(recipientHandle, input) {
|
|
81
|
-
|
|
144
|
+
const conversationId = randomUUID();
|
|
145
|
+
const messageId = await this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
|
|
146
|
+
return { conversationId, messageId };
|
|
82
147
|
}
|
|
83
148
|
async sendAttachmentInConversation(recipientHandle, input, conversationId) {
|
|
84
149
|
return this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
|
|
85
150
|
}
|
|
86
151
|
async sendAttachmentFileInConversation(recipientHandle, input, conversationId) {
|
|
87
|
-
return this.
|
|
152
|
+
return this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
|
|
153
|
+
}
|
|
154
|
+
/** Download an attachment descriptor retained by a durable bridge. */
|
|
155
|
+
async downloadAttachment(descriptor) {
|
|
156
|
+
const parts = [];
|
|
157
|
+
for (const part of attachmentPartDescriptors(descriptor)) {
|
|
158
|
+
const response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${part.id}`);
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
throw await responseError(response);
|
|
161
|
+
parts.push(new Uint8Array(await response.arrayBuffer()));
|
|
162
|
+
}
|
|
163
|
+
return decryptAttachment(joinEncryptedAttachmentParts(parts, descriptor), descriptor);
|
|
88
164
|
}
|
|
89
|
-
|
|
165
|
+
/** Stream-decrypt an attachment into an atomic local file without buffering the whole payload. */
|
|
166
|
+
async downloadAttachmentTo(descriptor, filePath, options) {
|
|
167
|
+
return this.downloadAttachmentToFile(descriptor, filePath, options);
|
|
168
|
+
}
|
|
169
|
+
/** Mark an incoming message as read when only its durable id is available. */
|
|
170
|
+
async markMessageRead(messageId) {
|
|
171
|
+
await this.completeIncoming(messageId, "READ");
|
|
172
|
+
this.sendFrame({ kind: "ACK", messageId, state: "READ" });
|
|
173
|
+
this.scheduleInboxRetry();
|
|
174
|
+
}
|
|
175
|
+
async activate(existingKeys) {
|
|
90
176
|
if (!this.activationToken) {
|
|
91
177
|
throw new Error("ACTIVATION_REQUIRED: Provide a one-time token because no persisted credentials were found");
|
|
92
178
|
}
|
|
93
|
-
const
|
|
179
|
+
const remembered = this.runtimeState.pendingActivation;
|
|
180
|
+
const pending = remembered && (!existingKeys || sameIdentityKeys(remembered.keys, existingKeys))
|
|
181
|
+
? remembered
|
|
182
|
+
: {
|
|
183
|
+
requestId: randomUUID(),
|
|
184
|
+
keys: existingKeys ?? generateIdentityKeysNative(),
|
|
185
|
+
};
|
|
186
|
+
if (pending !== remembered) {
|
|
187
|
+
await this.mutateRuntimeState((state) => { state.pendingActivation = pending; });
|
|
188
|
+
}
|
|
94
189
|
const response = await this.request("/v1/agents/activate", {
|
|
95
190
|
method: "POST",
|
|
96
191
|
body: JSON.stringify({
|
|
97
192
|
activationToken: this.activationToken,
|
|
98
|
-
|
|
99
|
-
|
|
193
|
+
activationRequestId: pending.requestId,
|
|
194
|
+
signingPublicKey: pending.keys.signingPublicKey,
|
|
195
|
+
encryptionPublicKey: pending.keys.encryptionPublicKey,
|
|
100
196
|
}),
|
|
101
197
|
}, false);
|
|
102
|
-
const
|
|
198
|
+
const access = response.accessToken ?? response.token;
|
|
199
|
+
const accessTokenExpiresAt = response.accessTokenExpiresAt ?? response.expiresAt;
|
|
200
|
+
const credentials = {
|
|
201
|
+
sessionToken: access,
|
|
202
|
+
accessToken: access,
|
|
203
|
+
...(response.refreshToken ? { refreshToken: response.refreshToken } : {}),
|
|
204
|
+
...(accessTokenExpiresAt ? { accessTokenExpiresAt } : {}),
|
|
205
|
+
peer: response.peer,
|
|
206
|
+
keys: pending.keys,
|
|
207
|
+
};
|
|
103
208
|
await this.credentialStore.save(credentials);
|
|
209
|
+
await this.mutateRuntimeState((state) => { delete state.pendingActivation; });
|
|
104
210
|
return credentials;
|
|
105
211
|
}
|
|
212
|
+
async prepareAndConnect() {
|
|
213
|
+
await this.refreshCredentialsIfNeeded("EXPIRING");
|
|
214
|
+
if (this.supervisionEnabled) {
|
|
215
|
+
const result = await this.request("/v1/agent-runtime/supervisors");
|
|
216
|
+
this.supervisors = result.supervisors;
|
|
217
|
+
}
|
|
218
|
+
await this.connectWithRefresh();
|
|
219
|
+
}
|
|
106
220
|
async connect() {
|
|
107
221
|
const credentials = this.requireCredentials();
|
|
108
222
|
const websocketUrl = `${this.baseUrl.replace(/^http/u, "ws")}/v1/ws`;
|
|
@@ -117,7 +231,7 @@ export class Agent {
|
|
|
117
231
|
reject(new Error("aTalk connection timed out"));
|
|
118
232
|
}
|
|
119
233
|
}, 10_000);
|
|
120
|
-
socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: credentials
|
|
234
|
+
socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: accessToken(credentials) })));
|
|
121
235
|
socket.on("message", (raw) => {
|
|
122
236
|
const frame = serverFrameSchema.parse(JSON.parse(raw.toString()));
|
|
123
237
|
void this.handleFrame(frame).then(() => {
|
|
@@ -125,8 +239,11 @@ export class Agent {
|
|
|
125
239
|
ready = true;
|
|
126
240
|
this.ready = true;
|
|
127
241
|
this.reconnectAttempt = 0;
|
|
242
|
+
this.sentThisConnection.clear();
|
|
128
243
|
clearTimeout(timeout);
|
|
129
244
|
resolve();
|
|
245
|
+
void this.drainOutbox().catch((error) => this.emitError(error));
|
|
246
|
+
void this.drainInbox();
|
|
130
247
|
}
|
|
131
248
|
}).catch((error) => {
|
|
132
249
|
if (!ready) {
|
|
@@ -136,6 +253,7 @@ export class Agent {
|
|
|
136
253
|
}
|
|
137
254
|
else {
|
|
138
255
|
this.emitError(error);
|
|
256
|
+
this.scheduleInboxRetry();
|
|
139
257
|
}
|
|
140
258
|
});
|
|
141
259
|
});
|
|
@@ -151,15 +269,14 @@ export class Agent {
|
|
|
151
269
|
if (this.socket === socket)
|
|
152
270
|
this.ready = false;
|
|
153
271
|
if (!ready) {
|
|
154
|
-
const error =
|
|
155
|
-
|
|
156
|
-
|
|
272
|
+
const error = code === 4001 || code === 1008
|
|
273
|
+
? new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected")
|
|
274
|
+
: new Error("aTalk connection closed before authentication");
|
|
157
275
|
reject(error);
|
|
158
276
|
return;
|
|
159
277
|
}
|
|
160
278
|
if (code === 4001 || code === 1008) {
|
|
161
|
-
this.
|
|
162
|
-
this.emitError(new Error("INVALID_SESSION: Agent credentials were revoked"));
|
|
279
|
+
void this.recoverRejectedSession();
|
|
163
280
|
return;
|
|
164
281
|
}
|
|
165
282
|
if (!this.stopped && ready)
|
|
@@ -174,7 +291,9 @@ export class Agent {
|
|
|
174
291
|
setTimeout(() => {
|
|
175
292
|
if (this.stopped)
|
|
176
293
|
return;
|
|
177
|
-
void this.
|
|
294
|
+
void this.connectWithRefresh().catch((error) => {
|
|
295
|
+
if (isSessionError(error))
|
|
296
|
+
this.stopped = true;
|
|
178
297
|
this.emitError(error);
|
|
179
298
|
if (!this.stopped)
|
|
180
299
|
this.scheduleReconnect();
|
|
@@ -182,16 +301,45 @@ export class Agent {
|
|
|
182
301
|
}, delay);
|
|
183
302
|
}
|
|
184
303
|
async handleFrame(frame) {
|
|
185
|
-
if (frame.kind === "ERROR")
|
|
186
|
-
throw new
|
|
304
|
+
if (frame.kind === "ERROR") {
|
|
305
|
+
throw new AgentProtocolError(frame.code, frame.message);
|
|
306
|
+
}
|
|
187
307
|
if (frame.kind === "RECEIPT") {
|
|
308
|
+
await this.removeFromOutbox(frame.messageId);
|
|
188
309
|
this.sendFrame({ kind: "RECEIPT_ACK", messageId: frame.messageId, state: frame.state });
|
|
189
310
|
return;
|
|
190
311
|
}
|
|
312
|
+
if (frame.kind === "ACK_RECEIVED") {
|
|
313
|
+
await this.forgetIncoming(frame.messageId);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
191
316
|
if (frame.kind !== "MESSAGE")
|
|
192
317
|
return;
|
|
318
|
+
const messageId = frame.envelope.message_id;
|
|
319
|
+
const confirmed = this.runtimeState.processedIncoming[messageId];
|
|
320
|
+
if (confirmed) {
|
|
321
|
+
this.sendFrame({ kind: "ACK", messageId, state: confirmed });
|
|
322
|
+
this.scheduleInboxRetry();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await this.rememberIncoming(frame.envelope);
|
|
326
|
+
const existing = this.processingIncoming.get(messageId);
|
|
327
|
+
if (existing) {
|
|
328
|
+
await existing;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const processing = this.processIncomingMessage(frame);
|
|
332
|
+
this.processingIncoming.set(messageId, processing);
|
|
333
|
+
try {
|
|
334
|
+
await processing;
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
this.processingIncoming.delete(messageId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async processIncomingMessage(frame) {
|
|
193
341
|
const credentials = this.requireCredentials();
|
|
194
|
-
const sender = await this.request(`/v1/
|
|
342
|
+
const sender = await this.request(`/v1/messages/${frame.envelope.message_id}/sender-keys`);
|
|
195
343
|
const text = decryptTextNative({
|
|
196
344
|
envelope: frame.envelope,
|
|
197
345
|
senderSigningPublicKey: sender.signingPublicKey,
|
|
@@ -202,61 +350,76 @@ export class Agent {
|
|
|
202
350
|
const content = directedMessage?.content ?? text;
|
|
203
351
|
const attachmentMessage = decodeAttachmentMessage(content);
|
|
204
352
|
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === sender.id);
|
|
353
|
+
const isMentioned = directedMessage?.mentions.some((mention) => mention.peerId === credentials.peer.id) ?? false;
|
|
354
|
+
const counterparty = isSupervisor ? this.counterparties.get(frame.envelope.conversation_id) : sender;
|
|
355
|
+
const routing = isSupervisor && !isMentioned
|
|
356
|
+
? { mode: "RELAY", targetHandle: counterparty?.handle ?? "" }
|
|
357
|
+
: { mode: "REPLY", targetHandle: sender.handle };
|
|
205
358
|
if (!isSupervisor) {
|
|
206
359
|
this.counterparties.set(frame.envelope.conversation_id, sender);
|
|
207
|
-
await this.
|
|
208
|
-
|
|
209
|
-
this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: "DELIVERED" });
|
|
210
|
-
if (this.messageHandler) {
|
|
211
|
-
await this.messageHandler({
|
|
212
|
-
id: frame.envelope.message_id,
|
|
213
|
-
conversationId: frame.envelope.conversation_id,
|
|
214
|
-
text: attachmentMessage?.caption ?? (attachmentMessage ? "" : content),
|
|
215
|
-
...(attachmentMessage ? { attachment: {
|
|
216
|
-
descriptor: attachmentMessage.attachment,
|
|
217
|
-
download: () => this.downloadAttachment(attachmentMessage.attachment),
|
|
218
|
-
downloadTo: (filePath) => this.downloadAttachmentToFile(attachmentMessage.attachment, filePath),
|
|
219
|
-
} } : {}),
|
|
220
|
-
sender,
|
|
221
|
-
receivedAt: new Date(frame.envelope.timestamp),
|
|
222
|
-
isSupervisor,
|
|
223
|
-
mentions: directedMessage?.mentions ?? [],
|
|
224
|
-
isMentioned: directedMessage?.mentions.some((mention) => mention.peerId === credentials.peer.id) ?? false,
|
|
225
|
-
reply: (replyText) => this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id),
|
|
226
|
-
replyAttachment: (input) => this.sendAttachmentEnvelope(sender.handle, input, frame.envelope.conversation_id),
|
|
227
|
-
replyAttachmentFile: async (input) => this.sendAttachmentEnvelope(sender.handle, await attachmentInputFromFile(input), frame.envelope.conversation_id),
|
|
228
|
-
relay: async (relayText) => {
|
|
229
|
-
if (!isSupervisor)
|
|
230
|
-
throw new Error("Only supervisor messages can be relayed");
|
|
231
|
-
const counterparty = this.counterparties.get(frame.envelope.conversation_id);
|
|
232
|
-
if (!counterparty)
|
|
233
|
-
throw new Error("No active counterparty exists for this supervised conversation");
|
|
234
|
-
return this.sendEnvelope(counterparty.handle, relayText, frame.envelope.conversation_id);
|
|
235
|
-
},
|
|
236
|
-
relayAttachment: async (input) => {
|
|
237
|
-
if (!isSupervisor)
|
|
238
|
-
throw new Error("Only supervisor messages can be relayed");
|
|
239
|
-
const counterparty = this.counterparties.get(frame.envelope.conversation_id);
|
|
240
|
-
if (!counterparty)
|
|
241
|
-
throw new Error("No active counterparty exists for this supervised conversation");
|
|
242
|
-
return this.sendAttachmentEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
|
|
243
|
-
},
|
|
244
|
-
relayAttachmentFile: async (input) => {
|
|
245
|
-
if (!isSupervisor)
|
|
246
|
-
throw new Error("Only supervisor messages can be relayed");
|
|
247
|
-
const counterparty = this.counterparties.get(frame.envelope.conversation_id);
|
|
248
|
-
if (!counterparty)
|
|
249
|
-
throw new Error("No active counterparty exists for this supervised conversation");
|
|
250
|
-
return this.sendAttachmentEnvelope(counterparty.handle, await attachmentInputFromFile(input), frame.envelope.conversation_id);
|
|
251
|
-
},
|
|
252
|
-
markRead: async () => this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: "READ" }),
|
|
360
|
+
await this.mutateRuntimeState((state) => {
|
|
361
|
+
state.counterparties[frame.envelope.conversation_id] = sender;
|
|
253
362
|
});
|
|
363
|
+
await this.mirrorActivity("INCOMING", sender, text, frame.envelope.conversation_id, frame.envelope.message_id, frame.envelope.timestamp);
|
|
254
364
|
}
|
|
365
|
+
if (!this.messageHandler)
|
|
366
|
+
throw new Error("MESSAGE_HANDLER_NOT_CONFIGURED: Register a message handler before start()");
|
|
367
|
+
let acknowledgedState = "DELIVERED";
|
|
368
|
+
let handlerComplete = false;
|
|
369
|
+
await this.messageHandler({
|
|
370
|
+
id: frame.envelope.message_id,
|
|
371
|
+
conversationId: frame.envelope.conversation_id,
|
|
372
|
+
text: attachmentMessage?.caption ?? (attachmentMessage ? "" : content),
|
|
373
|
+
...(attachmentMessage ? { attachment: {
|
|
374
|
+
descriptor: attachmentMessage.attachment,
|
|
375
|
+
download: () => this.downloadAttachment(attachmentMessage.attachment),
|
|
376
|
+
downloadTo: (filePath, options) => this.downloadAttachmentToFile(attachmentMessage.attachment, filePath, options),
|
|
377
|
+
} } : {}),
|
|
378
|
+
sender,
|
|
379
|
+
receivedAt: new Date(frame.envelope.timestamp),
|
|
380
|
+
isSupervisor,
|
|
381
|
+
mentions: directedMessage?.mentions ?? [],
|
|
382
|
+
isMentioned,
|
|
383
|
+
reply: (replyText) => this.sendEnvelope(sender.handle, replyText, frame.envelope.conversation_id),
|
|
384
|
+
replyAttachment: (input) => this.sendAttachmentEnvelope(sender.handle, input, frame.envelope.conversation_id),
|
|
385
|
+
replyAttachmentFile: (input) => this.sendAttachmentFileEnvelope(sender.handle, input, frame.envelope.conversation_id),
|
|
386
|
+
relay: async (relayText) => {
|
|
387
|
+
if (!isSupervisor)
|
|
388
|
+
throw new Error("Only supervisor messages can be relayed");
|
|
389
|
+
if (!counterparty)
|
|
390
|
+
throw new Error("No active counterparty exists for this supervised conversation");
|
|
391
|
+
return this.sendEnvelope(counterparty.handle, relayText, frame.envelope.conversation_id);
|
|
392
|
+
},
|
|
393
|
+
relayAttachment: async (input) => {
|
|
394
|
+
if (!isSupervisor)
|
|
395
|
+
throw new Error("Only supervisor messages can be relayed");
|
|
396
|
+
if (!counterparty)
|
|
397
|
+
throw new Error("No active counterparty exists for this supervised conversation");
|
|
398
|
+
return this.sendAttachmentEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
|
|
399
|
+
},
|
|
400
|
+
relayAttachmentFile: async (input) => {
|
|
401
|
+
if (!isSupervisor)
|
|
402
|
+
throw new Error("Only supervisor messages can be relayed");
|
|
403
|
+
if (!counterparty)
|
|
404
|
+
throw new Error("No active counterparty exists for this supervised conversation");
|
|
405
|
+
return this.sendAttachmentFileEnvelope(counterparty.handle, input, frame.envelope.conversation_id);
|
|
406
|
+
},
|
|
407
|
+
markRead: async () => {
|
|
408
|
+
if (!handlerComplete) {
|
|
409
|
+
acknowledgedState = "READ";
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
await this.markMessageRead(frame.envelope.message_id);
|
|
413
|
+
},
|
|
414
|
+
routing,
|
|
415
|
+
});
|
|
416
|
+
handlerComplete = true;
|
|
417
|
+
await this.completeIncoming(frame.envelope.message_id, acknowledgedState);
|
|
418
|
+
this.sendFrame({ kind: "ACK", messageId: frame.envelope.message_id, state: acknowledgedState });
|
|
419
|
+
this.scheduleInboxRetry();
|
|
255
420
|
}
|
|
256
421
|
async sendEnvelope(recipientHandle, text, conversationId) {
|
|
257
422
|
const credentials = this.requireCredentials();
|
|
258
|
-
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
259
|
-
throw new Error("Agent is not connected");
|
|
260
423
|
const { recipient } = await this.request("/v1/messages/authorize", {
|
|
261
424
|
method: "POST",
|
|
262
425
|
body: JSON.stringify({ recipientHandle }),
|
|
@@ -272,18 +435,21 @@ export class Agent {
|
|
|
272
435
|
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
273
436
|
recipientEncryptionPublicKey: recipient.encryptionPublicKey,
|
|
274
437
|
});
|
|
275
|
-
this.sendFrame({ kind: "DELIVER", envelope });
|
|
276
438
|
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
|
|
277
439
|
if (!isSupervisor) {
|
|
278
440
|
this.counterparties.set(conversationId, recipient);
|
|
441
|
+
await this.mutateRuntimeState((state) => {
|
|
442
|
+
state.counterparties[conversationId] = recipient;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
await this.queueEnvelope(envelope);
|
|
446
|
+
if (!isSupervisor) {
|
|
279
447
|
await this.mirrorActivity("OUTGOING", recipient, text, conversationId, envelope.message_id, envelope.timestamp);
|
|
280
448
|
}
|
|
281
449
|
return envelope.message_id;
|
|
282
450
|
}
|
|
283
451
|
async sendAttachmentEnvelope(recipientHandle, input, conversationId) {
|
|
284
452
|
const credentials = this.requireCredentials();
|
|
285
|
-
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
286
|
-
throw new Error("Agent is not connected");
|
|
287
453
|
const { recipient } = await this.request("/v1/messages/authorize", {
|
|
288
454
|
method: "POST",
|
|
289
455
|
body: JSON.stringify({ recipientHandle }),
|
|
@@ -295,7 +461,7 @@ export class Agent {
|
|
|
295
461
|
mimeType: input.mimeType ?? "application/octet-stream",
|
|
296
462
|
}), randomUUID);
|
|
297
463
|
for (const part of encrypted.parts) {
|
|
298
|
-
await this.uploadAttachment(recipient.id, part.id, part.ciphertext);
|
|
464
|
+
await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, part.ciphertext);
|
|
299
465
|
}
|
|
300
466
|
const caption = input.caption?.trim();
|
|
301
467
|
const plaintext = encodeAttachmentMessage({
|
|
@@ -313,43 +479,200 @@ export class Agent {
|
|
|
313
479
|
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
314
480
|
recipientEncryptionPublicKey: recipient.encryptionPublicKey,
|
|
315
481
|
});
|
|
316
|
-
this.sendFrame({ kind: "DELIVER", envelope });
|
|
317
482
|
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
|
|
318
483
|
if (!isSupervisor) {
|
|
319
484
|
this.counterparties.set(conversationId, recipient);
|
|
485
|
+
await this.mutateRuntimeState((state) => {
|
|
486
|
+
state.counterparties[conversationId] = recipient;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
await this.queueEnvelope(envelope);
|
|
490
|
+
if (!isSupervisor) {
|
|
320
491
|
await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
|
|
321
492
|
}
|
|
322
493
|
return envelope.message_id;
|
|
323
494
|
}
|
|
324
|
-
async
|
|
325
|
-
const
|
|
495
|
+
async sendAttachmentFileEnvelope(recipientHandle, input, conversationId) {
|
|
496
|
+
const credentials = this.requireCredentials();
|
|
497
|
+
const path = resolve(input.path);
|
|
498
|
+
const metadata = await stat(path);
|
|
499
|
+
if (!metadata.isFile())
|
|
500
|
+
throw new Error("ATTACHMENT_NOT_A_FILE");
|
|
501
|
+
const { recipient } = await this.request("/v1/messages/authorize", {
|
|
326
502
|
method: "POST",
|
|
327
|
-
|
|
328
|
-
authorization: `Bearer ${this.requireCredentials().sessionToken}`,
|
|
329
|
-
"content-type": "application/octet-stream",
|
|
330
|
-
},
|
|
331
|
-
body: exactArrayBuffer(ciphertext),
|
|
503
|
+
body: JSON.stringify({ recipientHandle }),
|
|
332
504
|
});
|
|
333
|
-
|
|
334
|
-
|
|
505
|
+
const descriptor = createChunkedAttachmentDescriptor({
|
|
506
|
+
id: randomUUID(),
|
|
507
|
+
size: metadata.size,
|
|
508
|
+
name: input.name?.trim() || basename(path),
|
|
509
|
+
mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
|
|
510
|
+
nextId: randomUUID,
|
|
511
|
+
});
|
|
512
|
+
if (descriptor.version !== 2)
|
|
513
|
+
throw new Error("ATTACHMENT_VERSION_UNSUPPORTED");
|
|
514
|
+
const file = await open(path, "r");
|
|
515
|
+
let transferred = 0;
|
|
516
|
+
try {
|
|
517
|
+
for (let index = 0; index < descriptor.chunks.length; index += 1) {
|
|
518
|
+
throwIfAborted(input.transfer?.signal);
|
|
519
|
+
const part = descriptor.chunks[index];
|
|
520
|
+
const plaintext = new Uint8Array(part.plaintextSize);
|
|
521
|
+
const { bytesRead } = await file.read(plaintext, 0, plaintext.byteLength, transferred);
|
|
522
|
+
if (bytesRead !== plaintext.byteLength)
|
|
523
|
+
throw new Error("ATTACHMENT_SIZE_MISMATCH");
|
|
524
|
+
await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, encryptAttachmentChunk(plaintext, descriptor, index), input.transfer);
|
|
525
|
+
transferred += bytesRead;
|
|
526
|
+
input.transfer?.onProgress?.({
|
|
527
|
+
phase: "UPLOADING",
|
|
528
|
+
bytesTransferred: transferred,
|
|
529
|
+
totalBytes: descriptor.size,
|
|
530
|
+
partIndex: index + 1,
|
|
531
|
+
partCount: descriptor.chunks.length,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
// Delete every id in the unpublished descriptor. This also removes a part
|
|
537
|
+
// whose POST reached the server but whose response was lost in transit.
|
|
538
|
+
await Promise.allSettled(descriptor.chunks.map((part) => this.deleteAttachmentPart(part.id)));
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
await file.close();
|
|
543
|
+
}
|
|
544
|
+
const caption = input.caption?.trim();
|
|
545
|
+
const plaintext = encodeAttachmentMessage({ attachment: descriptor, ...(caption ? { caption } : {}) });
|
|
546
|
+
const envelope = encryptTextNative({
|
|
547
|
+
messageId: randomUUID(),
|
|
548
|
+
conversationId,
|
|
549
|
+
senderPeerId: credentials.peer.id,
|
|
550
|
+
recipientPeerId: recipient.id,
|
|
551
|
+
timestamp: new Date().toISOString(),
|
|
552
|
+
plaintext,
|
|
553
|
+
senderSigningSecretKey: credentials.keys.signingSecretKey,
|
|
554
|
+
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
555
|
+
recipientEncryptionPublicKey: recipient.encryptionPublicKey,
|
|
556
|
+
});
|
|
557
|
+
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
|
|
558
|
+
if (!isSupervisor) {
|
|
559
|
+
this.counterparties.set(conversationId, recipient);
|
|
560
|
+
await this.mutateRuntimeState((state) => { state.counterparties[conversationId] = recipient; });
|
|
561
|
+
}
|
|
562
|
+
await this.queueEnvelope(envelope);
|
|
563
|
+
if (!isSupervisor) {
|
|
564
|
+
await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
|
|
565
|
+
}
|
|
566
|
+
return envelope.message_id;
|
|
335
567
|
}
|
|
336
|
-
async
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
568
|
+
async uploadAttachment(scope, attachmentId, ciphertext, options) {
|
|
569
|
+
throwIfAborted(options?.signal);
|
|
570
|
+
const existing = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}`, {
|
|
571
|
+
method: "HEAD", ...(options?.signal ? { signal: options.signal } : {}),
|
|
572
|
+
}).catch(() => undefined);
|
|
573
|
+
if (existing?.ok)
|
|
574
|
+
return;
|
|
575
|
+
const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
|
|
576
|
+
let lastError;
|
|
577
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
578
|
+
throwIfAborted(options?.signal);
|
|
579
|
+
let response;
|
|
580
|
+
try {
|
|
581
|
+
response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${attachmentId}?${"recipientPeerId" in scope
|
|
582
|
+
? `recipientPeerId=${encodeURIComponent(scope.recipientPeerId)}`
|
|
583
|
+
: `workroomId=${encodeURIComponent(scope.workroomId)}`}`, {
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: { "content-type": "application/octet-stream" },
|
|
586
|
+
body: exactArrayBuffer(ciphertext),
|
|
587
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
if (options?.signal?.aborted)
|
|
592
|
+
throw abortError();
|
|
593
|
+
lastError = error;
|
|
594
|
+
if (attempt + 1 < attempts)
|
|
595
|
+
await abortableDelay(250 * (2 ** attempt), options?.signal);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (response.ok)
|
|
599
|
+
return;
|
|
600
|
+
lastError = await responseError(response);
|
|
601
|
+
if (response.status < 500 && response.status !== 429)
|
|
602
|
+
throw lastError;
|
|
603
|
+
if (attempt + 1 < attempts)
|
|
604
|
+
await abortableDelay(250 * (2 ** attempt), options?.signal);
|
|
345
605
|
}
|
|
346
|
-
|
|
606
|
+
throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_UPLOAD_FAILED");
|
|
347
607
|
}
|
|
348
|
-
async
|
|
608
|
+
async deleteAttachmentPart(id) {
|
|
609
|
+
await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, { method: "DELETE" }).catch(() => undefined);
|
|
610
|
+
}
|
|
611
|
+
async downloadAttachmentToFile(descriptor, filePath, options) {
|
|
349
612
|
const target = resolve(filePath);
|
|
350
613
|
await mkdir(dirname(target), { recursive: true });
|
|
351
|
-
|
|
352
|
-
|
|
614
|
+
if (descriptor.version === 1) {
|
|
615
|
+
throwIfAborted(options?.signal);
|
|
616
|
+
await writeFile(target, await this.downloadAttachment(descriptor), { mode: 0o600 });
|
|
617
|
+
options?.onProgress?.({ phase: "DOWNLOADING", bytesTransferred: descriptor.size, totalBytes: descriptor.size, partIndex: 1, partCount: 1 });
|
|
618
|
+
return target;
|
|
619
|
+
}
|
|
620
|
+
const chunks = descriptor.chunks;
|
|
621
|
+
const temporary = `${target}.atalk-${randomUUID()}.part`;
|
|
622
|
+
const file = await open(temporary, "wx", 0o600);
|
|
623
|
+
let transferred = 0;
|
|
624
|
+
try {
|
|
625
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
626
|
+
throwIfAborted(options?.signal);
|
|
627
|
+
const part = chunks[index];
|
|
628
|
+
const ciphertext = await this.downloadAttachmentPart(part.id, options);
|
|
629
|
+
const plaintext = decryptAttachmentChunk(ciphertext, descriptor, index);
|
|
630
|
+
await file.write(plaintext);
|
|
631
|
+
transferred += plaintext.byteLength;
|
|
632
|
+
options?.onProgress?.({
|
|
633
|
+
phase: "DOWNLOADING", bytesTransferred: transferred, totalBytes: descriptor.size,
|
|
634
|
+
partIndex: index + 1, partCount: chunks.length,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
await file.sync();
|
|
638
|
+
await file.close();
|
|
639
|
+
await rename(temporary, target);
|
|
640
|
+
return target;
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
await file.close().catch(() => undefined);
|
|
644
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async downloadAttachmentPart(id, options) {
|
|
649
|
+
const attempts = Math.max(1, Math.min(5, options?.maxAttempts ?? 3));
|
|
650
|
+
let lastError;
|
|
651
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
652
|
+
throwIfAborted(options?.signal);
|
|
653
|
+
let response;
|
|
654
|
+
try {
|
|
655
|
+
response = await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, {
|
|
656
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (options?.signal?.aborted)
|
|
661
|
+
throw abortError();
|
|
662
|
+
lastError = error;
|
|
663
|
+
if (attempt + 1 < attempts)
|
|
664
|
+
await abortableDelay(250 * (2 ** attempt), options?.signal);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (response.ok)
|
|
668
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
669
|
+
lastError = await responseError(response);
|
|
670
|
+
if (response.status < 500 && response.status !== 429)
|
|
671
|
+
throw lastError;
|
|
672
|
+
if (attempt + 1 < attempts)
|
|
673
|
+
await abortableDelay(250 * (2 ** attempt), options?.signal);
|
|
674
|
+
}
|
|
675
|
+
throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_DOWNLOAD_FAILED");
|
|
353
676
|
}
|
|
354
677
|
async mirrorActivity(direction, counterparty, text, conversationId, sourceMessageId, observedAt) {
|
|
355
678
|
if (!this.supervisionEnabled || this.supervisors.length === 0)
|
|
@@ -370,7 +693,7 @@ export class Agent {
|
|
|
370
693
|
});
|
|
371
694
|
for (const supervisor of this.supervisors) {
|
|
372
695
|
const envelope = encryptTextNative({
|
|
373
|
-
messageId:
|
|
696
|
+
messageId: deterministicUuid(`${sourceMessageId}:${supervisor.id}:${direction}:activity`),
|
|
374
697
|
conversationId,
|
|
375
698
|
senderPeerId: credentials.peer.id,
|
|
376
699
|
recipientPeerId: supervisor.id,
|
|
@@ -380,28 +703,241 @@ export class Agent {
|
|
|
380
703
|
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
381
704
|
recipientEncryptionPublicKey: supervisor.encryptionPublicKey,
|
|
382
705
|
});
|
|
383
|
-
this.
|
|
706
|
+
await this.queueEnvelope(envelope);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async queueEnvelope(envelope) {
|
|
710
|
+
await this.mutateRuntimeState((state) => {
|
|
711
|
+
if (!state.outbox.some((item) => item.message_id === envelope.message_id))
|
|
712
|
+
state.outbox.push(envelope);
|
|
713
|
+
});
|
|
714
|
+
if (this.connected)
|
|
715
|
+
await this.drainOutbox();
|
|
716
|
+
}
|
|
717
|
+
async removeFromOutbox(messageId) {
|
|
718
|
+
if (!this.runtimeState.outbox.some((item) => item.message_id === messageId))
|
|
719
|
+
return;
|
|
720
|
+
await this.mutateRuntimeState((state) => {
|
|
721
|
+
state.outbox = state.outbox.filter((item) => item.message_id !== messageId);
|
|
722
|
+
});
|
|
723
|
+
this.sentThisConnection.delete(messageId);
|
|
724
|
+
}
|
|
725
|
+
async drainOutbox() {
|
|
726
|
+
if (this.outboxDrain)
|
|
727
|
+
return this.outboxDrain;
|
|
728
|
+
const drain = (async () => {
|
|
729
|
+
while (this.connected) {
|
|
730
|
+
const envelope = this.runtimeState.outbox.find((item) => !this.sentThisConnection.has(item.message_id));
|
|
731
|
+
if (!envelope)
|
|
732
|
+
return;
|
|
733
|
+
this.sendFrame({ kind: "DELIVER", envelope });
|
|
734
|
+
this.sentThisConnection.add(envelope.message_id);
|
|
735
|
+
}
|
|
736
|
+
})();
|
|
737
|
+
this.outboxDrain = drain;
|
|
738
|
+
try {
|
|
739
|
+
await drain;
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
if (this.outboxDrain === drain)
|
|
743
|
+
this.outboxDrain = undefined;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async rememberIncoming(envelope) {
|
|
747
|
+
if (this.runtimeState.inbox.some((item) => item.message_id === envelope.message_id))
|
|
748
|
+
return;
|
|
749
|
+
await this.mutateRuntimeState((runtime) => {
|
|
750
|
+
if (!runtime.inbox.some((item) => item.message_id === envelope.message_id))
|
|
751
|
+
runtime.inbox.push(envelope);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async completeIncoming(messageId, state) {
|
|
755
|
+
await this.mutateRuntimeState((runtime) => {
|
|
756
|
+
runtime.processedIncoming[messageId] = runtime.processedIncoming[messageId] === "READ" ? "READ" : state;
|
|
757
|
+
const ids = Object.keys(runtime.processedIncoming);
|
|
758
|
+
for (let index = 0; index < ids.length - MAX_PROCESSED_INCOMING; index += 1) {
|
|
759
|
+
delete runtime.processedIncoming[ids[index]];
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
async forgetIncoming(messageId) {
|
|
764
|
+
if (!this.runtimeState.inbox.some((item) => item.message_id === messageId))
|
|
765
|
+
return;
|
|
766
|
+
await this.mutateRuntimeState((runtime) => {
|
|
767
|
+
runtime.inbox = runtime.inbox.filter((item) => item.message_id !== messageId);
|
|
768
|
+
});
|
|
769
|
+
if (this.runtimeState.inbox.length === 0 && this.inboxRetryTimer) {
|
|
770
|
+
clearTimeout(this.inboxRetryTimer);
|
|
771
|
+
this.inboxRetryTimer = undefined;
|
|
772
|
+
this.inboxRetryAttempt = 0;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async drainInbox() {
|
|
776
|
+
if (this.inboxDrain)
|
|
777
|
+
return this.inboxDrain;
|
|
778
|
+
const drain = (async () => {
|
|
779
|
+
if (!this.connected) {
|
|
780
|
+
this.scheduleInboxRetry();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
for (const envelope of [...this.runtimeState.inbox]) {
|
|
784
|
+
try {
|
|
785
|
+
await this.handleFrame({ kind: "MESSAGE", envelope });
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
this.emitError(error);
|
|
789
|
+
this.scheduleInboxRetry();
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (this.runtimeState.inbox.length === 0)
|
|
794
|
+
this.inboxRetryAttempt = 0;
|
|
795
|
+
})();
|
|
796
|
+
this.inboxDrain = drain;
|
|
797
|
+
try {
|
|
798
|
+
await drain;
|
|
799
|
+
}
|
|
800
|
+
finally {
|
|
801
|
+
if (this.inboxDrain === drain)
|
|
802
|
+
this.inboxDrain = undefined;
|
|
384
803
|
}
|
|
385
804
|
}
|
|
805
|
+
scheduleInboxRetry() {
|
|
806
|
+
if (this.stopped || this.runtimeState.inbox.length === 0 || this.inboxRetryTimer)
|
|
807
|
+
return;
|
|
808
|
+
const delay = reconnectDelay(this.inboxRetryAttempt++);
|
|
809
|
+
this.inboxRetryTimer = setTimeout(() => {
|
|
810
|
+
this.inboxRetryTimer = undefined;
|
|
811
|
+
void this.drainInbox();
|
|
812
|
+
}, delay);
|
|
813
|
+
}
|
|
814
|
+
async mutateRuntimeState(mutator) {
|
|
815
|
+
const operation = this.stateMutation.then(async () => {
|
|
816
|
+
const next = structuredClone(this.runtimeState);
|
|
817
|
+
mutator(next);
|
|
818
|
+
await this.runtimeStateStore.save(next);
|
|
819
|
+
this.runtimeState = next;
|
|
820
|
+
});
|
|
821
|
+
this.stateMutation = operation.catch(() => undefined);
|
|
822
|
+
return operation;
|
|
823
|
+
}
|
|
386
824
|
sendFrame(frame) {
|
|
387
825
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
388
826
|
throw new Error("Agent is not connected");
|
|
389
827
|
this.socket.send(JSON.stringify(frame));
|
|
390
828
|
}
|
|
391
829
|
async request(path, init = {}, authenticated = true) {
|
|
392
|
-
const
|
|
393
|
-
const response =
|
|
830
|
+
const headers = { "content-type": "application/json", ...init.headers };
|
|
831
|
+
const response = authenticated
|
|
832
|
+
? await this.authorizedFetch(`${this.baseUrl}${path}`, { ...init, headers })
|
|
833
|
+
: await fetch(`${this.baseUrl}${path}`, { ...init, headers });
|
|
834
|
+
const body = await response.json();
|
|
835
|
+
if (!response.ok)
|
|
836
|
+
throw new AgentProtocolError(body.error?.code ?? `HTTP_${response.status}`, body.error?.message ?? `HTTP ${response.status}`);
|
|
837
|
+
return body;
|
|
838
|
+
}
|
|
839
|
+
async authorizedFetch(url, init = {}, retry = true) {
|
|
840
|
+
await this.refreshCredentialsIfNeeded("EXPIRING");
|
|
841
|
+
const response = await fetch(url, {
|
|
394
842
|
...init,
|
|
395
843
|
headers: {
|
|
396
|
-
|
|
397
|
-
...(authenticated && credentials ? { authorization: `Bearer ${credentials.sessionToken}` } : {}),
|
|
844
|
+
authorization: `Bearer ${accessToken(this.requireCredentials())}`,
|
|
398
845
|
...init.headers,
|
|
399
846
|
},
|
|
400
847
|
});
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return
|
|
848
|
+
if (response.status === 401 && retry && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
|
|
849
|
+
return this.authorizedFetch(url, init, false);
|
|
850
|
+
}
|
|
851
|
+
return response;
|
|
852
|
+
}
|
|
853
|
+
async refreshCredentialsIfNeeded(reason, force = false) {
|
|
854
|
+
if (!this.credentialRefresher || !this.credentials)
|
|
855
|
+
return false;
|
|
856
|
+
if (reason === "EXPIRING" && !force) {
|
|
857
|
+
const expiresAt = this.credentials.accessTokenExpiresAt
|
|
858
|
+
? Date.parse(this.credentials.accessTokenExpiresAt)
|
|
859
|
+
: Number.POSITIVE_INFINITY;
|
|
860
|
+
if (!Number.isFinite(expiresAt) || expiresAt > Date.now() + this.refreshLeewayMs)
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
if (this.refreshPromise)
|
|
864
|
+
return this.refreshPromise;
|
|
865
|
+
const refresh = (async () => {
|
|
866
|
+
let current = this.requireCredentials();
|
|
867
|
+
if (this.usesDefaultCredentialRefresher && current.refreshToken && !current.refreshRequestId) {
|
|
868
|
+
current = { ...current, refreshRequestId: randomUUID() };
|
|
869
|
+
// Persist intent before sending. If the successful response is lost, a
|
|
870
|
+
// restart repeats the same server-side operation instead of reusing the
|
|
871
|
+
// rotated token under a new idempotency key.
|
|
872
|
+
await this.credentialStore.save(current);
|
|
873
|
+
this.credentials = current;
|
|
874
|
+
}
|
|
875
|
+
let refreshed;
|
|
876
|
+
try {
|
|
877
|
+
refreshed = await this.credentialRefresher({ credentials: current, reason, baseUrl: this.baseUrl });
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
if (this.usesDefaultCredentialRefresher && current.refreshRequestId && isSessionError(error)) {
|
|
881
|
+
const { refreshRequestId: _failedRefreshRequest, ...restored } = current;
|
|
882
|
+
await this.credentialStore.save(restored);
|
|
883
|
+
this.credentials = restored;
|
|
884
|
+
}
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
887
|
+
if (!refreshed)
|
|
888
|
+
return false;
|
|
889
|
+
const { accessTokenExpiresAt: _previousExpiry, refreshRequestId: _completedRefreshRequest, ...currentWithoutExpiry } = current;
|
|
890
|
+
const next = {
|
|
891
|
+
...currentWithoutExpiry,
|
|
892
|
+
sessionToken: refreshed.accessToken,
|
|
893
|
+
accessToken: refreshed.accessToken,
|
|
894
|
+
...(refreshed.refreshToken ?? current.refreshToken
|
|
895
|
+
? { refreshToken: refreshed.refreshToken ?? current.refreshToken }
|
|
896
|
+
: {}),
|
|
897
|
+
...(refreshed.accessTokenExpiresAt ? { accessTokenExpiresAt: refreshed.accessTokenExpiresAt } : {}),
|
|
898
|
+
};
|
|
899
|
+
await this.credentialStore.save(next);
|
|
900
|
+
this.credentials = next;
|
|
901
|
+
return true;
|
|
902
|
+
})();
|
|
903
|
+
this.refreshPromise = refresh;
|
|
904
|
+
try {
|
|
905
|
+
return await refresh;
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
if (this.refreshPromise === refresh)
|
|
909
|
+
this.refreshPromise = undefined;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async connectWithRefresh() {
|
|
913
|
+
try {
|
|
914
|
+
await this.connect();
|
|
915
|
+
}
|
|
916
|
+
catch (error) {
|
|
917
|
+
if (isSessionError(error) && await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
|
|
918
|
+
await this.connect();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
throw error;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async recoverRejectedSession() {
|
|
925
|
+
try {
|
|
926
|
+
if (!await this.refreshCredentialsIfNeeded("UNAUTHORIZED", true)) {
|
|
927
|
+
this.stopped = true;
|
|
928
|
+
this.emitError(new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected"));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (!this.stopped)
|
|
932
|
+
await this.connectWithRefresh();
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (isSessionError(error))
|
|
936
|
+
this.stopped = true;
|
|
937
|
+
this.emitError(error);
|
|
938
|
+
if (!this.stopped)
|
|
939
|
+
this.scheduleReconnect();
|
|
940
|
+
}
|
|
405
941
|
}
|
|
406
942
|
requireCredentials() {
|
|
407
943
|
if (!this.credentials)
|
|
@@ -419,6 +955,80 @@ export class Agent {
|
|
|
419
955
|
function exactArrayBuffer(bytes) {
|
|
420
956
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
421
957
|
}
|
|
958
|
+
function abortError() {
|
|
959
|
+
const error = new Error("Attachment transfer was cancelled");
|
|
960
|
+
error.name = "AbortError";
|
|
961
|
+
return error;
|
|
962
|
+
}
|
|
963
|
+
function throwIfAborted(signal) {
|
|
964
|
+
if (signal?.aborted)
|
|
965
|
+
throw abortError();
|
|
966
|
+
}
|
|
967
|
+
async function abortableDelay(milliseconds, signal) {
|
|
968
|
+
throwIfAborted(signal);
|
|
969
|
+
await new Promise((resolveDelay, rejectDelay) => {
|
|
970
|
+
const timer = setTimeout(resolveDelay, milliseconds);
|
|
971
|
+
signal?.addEventListener("abort", () => {
|
|
972
|
+
clearTimeout(timer);
|
|
973
|
+
rejectDelay(abortError());
|
|
974
|
+
}, { once: true });
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
class AgentProtocolError extends Error {
|
|
978
|
+
code;
|
|
979
|
+
constructor(code, message) {
|
|
980
|
+
super(`${code}: ${message}`);
|
|
981
|
+
this.code = code;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
function accessToken(credentials) {
|
|
985
|
+
return credentials.accessToken ?? credentials.sessionToken;
|
|
986
|
+
}
|
|
987
|
+
async function refreshAtalkCredentials(context) {
|
|
988
|
+
const refreshToken = context.credentials.refreshToken;
|
|
989
|
+
if (!refreshToken)
|
|
990
|
+
return undefined;
|
|
991
|
+
const response = await fetch(`${context.baseUrl}/v1/agent-runtime/session/refresh`, {
|
|
992
|
+
method: "POST",
|
|
993
|
+
headers: { "content-type": "application/json" },
|
|
994
|
+
body: JSON.stringify({
|
|
995
|
+
refreshToken,
|
|
996
|
+
// Deterministic per credential, so a process restart retries the same
|
|
997
|
+
// idempotency operation if the first response was lost.
|
|
998
|
+
requestId: context.credentials.refreshRequestId
|
|
999
|
+
?? deterministicUuid(`atalk-agent-refresh:${refreshToken}`),
|
|
1000
|
+
}),
|
|
1001
|
+
});
|
|
1002
|
+
if (!response.ok)
|
|
1003
|
+
throw await responseError(response);
|
|
1004
|
+
const body = await response.json();
|
|
1005
|
+
const nextAccessToken = body.accessToken ?? body.token;
|
|
1006
|
+
if (!nextAccessToken)
|
|
1007
|
+
throw new Error("INVALID_REFRESH_RESPONSE: aTalk did not return an access token");
|
|
1008
|
+
return {
|
|
1009
|
+
accessToken: nextAccessToken,
|
|
1010
|
+
...(body.refreshToken ? { refreshToken: body.refreshToken } : {}),
|
|
1011
|
+
...(body.accessTokenExpiresAt ?? body.expiresAt
|
|
1012
|
+
? { accessTokenExpiresAt: body.accessTokenExpiresAt ?? body.expiresAt }
|
|
1013
|
+
: {}),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
function isSessionError(error) {
|
|
1017
|
+
return error instanceof AgentProtocolError && FATAL_SESSION_CODES.has(error.code);
|
|
1018
|
+
}
|
|
1019
|
+
function sameIdentityKeys(left, right) {
|
|
1020
|
+
return left.signingPublicKey === right.signingPublicKey
|
|
1021
|
+
&& left.signingSecretKey === right.signingSecretKey
|
|
1022
|
+
&& left.encryptionPublicKey === right.encryptionPublicKey
|
|
1023
|
+
&& left.encryptionSecretKey === right.encryptionSecretKey;
|
|
1024
|
+
}
|
|
1025
|
+
function deterministicUuid(value) {
|
|
1026
|
+
const bytes = createHash("sha256").update(value).digest().subarray(0, 16);
|
|
1027
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
1028
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
1029
|
+
const hex = bytes.toString("hex");
|
|
1030
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1031
|
+
}
|
|
422
1032
|
async function attachmentInputFromFile(input) {
|
|
423
1033
|
const path = resolve(input.path);
|
|
424
1034
|
return {
|
|
@@ -442,12 +1052,12 @@ async function responseError(response) {
|
|
|
442
1052
|
try {
|
|
443
1053
|
const body = await response.json();
|
|
444
1054
|
if (body.error)
|
|
445
|
-
return new
|
|
1055
|
+
return new AgentProtocolError(body.error.code ?? `HTTP_${response.status}`, body.error.message ?? "request failed");
|
|
446
1056
|
}
|
|
447
1057
|
catch {
|
|
448
1058
|
// Keep the HTTP fallback for non-JSON proxy responses.
|
|
449
1059
|
}
|
|
450
|
-
return new
|
|
1060
|
+
return new AgentProtocolError(`HTTP_${response.status}`, `HTTP ${response.status}`);
|
|
451
1061
|
}
|
|
452
1062
|
function reconnectDelay(attempt) {
|
|
453
1063
|
const exponential = Math.min(30_000, 500 * (2 ** Math.min(attempt, 6)));
|