message-cp 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js ADDED
@@ -0,0 +1,507 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { setting } from "./settings.js";
3
+ export class ToolError extends Error {
4
+ }
5
+ export const NOT_REGISTERED = "This session is not registered. Call `register` first.";
6
+ export const NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
7
+ const MAX_BIO_LENGTH = 500;
8
+ function toAgent(row) {
9
+ return {
10
+ name: row.name,
11
+ bio: row.bio,
12
+ registeredAt: new Date(row.registered_at).toISOString(),
13
+ lastSeen: new Date(row.last_seen).toISOString(),
14
+ person: row.person === 1,
15
+ };
16
+ }
17
+ /** The refusal for `register` and `unregister` on a person's name. */
18
+ const personRefusal = (name) => `The name '${name}' belongs to a person, not an agent. No agent can register or remove it.`;
19
+ export function validateName(name) {
20
+ if (!NAME_PATTERN.test(name)) {
21
+ throw new ToolError("Invalid name: use 1-32 characters from letters, digits, '-' and '_'.");
22
+ }
23
+ if (name.toLowerCase() === "all") {
24
+ throw new ToolError("The name 'all' is reserved.");
25
+ }
26
+ }
27
+ const MAX_KEY_LENGTH = 256;
28
+ const hashKey = (key) => createHash("sha256").update(key).digest("hex");
29
+ const sameHash = (a, b) => {
30
+ const x = Buffer.from(a);
31
+ const y = Buffer.from(b);
32
+ return x.length === y.length && timingSafeEqual(x, y);
33
+ };
34
+ const toIdentity = (row) => ({
35
+ id: row.id,
36
+ registeredAt: row.registered_at,
37
+ instance: row.instance,
38
+ });
39
+ function validateKey(key) {
40
+ if (key.length < 1 || key.length > MAX_KEY_LENGTH) {
41
+ throw new ToolError(`Invalid key: use 1-${MAX_KEY_LENGTH} characters.`);
42
+ }
43
+ }
44
+ /**
45
+ * Creates the agent, or takes over an existing one with the same name (any case).
46
+ * An agent that has a key can only be taken over with that key; one without a key (registered
47
+ * before keys existed) is claimed by this call. With no `key` given a new one is generated and
48
+ * returned, this once; only its hash is stored. `bio` undefined keeps an existing agent's bio.
49
+ */
50
+ export const registerAgent = (db, account, name, bio, key) => registerWithIdentity(db, account, name, bio, key).result;
51
+ /** The refusal for a token that registers its own agents, given a key: such a token's agents have none. */
52
+ export const REGISTERS_NO_KEY = "This token registers its own agents, which have no key: the token is their credential. Call register without a key.";
53
+ /**
54
+ * `registerAgent`, also returning the identity of the registration the caller now holds. With `token`, the row id of a
55
+ * token that registers its own agents, it takes no key and returns none: a name no live agent or person of the account
56
+ * has makes a new agent recorded as that token's, and a live agent that token registered is taken back as it is; any
57
+ * other agent is refused, whatever key it has. The new agent's key hash is still set, from a key nobody is given, so no
58
+ * call without the token can claim it.
59
+ */
60
+ export const registerWithIdentity = (db, account, name, bio, key, token) => db.transaction(() => {
61
+ validateName(name);
62
+ if (bio !== undefined && bio.length > MAX_BIO_LENGTH) {
63
+ throw new ToolError(`Bio is too long: at most ${MAX_BIO_LENGTH} characters.`);
64
+ }
65
+ if (token !== undefined && key !== undefined)
66
+ throw new ToolError(REGISTERS_NO_KEY);
67
+ if (key !== undefined)
68
+ validateKey(key);
69
+ const existing = db
70
+ .prepare("SELECT name, key_hash, person, token_id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
71
+ .get(account, name);
72
+ // Before the key check: no key, not even a right one, makes a person's name an agent's.
73
+ if (existing?.person === 1)
74
+ throw new ToolError(personRefusal(existing.name));
75
+ if (token !== undefined) {
76
+ if (existing && existing.token_id !== token) {
77
+ throw new ToolError(`Agent '${existing.name}' is already registered. This token registers only new names and takes back only agents it registered.`);
78
+ }
79
+ }
80
+ else if (existing && existing.key_hash !== null && (key === undefined || !sameHash(hashKey(key), existing.key_hash))) {
81
+ throw new ToolError(`Agent '${name}' is already registered and has a key. Pass its key to take it over.`);
82
+ }
83
+ const issued = key === undefined && token === undefined;
84
+ const effectiveKey = key ?? randomBytes(32).toString("hex");
85
+ const keyHash = hashKey(effectiveKey);
86
+ const now = Date.now();
87
+ const row = db
88
+ .prepare(`INSERT INTO agents (account_id, name, bio, registered_at, last_seen, key_hash, instance, token_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
89
+ -- Reached on a conflict only after the key or token check above passed. Another account's agent of the same name
90
+ -- never conflicts. A token's own agent keeps its key hash and token, so taking it back changes neither.
91
+ ON CONFLICT(account_id, name) DO UPDATE SET bio = COALESCE(?, bio), last_seen = excluded.last_seen,
92
+ key_hash = CASE WHEN excluded.token_id IS NULL THEN excluded.key_hash ELSE key_hash END
93
+ RETURNING id, name, bio, registered_at, last_seen, person, instance`)
94
+ .get(account, name, bio ?? "", now, now, keyHash, randomBytes(16).toString("hex"), token ?? null, bio ?? null);
95
+ // A caller that supplied its own key already has it.
96
+ return { result: issued ? { ...toAgent(row), key: effectiveKey } : toAgent(row), identity: toIdentity(row) };
97
+ }).immediate();
98
+ /**
99
+ * The agent a supervisor chose for this process through MESSAGE_CP_AGENT and MESSAGE_CP_AGENT_KEY:
100
+ * created or claimed with that key. Throws ToolError if the agent has a different key.
101
+ */
102
+ export function agentFromEnv(db, account, env = process.env) {
103
+ return registerFromEnv(db, account, env)?.name;
104
+ }
105
+ /** `agentFromEnv`, also returning the identity of the registration it made, so a session binds to exactly that one. */
106
+ export function registerFromEnv(db, account, env = process.env) {
107
+ const name = setting("AGENT", env);
108
+ const key = setting("AGENT_KEY", env);
109
+ if (name === undefined && key === undefined)
110
+ return undefined;
111
+ if (!name || !key) {
112
+ throw new ToolError("MESSAGE_CP_AGENT and MESSAGE_CP_AGENT_KEY must be set together.");
113
+ }
114
+ const { result, identity } = registerWithIdentity(db, account, name, undefined, key);
115
+ return { name: result.name, identity };
116
+ }
117
+ export const DEFAULT_LIST_LIMIT = 50;
118
+ export const MAX_LIST_LIMIT = 200;
119
+ /** One page of the account's agents ordered by name; pass `offset` to read the rest. */
120
+ export function listAgents(db, account, limit = DEFAULT_LIST_LIMIT, offset = 0) {
121
+ const rows = db
122
+ .prepare("SELECT name, bio, registered_at, last_seen, person FROM agents WHERE account_id = ? AND removed_at IS NULL ORDER BY name LIMIT ? OFFSET ?")
123
+ .all(account, Math.min(limit, MAX_LIST_LIMIT), offset);
124
+ return rows.map(toAgent);
125
+ }
126
+ export function getAgent(db, account, name) {
127
+ const row = db
128
+ .prepare("SELECT name, bio, registered_at, last_seen, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
129
+ .get(account, name);
130
+ return row && toAgent(row);
131
+ }
132
+ /** The identity of the agent registered under `name` right now, if any. A person has none: nothing acts as one. */
133
+ export function identityOf(db, account, name) {
134
+ const row = db
135
+ .prepare("SELECT id, registered_at, instance FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 0")
136
+ .get(account, name);
137
+ return row && toIdentity(row);
138
+ }
139
+ /**
140
+ * Whether `identity` is a live agent of `account` that the token of row id `token` registered (`registerWithIdentity`
141
+ * with that token). Found by row, so an agent removed and its name registered again by someone else is not the token's.
142
+ */
143
+ export function registeredBy(db, account, identity, token) {
144
+ return (db
145
+ .prepare("SELECT 1 FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL AND person = 0 AND token_id = ?")
146
+ .get(account, identity.id, identity.registeredAt, identity.instance, token) !== undefined);
147
+ }
148
+ /** The identity and stored name of the live agent `name` (any case) of `account` that the token of row id `token` registered. */
149
+ export function ownAgentOf(db, account, name, token) {
150
+ const row = db
151
+ .prepare("SELECT id, registered_at, instance, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 0 AND token_id = ?")
152
+ .get(account, name, token);
153
+ return row && { ...toIdentity(row), name: row.name };
154
+ }
155
+ /** Thrown, with nothing stored, when a person token sends or posts as anyone but its own person. */
156
+ export class WrongPersonError extends Error {
157
+ }
158
+ /**
159
+ * The account's live person `name`, as `personOf` finds them. With `only`, the row id of a person token's person, any
160
+ * other row, or none, throws `WrongPersonError`: compared by row, so a removed person's name taken again is not theirs.
161
+ * A person removed after their token was checked, during the request, is refused the same way.
162
+ */
163
+ export function actingPerson(db, account, name, only) {
164
+ const person = personOf(db, account, name);
165
+ if (only !== undefined && person?.id !== only)
166
+ throw new WrongPersonError("This token sends and posts only as its own person");
167
+ return person;
168
+ }
169
+ /**
170
+ * The row id and stored name of the account's live person `name` (any case), or undefined for an agent, a removed row
171
+ * or no row at all. Only the HTTP routes that send and post as a person use it.
172
+ */
173
+ export function personOf(db, account, name) {
174
+ return db
175
+ .prepare("SELECT id, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND person = 1")
176
+ .get(account, name);
177
+ }
178
+ /**
179
+ * The name of the agent `identity` still belongs to, or undefined once that registration was removed. Undefined for a
180
+ * person's row too, so no session or channel member can act as a person.
181
+ */
182
+ export function nameOfIdentity(db, account, identity) {
183
+ const row = db
184
+ .prepare("SELECT name FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL AND person = 0")
185
+ .get(account, identity.id, identity.registeredAt, identity.instance);
186
+ return row?.name;
187
+ }
188
+ /** How long a wait's mark lasts after the check that last renewed it, so a waiter that crashed holds no delivery longer. */
189
+ export const WAIT_MARK_MS = 30_000;
190
+ /** A check renews the mark only once less than this is left, so a held wait writes about every 10 seconds, not every check. */
191
+ const WAIT_MARK_RENEW_MS = 20_000;
192
+ /**
193
+ * Marks `reader` (a name, or one registration) as waiting for mail until `WAIT_MARK_MS` from now, for `sendPending` in
194
+ * webhooks.ts, which holds the deliveries queued for its messages and mentions meanwhile. Each check of a held wait that
195
+ * finds nothing unread calls it, never one that finds mail, so an agent that keeps waiting without reading holds nothing.
196
+ * A person is never marked. It reads first and writes only when the mark needs renewing, so most checks take no write
197
+ * lock. Best effort: a lock held past the busy timeout leaves the mark as it was, and the next check tries again.
198
+ */
199
+ export function markWaiting(db, account, reader, now = Date.now()) {
200
+ const which = typeof reader === "string" ? "name = ?" : "id = ? AND registered_at = ? AND instance IS ?";
201
+ const args = typeof reader === "string" ? [reader] : [reader.id, reader.registeredAt, reader.instance];
202
+ const live = `account_id = ? AND ${which} AND removed_at IS NULL AND person = 0 AND (waiting_until IS NULL OR waiting_until < ?)`;
203
+ try {
204
+ if (!db.prepare(`SELECT 1 FROM agents WHERE ${live}`).get(account, ...args, now + WAIT_MARK_RENEW_MS))
205
+ return;
206
+ db.prepare(`UPDATE agents SET waiting_until = ? WHERE ${live}`).run(now + WAIT_MARK_MS, account, ...args, now + WAIT_MARK_RENEW_MS);
207
+ }
208
+ catch (err) {
209
+ const code = err?.code;
210
+ if (typeof code !== "string" || !code.startsWith("SQLITE_BUSY"))
211
+ throw err;
212
+ }
213
+ }
214
+ export function touchAgent(db, account, name) {
215
+ db.prepare("UPDATE agents SET last_seen = ? WHERE account_id = ? AND name = ? AND removed_at IS NULL").run(Date.now(), account, name);
216
+ }
217
+ /** How long a status line stays current after `set_status` set it. */
218
+ export const STATUS_TTL_MS = 5 * 60 * 1000;
219
+ export const MAX_STATUS_LENGTH = 100;
220
+ // A newline, any other control character, or a line or paragraph separator: the line has to stay one line.
221
+ const NOT_ONE_LINE = /[\p{Cc}\p{Zl}\p{Zp}]/u;
222
+ // Bidirectional marks, embeddings, overrides and isolates, which could reorder the text shown around the line. Other
223
+ // format characters, such as the zero-width joiner in emoji sequences, are allowed.
224
+ const BIDI_CONTROL = /\p{Bidi_Control}/u;
225
+ /**
226
+ * Sets the status line of the caller's own registration, `own`, replacing any earlier one, or clears it when `text` is
227
+ * empty or only spaces. Throws, changing nothing, for a line that is too long, not one line or holds a bidirectional
228
+ * formatting character, or NOT_REGISTERED once that registration was removed. A person's row never matches.
229
+ */
230
+ export const setStatus = (db, account, own, text, now = Date.now()) => {
231
+ // Before the blank check, so a lone newline or tab is refused rather than taken as clearing the line.
232
+ if (NOT_ONE_LINE.test(text))
233
+ throw new ToolError("Status must be one line, with no newline or other control character.");
234
+ if (BIDI_CONTROL.test(text))
235
+ throw new ToolError("Status must not contain bidirectional formatting characters.");
236
+ const clear = text.trim().length === 0;
237
+ if (!clear) {
238
+ // Count characters (code points), as validateBody does.
239
+ let n = 0;
240
+ for (const _ of text)
241
+ if (++n > MAX_STATUS_LENGTH)
242
+ break;
243
+ if (n > MAX_STATUS_LENGTH)
244
+ throw new ToolError(`Status is too long: at most ${MAX_STATUS_LENGTH} characters.`);
245
+ }
246
+ return db
247
+ .transaction(() => {
248
+ const changed = db
249
+ .prepare("UPDATE agents SET status = ?, status_at = ? WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL AND person = 0")
250
+ .run(clear ? null : text, clear ? null : now, account, own.id, own.registeredAt, own.instance).changes;
251
+ if (changed === 0)
252
+ throw new ToolError(NOT_REGISTERED);
253
+ return clear ? { status: null, expiresAt: null } : { status: text, expiresAt: new Date(now + STATUS_TTL_MS).toISOString() };
254
+ })
255
+ .immediate();
256
+ };
257
+ /**
258
+ * The name a removed agent's row is given, so the real name is free again. `:` is outside the name pattern, so no
259
+ * one can register it, and the id makes it unique.
260
+ */
261
+ const removedName = (id) => `removed:${id}`;
262
+ // Match no key and no session's instance, so a release from before removals were kept, run on this database, can
263
+ // neither take over or remove a removed agent's row by name nor act as it from a session that was registered as it,
264
+ // and so cannot read or delete the messages kept with it.
265
+ const REMOVED_KEY_HASH = "removed";
266
+ const REMOVED_INSTANCE = "removed";
267
+ /**
268
+ * Removes the agent `name` from the directory and frees its name. The row stays, marked removed, with its real name
269
+ * in `removed_name`, so the messages addressed to it are kept and its id is never given to a new agent. An agent
270
+ * with a key is removed only with that key, unless `own` is the identity of the caller's session: then that exact
271
+ * registration is removed, or nothing if it is already gone, so a name registered by someone else in the meantime is
272
+ * never touched. With `token` instead, the row id of a token that registers its own agents, only a live agent that token
273
+ * registered is removed, with no key (a key is refused); any other name is refused.
274
+ */
275
+ export const removeAgent = (db, account, name, key, own, token) => db.transaction(() => {
276
+ if (token !== undefined && !own && key !== undefined)
277
+ throw new ToolError(REGISTERS_NO_KEY);
278
+ const row = (own
279
+ ? db
280
+ .prepare("SELECT id, name, key_hash, person FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
281
+ .get(account, own.id, own.registeredAt, own.instance)
282
+ : db
283
+ .prepare("SELECT id, name, key_hash, person, token_id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
284
+ .get(account, name));
285
+ if (!row)
286
+ throw new ToolError(own ? NOT_REGISTERED : `No agent named '${name}'.`);
287
+ if (row.person === 1)
288
+ throw new ToolError(personRefusal(row.name));
289
+ if (token !== undefined && !own) {
290
+ if (row.token_id !== token)
291
+ throw new ToolError(`Agent '${row.name}' was not registered by this token, which removes only agents it registered.`);
292
+ }
293
+ else if (row.key_hash !== null && !own) {
294
+ if (key === undefined)
295
+ throw new ToolError(`Agent '${row.name}' has a key. Pass its key to remove it.`);
296
+ if (!sameHash(hashKey(key), row.key_hash))
297
+ throw new ToolError(`Wrong key for agent '${row.name}'.`);
298
+ }
299
+ db.prepare("UPDATE agents SET name = ?, removed_name = name, removed_at = ?, key_hash = ?, instance = ?, status = NULL, status_at = NULL WHERE id = ?").run(removedName(row.id), Date.now(), REMOVED_KEY_HASH, REMOVED_INSTANCE, row.id);
300
+ const keptMessages = db.prepare("SELECT COUNT(*) AS n FROM messages WHERE recipient_id = ?").get(row.id).n;
301
+ return { removed: row.name, keptMessages };
302
+ }).immediate();
303
+ /**
304
+ * Ends every live agent the token of row id `token`, one that registers its own agents, registered in the account
305
+ * `account`, as `removeAgent` ends one: its name is freed, its row kept, marked removed, with its messages. Returns
306
+ * their names, in name order; none for any other token, as only a token that registers its own agents is ever stored
307
+ * on an agent (`registerWithIdentity`). Run inside the transaction that revokes the token, so no reader sees one
308
+ * without the other.
309
+ */
310
+ export function endTokenAgents(db, account, token) {
311
+ // The new name is the one removedName gives. Every expression in SET reads the row as it was, so removed_name takes
312
+ // the live name. The unary + keeps SQLite finding the rows through agents_by_token, as listApprovals does.
313
+ const ended = db
314
+ .prepare(`UPDATE agents SET name = 'removed:' || id, removed_name = name, removed_at = ?, key_hash = ?, instance = ?, status = NULL, status_at = NULL
315
+ WHERE token_id = ? AND +account_id = ? AND removed_at IS NULL AND person = 0 RETURNING removed_name`)
316
+ .pluck()
317
+ .all(Date.now(), REMOVED_KEY_HASH, REMOVED_INSTANCE, token, account);
318
+ // In the order listApprovals lists them: names are ASCII and agents.name is COLLATE NOCASE, so case is ignored, and
319
+ // no two live names of an account differ only in case.
320
+ const key = (n) => n.toLowerCase();
321
+ return ended.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
322
+ }
323
+ export class PurgeError extends Error {
324
+ }
325
+ /**
326
+ * For `message-cp agent remove`: removes the agent `name` (any case) of the account named `account`, live or only
327
+ * left as removed rows, with every direct message to or from it and its mentions, and frees its name as `removeAgent`
328
+ * does. Channels, posts and read positions stay. The rows stay too, marked removed: posts and read positions point
329
+ * at them. Refuses, changing nothing, a live person's name, a name no agent of the account has had, or an unknown
330
+ * account.
331
+ */
332
+ export const purgeAgent = (db, account, name) => db.transaction(() => {
333
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
334
+ if (!acct)
335
+ throw new PurgeError(`No account named '${account}'.`);
336
+ const live = db
337
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
338
+ .get(acct.id, name);
339
+ // Before anything is deleted, so a person's messages, and any sent under the name they now have, are never touched.
340
+ if (live?.person === 1)
341
+ throw new PurgeError(`The name '${live.name}' belongs to a person, not an agent.`);
342
+ const removed = db
343
+ .prepare("SELECT id, removed_name FROM agents WHERE account_id = ? AND person = 0 AND removed_at IS NOT NULL AND removed_name = ? COLLATE NOCASE ORDER BY id")
344
+ .all(acct.id, name);
345
+ const ids = [...(live ? [live.id] : []), ...removed.map((r) => r.id)];
346
+ if (ids.length === 0)
347
+ throw new PurgeError(`No agent named '${name}' in account '${acct.name}'.`);
348
+ // The same rows as `ids`, as a subquery, so no statement needs one bound variable per registration. A person
349
+ // removed with `person remove` is not one of them.
350
+ const rows = `SELECT id FROM agents WHERE account_id = ? AND (id = ? OR (person = 0 AND removed_at IS NOT NULL AND removed_name = ? COLLATE NOCASE))`;
351
+ const rowArgs = [acct.id, live?.id ?? null, name];
352
+ // Two deletes, each searched by its own index (messages_history, then messages_by_sender), where one with OR scans
353
+ // every message. A message both match goes with the first, so the sum counts each once.
354
+ const messages = db.prepare(`DELETE FROM messages WHERE recipient_id IN (${rows})`).run(...rowArgs).changes +
355
+ db
356
+ .prepare("DELETE FROM messages WHERE sender = ? COLLATE NOCASE AND recipient_id IN (SELECT id FROM agents WHERE account_id = ?)")
357
+ .run(name, acct.id).changes;
358
+ const mentions = db.prepare(`DELETE FROM mentions WHERE agent_id IN (${rows})`).run(...rowArgs).changes;
359
+ if (live) {
360
+ db.prepare("UPDATE agents SET name = ?, removed_name = name, removed_at = ?, key_hash = ?, instance = ?, status = NULL, status_at = NULL WHERE id = ?").run(removedName(live.id), Date.now(), REMOVED_KEY_HASH, REMOVED_INSTANCE, live.id);
361
+ }
362
+ return { name: live?.name ?? removed[removed.length - 1].removed_name, account: acct.name, messages, mentions };
363
+ }).immediate();
364
+ export class KeyResetError extends Error {
365
+ }
366
+ /**
367
+ * For `message-cp agent key reset`: gives the live agent `name` (any case) of the account named `account` a new random
368
+ * key, returned this once; only its hash is stored. The row stays, so its id, bio, messages, mentions, channels and
369
+ * webhooks stay too. A new `instance` ends every session registered as the agent, as `nameOfIdentity` no longer matches
370
+ * their identity; `registered_at` is kept. Refuses, changing nothing, a person's name, a name with no live agent in the
371
+ * account, or an unknown account.
372
+ */
373
+ export const resetAgentKey = (db, account, name) => db.transaction(() => {
374
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
375
+ if (!acct)
376
+ throw new KeyResetError(`No account named '${account}'.`);
377
+ const live = db
378
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
379
+ .get(acct.id, name);
380
+ if (live?.person === 1)
381
+ throw new KeyResetError(`The name '${live.name}' belongs to a person, not an agent.`);
382
+ if (!live)
383
+ throw new KeyResetError(`No agent named '${name}' in account '${acct.name}'.`);
384
+ const key = randomBytes(32).toString("hex");
385
+ db.prepare("UPDATE agents SET key_hash = ?, instance = ? WHERE id = ?").run(hashKey(key), randomBytes(16).toString("hex"), live.id);
386
+ return { name: live.name, account: acct.name, key };
387
+ }).immediate();
388
+ export class PersonError extends Error {
389
+ }
390
+ // Matches no key and no session's instance, like REMOVED_KEY_HASH, so nothing can act as a person. The `person`
391
+ // checks in registerWithIdentity and removeAgent are what refuse; this only keeps an older release from claiming it.
392
+ const PERSON_KEY_HASH = "person";
393
+ const PERSON_INSTANCE = "person";
394
+ /**
395
+ * Adds the person `name` to the account named `account`, as a row of `agents` marked `person`, and `admin` with
396
+ * `admin`, so messages, mentions and webhooks reach it as they reach an agent. Refuses a name a live agent or person of
397
+ * that account has (any case).
398
+ */
399
+ export const addPerson = (db, account, name, admin = false) => db.transaction(() => {
400
+ try {
401
+ validateName(name);
402
+ }
403
+ catch (err) {
404
+ if (err instanceof ToolError)
405
+ throw new PersonError(err.message);
406
+ throw err;
407
+ }
408
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
409
+ if (!acct)
410
+ throw new PersonError(`No account named '${account}'.`);
411
+ const taken = db
412
+ .prepare("SELECT name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
413
+ .get(acct.id, name);
414
+ if (taken)
415
+ throw new PersonError(`The name '${taken.name}' is already used by ${taken.person === 1 ? "a person" : "an agent"} in account '${acct.name}'.`);
416
+ const now = Date.now();
417
+ db.prepare("INSERT INTO agents (account_id, name, bio, registered_at, last_seen, key_hash, instance, person, admin) VALUES (?, ?, '', ?, ?, ?, ?, 1, ?)").run(acct.id, name, now, now, PERSON_KEY_HASH, PERSON_INSTANCE, admin ? 1 : 0);
418
+ return { name, account: acct.name, addedAt: new Date(now).toISOString(), admin };
419
+ }).immediate();
420
+ /**
421
+ * For `message-cp person admin`: marks the live person `name` (any case) of the account named `account` an admin, or
422
+ * unmarks them with `admin` false. Their person tokens follow the mark from their next request, as `accountForToken`
423
+ * reads it each time. Refuses, changing nothing, an agent's name, a name no live person of the account has, or an
424
+ * unknown account.
425
+ */
426
+ export const setPersonAdmin = (db, account, name, admin) => db.transaction(() => {
427
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
428
+ if (!acct)
429
+ throw new PersonError(`No account named '${account}'.`);
430
+ const live = db
431
+ .prepare("SELECT id, name, person FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
432
+ .get(acct.id, name);
433
+ if (live?.person === 0)
434
+ throw new PersonError(`The name '${live.name}' belongs to an agent, not a person.`);
435
+ if (!live)
436
+ throw new PersonError(`No person named '${name}' in account '${acct.name}'.`);
437
+ db.prepare("UPDATE agents SET admin = ? WHERE account_id = ? AND id = ? AND removed_at IS NULL AND person = 1").run(admin ? 1 : 0, acct.id, live.id);
438
+ return { name: live.name, account: acct.name, admin };
439
+ }).immediate();
440
+ /**
441
+ * For `message-cp person remove`: removes the live person `name` (any case) of the account named `account`, with every
442
+ * direct message to them or sent from the name since they were added, and their mentions, and frees the name, as
443
+ * `purgeAgent` does for an agent. Channels, posts and read positions stay, and so does the row, marked removed.
444
+ * Removed rows of earlier agents with that name, and the messages they sent, are left to `agent remove`. Refuses,
445
+ * changing nothing, an agent's name, a name no live person of the account has, or an unknown account.
446
+ */
447
+ export const removePerson = (db, account, name) => db.transaction(() => {
448
+ const acct = db.prepare("SELECT id, name FROM accounts WHERE name = ?").get(account);
449
+ if (!acct)
450
+ throw new PersonError(`No account named '${account}'.`);
451
+ const live = db
452
+ .prepare("SELECT id, name, person, registered_at FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
453
+ .get(acct.id, name);
454
+ // Before anything is deleted, so an agent's messages are never touched.
455
+ if (live?.person === 0)
456
+ throw new PersonError(`The name '${live.name}' belongs to an agent, not a person. Remove it with \`agent remove\`.`);
457
+ if (!live)
458
+ throw new PersonError(`No person named '${name}' in account '${acct.name}'.`);
459
+ // Only the person sends under the name while it is theirs, so messages from it since they were added, by the
460
+ // server's clock, are theirs; older ones are an earlier agent's, which keeps them until `agent remove`.
461
+ // Two deletes rather than one with OR, so each searches an index (messages_history, messages_by_pair) rather than
462
+ // scanning every message under the write lock. A message to themselves goes with the first.
463
+ const messages = db.prepare("DELETE FROM messages WHERE recipient_id = ?").run(live.id).changes +
464
+ db
465
+ .prepare(`DELETE FROM messages WHERE sender = ? COLLATE NOCASE AND created_at >= ?
466
+ AND recipient_id IN (SELECT id FROM agents WHERE account_id = ?)`)
467
+ .run(live.name, live.registered_at, acct.id).changes;
468
+ const mentions = db.prepare("DELETE FROM mentions WHERE agent_id = ?").run(live.id).changes;
469
+ db.prepare("UPDATE agents SET name = ?, removed_name = name, removed_at = ?, key_hash = ?, instance = ?, status = NULL, status_at = NULL WHERE id = ?").run(removedName(live.id), Date.now(), REMOVED_KEY_HASH, REMOVED_INSTANCE, live.id);
470
+ return { name: live.name, account: acct.name, messages, mentions };
471
+ }).immediate();
472
+ /** The live people of the account named `account`, or of every account without it, by account then name. */
473
+ export function listPeople(db, account) {
474
+ if (account !== undefined && !db.prepare("SELECT 1 FROM accounts WHERE name = ?").get(account)) {
475
+ throw new PersonError(`No account named '${account}'.`);
476
+ }
477
+ const rows = db
478
+ .prepare(`SELECT agents.name, accounts.name AS account, agents.registered_at, agents.admin
479
+ FROM agents JOIN accounts ON accounts.id = agents.account_id
480
+ WHERE agents.person = 1 AND agents.removed_at IS NULL AND (? IS NULL OR accounts.name = ?)
481
+ ORDER BY accounts.name, agents.name`)
482
+ .all(account ?? null, account ?? null);
483
+ return rows.map((r) => ({ name: r.name, account: r.account, addedAt: new Date(r.registered_at).toISOString(), admin: r.admin === 1 }));
484
+ }
485
+ export class AgentListError extends Error {
486
+ }
487
+ /**
488
+ * For `message-cp agent list`: the live agents (not people) of the account named `account`, or of every account
489
+ * without it, by account then name. Returns no key or hash.
490
+ */
491
+ export function listServerAgents(db, account) {
492
+ if (account !== undefined && !db.prepare("SELECT 1 FROM accounts WHERE name = ?").get(account)) {
493
+ throw new AgentListError(`No account named '${account}'.`);
494
+ }
495
+ const rows = db
496
+ .prepare(`SELECT agents.name, accounts.name AS account, agents.registered_at, agents.last_seen
497
+ FROM agents JOIN accounts ON accounts.id = agents.account_id
498
+ WHERE agents.person = 0 AND agents.removed_at IS NULL AND (? IS NULL OR accounts.name = ?)
499
+ ORDER BY accounts.name, agents.name`)
500
+ .all(account ?? null, account ?? null);
501
+ return rows.map((r) => ({
502
+ name: r.name,
503
+ account: r.account,
504
+ registeredAt: new Date(r.registered_at).toISOString(),
505
+ lastSeen: new Date(r.last_seen).toISOString(),
506
+ }));
507
+ }