@atalk/sdk 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +101 -5
- package/dist/agent.d.ts +126 -4
- package/dist/agent.js +932 -42
- package/dist/agent.js.map +1 -1
- package/dist/credential-store.d.ts +19 -1
- package/dist/credential-store.js +15 -4
- 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 +51 -0
- package/dist/runtime-state-store.js +83 -0
- package/dist/runtime-state-store.js.map +1 -0
- package/dist/workrooms.d.ts +317 -0
- package/dist/workrooms.js +1093 -0
- package/dist/workrooms.js.map +1 -0
- package/package.json +3 -3
package/dist/agent.js
CHANGED
|
@@ -1,21 +1,79 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
4
|
+
import { decodeAttachmentMessage, createChunkedAttachmentDescriptor, decryptAttachmentChunk, decodeDirectedMessage, decryptAttachment, attachmentPartDescriptors, encodeAgentActivity, encodeAttachmentMessage, encryptAttachment, encryptAttachmentChunk, joinEncryptedAttachmentParts, serverFrameSchema, splitEncryptedAttachment, } from "@atalk/protocol";
|
|
3
5
|
import WebSocket from "ws";
|
|
4
|
-
import { FileCredentialStore } from "./credential-store.js";
|
|
6
|
+
import { FileCredentialStore, } from "./credential-store.js";
|
|
5
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
|
+
]);
|
|
6
18
|
export class Agent {
|
|
19
|
+
/** Durable, E2EE task/workroom API for this agent identity. */
|
|
20
|
+
workrooms;
|
|
7
21
|
baseUrl;
|
|
8
22
|
activationToken;
|
|
9
23
|
credentialStore;
|
|
24
|
+
runtimeStateStore;
|
|
25
|
+
credentialRefresher;
|
|
26
|
+
usesDefaultCredentialRefresher;
|
|
27
|
+
refreshLeewayMs;
|
|
28
|
+
supervisionEnabled;
|
|
10
29
|
credentials;
|
|
30
|
+
runtimeState = emptyRuntimeState();
|
|
31
|
+
stateMutation = Promise.resolve();
|
|
32
|
+
refreshPromise;
|
|
33
|
+
outboxDrain;
|
|
34
|
+
inboxDrain;
|
|
35
|
+
inboxRetryTimer;
|
|
36
|
+
inboxRetryAttempt = 0;
|
|
37
|
+
sentThisConnection = new Set();
|
|
11
38
|
socket;
|
|
39
|
+
ready = false;
|
|
40
|
+
reconnectAttempt = 0;
|
|
12
41
|
stopped = false;
|
|
13
42
|
messageHandler;
|
|
14
43
|
errorHandler;
|
|
44
|
+
supervisors = [];
|
|
45
|
+
counterparties = new Map();
|
|
46
|
+
processingIncoming = new Map();
|
|
15
47
|
constructor(options) {
|
|
16
48
|
this.activationToken = options.token;
|
|
17
49
|
this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:4001").replace(/\/$/u, "");
|
|
18
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);
|
|
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
|
+
});
|
|
71
|
+
}
|
|
72
|
+
get connected() {
|
|
73
|
+
return this.ready && this.socket?.readyState === WebSocket.OPEN;
|
|
74
|
+
}
|
|
75
|
+
get peer() {
|
|
76
|
+
return this.credentials?.peer;
|
|
19
77
|
}
|
|
20
78
|
on(event, handler) {
|
|
21
79
|
if (event === "message")
|
|
@@ -26,33 +84,143 @@ export class Agent {
|
|
|
26
84
|
}
|
|
27
85
|
async start() {
|
|
28
86
|
this.stopped = false;
|
|
29
|
-
this.
|
|
30
|
-
await this.
|
|
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();
|
|
107
|
+
}
|
|
31
108
|
}
|
|
32
109
|
async stop() {
|
|
33
110
|
this.stopped = true;
|
|
111
|
+
this.ready = false;
|
|
112
|
+
this.reconnectAttempt = 0;
|
|
113
|
+
if (this.inboxRetryTimer)
|
|
114
|
+
clearTimeout(this.inboxRetryTimer);
|
|
115
|
+
this.inboxRetryTimer = undefined;
|
|
34
116
|
this.socket?.close(1000, "Agent stopped");
|
|
117
|
+
await this.stateMutation;
|
|
35
118
|
}
|
|
36
119
|
async send(recipientHandle, text) {
|
|
37
|
-
return this.
|
|
120
|
+
return (await this.sendWithDetails(recipientHandle, text)).conversationId;
|
|
121
|
+
}
|
|
122
|
+
/** Start a conversation and return both transport identifiers. */
|
|
123
|
+
async sendWithDetails(recipientHandle, text) {
|
|
124
|
+
const conversationId = randomUUID();
|
|
125
|
+
const messageId = await this.sendEnvelope(recipientHandle, text, conversationId);
|
|
126
|
+
return { conversationId, messageId };
|
|
127
|
+
}
|
|
128
|
+
/** Send inside a known conversation and return the new message id. */
|
|
129
|
+
async sendInConversation(recipientHandle, text, conversationId) {
|
|
130
|
+
return this.sendEnvelope(recipientHandle, text, conversationId);
|
|
131
|
+
}
|
|
132
|
+
async sendAttachment(recipientHandle, input) {
|
|
133
|
+
return (await this.sendAttachmentWithDetails(recipientHandle, input)).conversationId;
|
|
134
|
+
}
|
|
135
|
+
async sendAttachmentWithDetails(recipientHandle, input) {
|
|
136
|
+
const conversationId = randomUUID();
|
|
137
|
+
const messageId = await this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
|
|
138
|
+
return { conversationId, messageId };
|
|
139
|
+
}
|
|
140
|
+
async sendAttachmentFile(recipientHandle, input) {
|
|
141
|
+
return (await this.sendAttachmentFileWithDetails(recipientHandle, input)).conversationId;
|
|
142
|
+
}
|
|
143
|
+
async sendAttachmentFileWithDetails(recipientHandle, input) {
|
|
144
|
+
const conversationId = randomUUID();
|
|
145
|
+
const messageId = await this.sendAttachmentFileEnvelope(recipientHandle, input, conversationId);
|
|
146
|
+
return { conversationId, messageId };
|
|
38
147
|
}
|
|
39
|
-
async
|
|
40
|
-
|
|
148
|
+
async sendAttachmentInConversation(recipientHandle, input, conversationId) {
|
|
149
|
+
return this.sendAttachmentEnvelope(recipientHandle, input, conversationId);
|
|
150
|
+
}
|
|
151
|
+
async sendAttachmentFileInConversation(recipientHandle, input, conversationId) {
|
|
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);
|
|
164
|
+
}
|
|
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) {
|
|
176
|
+
if (!this.activationToken) {
|
|
177
|
+
throw new Error("ACTIVATION_REQUIRED: Provide a one-time token because no persisted credentials were found");
|
|
178
|
+
}
|
|
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
|
+
}
|
|
41
189
|
const response = await this.request("/v1/agents/activate", {
|
|
42
190
|
method: "POST",
|
|
43
191
|
body: JSON.stringify({
|
|
44
192
|
activationToken: this.activationToken,
|
|
45
|
-
|
|
46
|
-
|
|
193
|
+
activationRequestId: pending.requestId,
|
|
194
|
+
signingPublicKey: pending.keys.signingPublicKey,
|
|
195
|
+
encryptionPublicKey: pending.keys.encryptionPublicKey,
|
|
47
196
|
}),
|
|
48
197
|
}, false);
|
|
49
|
-
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
|
+
};
|
|
50
208
|
await this.credentialStore.save(credentials);
|
|
209
|
+
await this.mutateRuntimeState((state) => { delete state.pendingActivation; });
|
|
51
210
|
return credentials;
|
|
52
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
|
+
}
|
|
53
220
|
async connect() {
|
|
54
221
|
const credentials = this.requireCredentials();
|
|
55
222
|
const websocketUrl = `${this.baseUrl.replace(/^http/u, "ws")}/v1/ws`;
|
|
223
|
+
this.ready = false;
|
|
56
224
|
await new Promise((resolve, reject) => {
|
|
57
225
|
const socket = new WebSocket(websocketUrl);
|
|
58
226
|
this.socket = socket;
|
|
@@ -63,15 +231,31 @@ export class Agent {
|
|
|
63
231
|
reject(new Error("aTalk connection timed out"));
|
|
64
232
|
}
|
|
65
233
|
}, 10_000);
|
|
66
|
-
socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: credentials
|
|
234
|
+
socket.on("open", () => socket.send(JSON.stringify({ kind: "AUTH", token: accessToken(credentials) })));
|
|
67
235
|
socket.on("message", (raw) => {
|
|
68
|
-
|
|
69
|
-
|
|
236
|
+
const frame = serverFrameSchema.parse(JSON.parse(raw.toString()));
|
|
237
|
+
void this.handleFrame(frame).then(() => {
|
|
238
|
+
if (frame.kind === "READY" && !ready && this.socket === socket) {
|
|
70
239
|
ready = true;
|
|
240
|
+
this.ready = true;
|
|
241
|
+
this.reconnectAttempt = 0;
|
|
242
|
+
this.sentThisConnection.clear();
|
|
71
243
|
clearTimeout(timeout);
|
|
72
244
|
resolve();
|
|
245
|
+
void this.drainOutbox().catch((error) => this.emitError(error));
|
|
246
|
+
void this.drainInbox();
|
|
73
247
|
}
|
|
74
|
-
}).catch((error) =>
|
|
248
|
+
}).catch((error) => {
|
|
249
|
+
if (!ready) {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
reject(error);
|
|
252
|
+
socket.close();
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
this.emitError(error);
|
|
256
|
+
this.scheduleInboxRetry();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
75
259
|
});
|
|
76
260
|
socket.on("error", (error) => {
|
|
77
261
|
clearTimeout(timeout);
|
|
@@ -80,48 +264,162 @@ export class Agent {
|
|
|
80
264
|
else
|
|
81
265
|
this.emitError(error);
|
|
82
266
|
});
|
|
83
|
-
socket.on("close", () => {
|
|
267
|
+
socket.on("close", (code) => {
|
|
84
268
|
clearTimeout(timeout);
|
|
269
|
+
if (this.socket === socket)
|
|
270
|
+
this.ready = false;
|
|
271
|
+
if (!ready) {
|
|
272
|
+
const error = code === 4001 || code === 1008
|
|
273
|
+
? new AgentProtocolError("INVALID_SESSION", "Agent credentials were rejected")
|
|
274
|
+
: new Error("aTalk connection closed before authentication");
|
|
275
|
+
reject(error);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (code === 4001 || code === 1008) {
|
|
279
|
+
void this.recoverRejectedSession();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
85
282
|
if (!this.stopped && ready)
|
|
86
|
-
|
|
283
|
+
this.scheduleReconnect();
|
|
87
284
|
});
|
|
88
285
|
});
|
|
89
286
|
}
|
|
287
|
+
scheduleReconnect() {
|
|
288
|
+
if (this.stopped)
|
|
289
|
+
return;
|
|
290
|
+
const delay = reconnectDelay(this.reconnectAttempt++);
|
|
291
|
+
setTimeout(() => {
|
|
292
|
+
if (this.stopped)
|
|
293
|
+
return;
|
|
294
|
+
void this.connectWithRefresh().catch((error) => {
|
|
295
|
+
if (isSessionError(error))
|
|
296
|
+
this.stopped = true;
|
|
297
|
+
this.emitError(error);
|
|
298
|
+
if (!this.stopped)
|
|
299
|
+
this.scheduleReconnect();
|
|
300
|
+
});
|
|
301
|
+
}, delay);
|
|
302
|
+
}
|
|
90
303
|
async handleFrame(frame) {
|
|
91
|
-
if (frame.kind === "ERROR")
|
|
92
|
-
throw new
|
|
304
|
+
if (frame.kind === "ERROR") {
|
|
305
|
+
throw new AgentProtocolError(frame.code, frame.message);
|
|
306
|
+
}
|
|
93
307
|
if (frame.kind === "RECEIPT") {
|
|
308
|
+
await this.removeFromOutbox(frame.messageId);
|
|
94
309
|
this.sendFrame({ kind: "RECEIPT_ACK", messageId: frame.messageId, state: frame.state });
|
|
95
310
|
return;
|
|
96
311
|
}
|
|
312
|
+
if (frame.kind === "ACK_RECEIVED") {
|
|
313
|
+
await this.forgetIncoming(frame.messageId);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
97
316
|
if (frame.kind !== "MESSAGE")
|
|
98
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) {
|
|
99
341
|
const credentials = this.requireCredentials();
|
|
100
|
-
const sender = await this.request(`/v1/
|
|
342
|
+
const sender = await this.request(`/v1/messages/${frame.envelope.message_id}/sender-keys`);
|
|
101
343
|
const text = decryptTextNative({
|
|
102
344
|
envelope: frame.envelope,
|
|
103
345
|
senderSigningPublicKey: sender.signingPublicKey,
|
|
104
346
|
senderEncryptionPublicKey: sender.encryptionPublicKey,
|
|
105
347
|
recipientEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
106
348
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
349
|
+
const directedMessage = decodeDirectedMessage(text);
|
|
350
|
+
const content = directedMessage?.content ?? text;
|
|
351
|
+
const attachmentMessage = decodeAttachmentMessage(content);
|
|
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 };
|
|
358
|
+
if (!isSupervisor) {
|
|
359
|
+
this.counterparties.set(frame.envelope.conversation_id, sender);
|
|
360
|
+
await this.mutateRuntimeState((state) => {
|
|
361
|
+
state.counterparties[frame.envelope.conversation_id] = sender;
|
|
118
362
|
});
|
|
363
|
+
await this.mirrorActivity("INCOMING", sender, text, frame.envelope.conversation_id, frame.envelope.message_id, frame.envelope.timestamp);
|
|
119
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();
|
|
120
420
|
}
|
|
121
421
|
async sendEnvelope(recipientHandle, text, conversationId) {
|
|
122
422
|
const credentials = this.requireCredentials();
|
|
123
|
-
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
124
|
-
throw new Error("Agent is not connected");
|
|
125
423
|
const { recipient } = await this.request("/v1/messages/authorize", {
|
|
126
424
|
method: "POST",
|
|
127
425
|
body: JSON.stringify({ recipientHandle }),
|
|
@@ -137,28 +435,509 @@ export class Agent {
|
|
|
137
435
|
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
138
436
|
recipientEncryptionPublicKey: recipient.encryptionPublicKey,
|
|
139
437
|
});
|
|
140
|
-
this.
|
|
438
|
+
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
|
|
439
|
+
if (!isSupervisor) {
|
|
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) {
|
|
447
|
+
await this.mirrorActivity("OUTGOING", recipient, text, conversationId, envelope.message_id, envelope.timestamp);
|
|
448
|
+
}
|
|
449
|
+
return envelope.message_id;
|
|
450
|
+
}
|
|
451
|
+
async sendAttachmentEnvelope(recipientHandle, input, conversationId) {
|
|
452
|
+
const credentials = this.requireCredentials();
|
|
453
|
+
const { recipient } = await this.request("/v1/messages/authorize", {
|
|
454
|
+
method: "POST",
|
|
455
|
+
body: JSON.stringify({ recipientHandle }),
|
|
456
|
+
});
|
|
457
|
+
const encrypted = splitEncryptedAttachment(encryptAttachment({
|
|
458
|
+
id: randomUUID(),
|
|
459
|
+
bytes: input.data,
|
|
460
|
+
name: input.name,
|
|
461
|
+
mimeType: input.mimeType ?? "application/octet-stream",
|
|
462
|
+
}), randomUUID);
|
|
463
|
+
for (const part of encrypted.parts) {
|
|
464
|
+
await this.uploadAttachment({ recipientPeerId: recipient.id }, part.id, part.ciphertext);
|
|
465
|
+
}
|
|
466
|
+
const caption = input.caption?.trim();
|
|
467
|
+
const plaintext = encodeAttachmentMessage({
|
|
468
|
+
attachment: encrypted.descriptor,
|
|
469
|
+
...(caption ? { caption } : {}),
|
|
470
|
+
});
|
|
471
|
+
const envelope = encryptTextNative({
|
|
472
|
+
messageId: randomUUID(),
|
|
473
|
+
conversationId,
|
|
474
|
+
senderPeerId: credentials.peer.id,
|
|
475
|
+
recipientPeerId: recipient.id,
|
|
476
|
+
timestamp: new Date().toISOString(),
|
|
477
|
+
plaintext,
|
|
478
|
+
senderSigningSecretKey: credentials.keys.signingSecretKey,
|
|
479
|
+
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
480
|
+
recipientEncryptionPublicKey: recipient.encryptionPublicKey,
|
|
481
|
+
});
|
|
482
|
+
const isSupervisor = this.supervisors.some((supervisor) => supervisor.id === recipient.id);
|
|
483
|
+
if (!isSupervisor) {
|
|
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) {
|
|
491
|
+
await this.mirrorActivity("OUTGOING", recipient, plaintext, conversationId, envelope.message_id, envelope.timestamp);
|
|
492
|
+
}
|
|
493
|
+
return envelope.message_id;
|
|
494
|
+
}
|
|
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", {
|
|
502
|
+
method: "POST",
|
|
503
|
+
body: JSON.stringify({ recipientHandle }),
|
|
504
|
+
});
|
|
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
|
+
}
|
|
141
566
|
return envelope.message_id;
|
|
142
567
|
}
|
|
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);
|
|
605
|
+
}
|
|
606
|
+
throw lastError instanceof Error ? lastError : new Error("ATTACHMENT_UPLOAD_FAILED");
|
|
607
|
+
}
|
|
608
|
+
async deleteAttachmentPart(id) {
|
|
609
|
+
await this.authorizedFetch(`${this.baseUrl}/v1/attachments/${id}`, { method: "DELETE" }).catch(() => undefined);
|
|
610
|
+
}
|
|
611
|
+
async downloadAttachmentToFile(descriptor, filePath, options) {
|
|
612
|
+
const target = resolve(filePath);
|
|
613
|
+
await mkdir(dirname(target), { recursive: true });
|
|
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");
|
|
676
|
+
}
|
|
677
|
+
async mirrorActivity(direction, counterparty, text, conversationId, sourceMessageId, observedAt) {
|
|
678
|
+
if (!this.supervisionEnabled || this.supervisors.length === 0)
|
|
679
|
+
return;
|
|
680
|
+
const credentials = this.requireCredentials();
|
|
681
|
+
const plaintext = encodeAgentActivity({
|
|
682
|
+
version: 1,
|
|
683
|
+
kind: "AGENT_ACTIVITY",
|
|
684
|
+
agentPeerId: credentials.peer.id,
|
|
685
|
+
agentHandle: credentials.peer.handle,
|
|
686
|
+
counterpartyPeerId: counterparty.id,
|
|
687
|
+
counterpartyHandle: counterparty.handle,
|
|
688
|
+
counterpartyDisplayName: counterparty.displayName,
|
|
689
|
+
direction,
|
|
690
|
+
sourceMessageId,
|
|
691
|
+
observedAt,
|
|
692
|
+
text,
|
|
693
|
+
});
|
|
694
|
+
for (const supervisor of this.supervisors) {
|
|
695
|
+
const envelope = encryptTextNative({
|
|
696
|
+
messageId: deterministicUuid(`${sourceMessageId}:${supervisor.id}:${direction}:activity`),
|
|
697
|
+
conversationId,
|
|
698
|
+
senderPeerId: credentials.peer.id,
|
|
699
|
+
recipientPeerId: supervisor.id,
|
|
700
|
+
timestamp: new Date().toISOString(),
|
|
701
|
+
plaintext,
|
|
702
|
+
senderSigningSecretKey: credentials.keys.signingSecretKey,
|
|
703
|
+
senderEncryptionSecretKey: credentials.keys.encryptionSecretKey,
|
|
704
|
+
recipientEncryptionPublicKey: supervisor.encryptionPublicKey,
|
|
705
|
+
});
|
|
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;
|
|
803
|
+
}
|
|
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
|
+
}
|
|
143
824
|
sendFrame(frame) {
|
|
144
825
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
145
826
|
throw new Error("Agent is not connected");
|
|
146
827
|
this.socket.send(JSON.stringify(frame));
|
|
147
828
|
}
|
|
148
829
|
async request(path, init = {}, authenticated = true) {
|
|
149
|
-
const
|
|
150
|
-
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, {
|
|
151
842
|
...init,
|
|
152
843
|
headers: {
|
|
153
|
-
|
|
154
|
-
...(authenticated && credentials ? { authorization: `Bearer ${credentials.sessionToken}` } : {}),
|
|
844
|
+
authorization: `Bearer ${accessToken(this.requireCredentials())}`,
|
|
155
845
|
...init.headers,
|
|
156
846
|
},
|
|
157
847
|
});
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
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
|
+
}
|
|
162
941
|
}
|
|
163
942
|
requireCredentials() {
|
|
164
943
|
if (!this.credentials)
|
|
@@ -173,4 +952,115 @@ export class Agent {
|
|
|
173
952
|
queueMicrotask(() => { throw normalized; });
|
|
174
953
|
}
|
|
175
954
|
}
|
|
955
|
+
function exactArrayBuffer(bytes) {
|
|
956
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
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
|
+
}
|
|
1032
|
+
async function attachmentInputFromFile(input) {
|
|
1033
|
+
const path = resolve(input.path);
|
|
1034
|
+
return {
|
|
1035
|
+
data: new Uint8Array(await readFile(path)),
|
|
1036
|
+
name: input.name?.trim() || basename(path),
|
|
1037
|
+
mimeType: input.mimeType?.trim() || mimeTypeFromPath(path),
|
|
1038
|
+
...(input.caption?.trim() ? { caption: input.caption.trim() } : {}),
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
function mimeTypeFromPath(path) {
|
|
1042
|
+
const mimeTypes = {
|
|
1043
|
+
".aac": "audio/aac", ".csv": "text/csv", ".gif": "image/gif", ".heic": "image/heic",
|
|
1044
|
+
".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".json": "application/json", ".m4a": "audio/mp4",
|
|
1045
|
+
".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg",
|
|
1046
|
+
".pdf": "application/pdf", ".png": "image/png", ".txt": "text/plain", ".wav": "audio/wav",
|
|
1047
|
+
".webm": "video/webm", ".webp": "image/webp", ".zip": "application/zip",
|
|
1048
|
+
};
|
|
1049
|
+
return mimeTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
1050
|
+
}
|
|
1051
|
+
async function responseError(response) {
|
|
1052
|
+
try {
|
|
1053
|
+
const body = await response.json();
|
|
1054
|
+
if (body.error)
|
|
1055
|
+
return new AgentProtocolError(body.error.code ?? `HTTP_${response.status}`, body.error.message ?? "request failed");
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
// Keep the HTTP fallback for non-JSON proxy responses.
|
|
1059
|
+
}
|
|
1060
|
+
return new AgentProtocolError(`HTTP_${response.status}`, `HTTP ${response.status}`);
|
|
1061
|
+
}
|
|
1062
|
+
function reconnectDelay(attempt) {
|
|
1063
|
+
const exponential = Math.min(30_000, 500 * (2 ** Math.min(attempt, 6)));
|
|
1064
|
+
return exponential + Math.floor(Math.random() * Math.max(1, Math.floor(exponential * 0.2)));
|
|
1065
|
+
}
|
|
176
1066
|
//# sourceMappingURL=agent.js.map
|