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/LICENSE +21 -0
- package/README.md +45 -2
- package/dist/accounts.js +279 -0
- package/dist/agents.js +507 -0
- package/dist/approve.js +260 -0
- package/dist/channels.js +262 -0
- package/dist/cli.js +523 -0
- package/dist/client-metadata.js +167 -0
- package/dist/connect.js +85 -0
- package/dist/dashboard.js +4961 -0
- package/dist/db.js +605 -0
- package/dist/http.js +1933 -0
- package/dist/index.js +21 -0
- package/dist/init.js +960 -0
- package/dist/login.js +530 -0
- package/dist/messages.js +513 -0
- package/dist/oauth.js +384 -0
- package/dist/server.js +420 -0
- package/dist/sessions.js +53 -0
- package/dist/settings.js +13 -0
- package/dist/wait.js +137 -0
- package/dist/webhooks.js +464 -0
- package/package.json +44 -4
package/dist/messages.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { NAME_PATTERN, STATUS_TTL_MS, ToolError, actingPerson, getAgent } from "./agents.js";
|
|
2
|
+
export const MAX_BODY_LENGTH = 4000;
|
|
3
|
+
export const DEFAULT_INBOX_LIMIT = 50;
|
|
4
|
+
export const MAX_INBOX_LIMIT = 200;
|
|
5
|
+
function countCodePoints(s) {
|
|
6
|
+
let n = 0;
|
|
7
|
+
for (const _ of s)
|
|
8
|
+
n++;
|
|
9
|
+
return n;
|
|
10
|
+
}
|
|
11
|
+
/** Throws unless `body` is a valid message: not empty or only whitespace, at most MAX_BODY_LENGTH characters. */
|
|
12
|
+
export function validateBody(body, kind = "Message") {
|
|
13
|
+
if (body.trim().length === 0)
|
|
14
|
+
throw new ToolError(`${kind} body is empty.`);
|
|
15
|
+
// Count characters (code points), not UTF-16 code units, to match what the tool advertises.
|
|
16
|
+
// A code point is at most 2 code units, so anything over twice the limit is too long without iterating.
|
|
17
|
+
if (body.length > 2 * MAX_BODY_LENGTH || countCodePoints(body) > MAX_BODY_LENGTH) {
|
|
18
|
+
throw new ToolError(`${kind} is too long: at most ${MAX_BODY_LENGTH} characters.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Stores one delivery per recipient: the named agent, or for `all` every agent of the account registered right now
|
|
23
|
+
* except the sender. Read state therefore belongs to each recipient. A message belongs to its recipient's account,
|
|
24
|
+
* which is always the sender's. `own` is the sending agent's registration, whose status line the send clears; a
|
|
25
|
+
* person sends without one. `inReplyTo`, which needs `own`, is the id of a message stored for that registration, which
|
|
26
|
+
* each stored row records; any other id throws the same error, so it does not tell whether the id exists.
|
|
27
|
+
*/
|
|
28
|
+
export function sendMessage(db, account, from, to, body, own, inReplyTo) {
|
|
29
|
+
validateBody(body);
|
|
30
|
+
const broadcast = to.toLowerCase() === "all";
|
|
31
|
+
// IMMEDIATE takes the write lock up front, so the recipient list cannot change under us.
|
|
32
|
+
return db
|
|
33
|
+
.transaction(() => {
|
|
34
|
+
// Checked against the caller's own live row, so another account's or agent's message, or a removed agent's that
|
|
35
|
+
// had the caller's name, fails as an unknown id does.
|
|
36
|
+
if (inReplyTo !== undefined) {
|
|
37
|
+
const answered = own &&
|
|
38
|
+
db
|
|
39
|
+
.prepare(`SELECT 1 FROM messages WHERE id = ? AND recipient_id = (SELECT id FROM agents WHERE account_id = ? AND id = ?
|
|
40
|
+
AND registered_at = ? AND instance IS ? AND removed_at IS NULL)`)
|
|
41
|
+
.get(inReplyTo, account, own.id, own.registeredAt, own.instance);
|
|
42
|
+
if (!answered)
|
|
43
|
+
throw new ToolError(`No message ${inReplyTo} was sent to you.`);
|
|
44
|
+
}
|
|
45
|
+
let recipients;
|
|
46
|
+
if (broadcast) {
|
|
47
|
+
recipients = db
|
|
48
|
+
.prepare("SELECT id, name FROM agents WHERE account_id = ? AND name <> ? AND removed_at IS NULL ORDER BY name")
|
|
49
|
+
.all(account, from);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const agent = getAgent(db, account, to);
|
|
53
|
+
if (!agent)
|
|
54
|
+
throw new ToolError(`No agent named '${to}'.`);
|
|
55
|
+
recipients = db
|
|
56
|
+
.prepare("SELECT id, name FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL")
|
|
57
|
+
.all(account, agent.name);
|
|
58
|
+
}
|
|
59
|
+
const insert = db.prepare("INSERT INTO messages (recipient_id, sender, body, broadcast, created_at, in_reply_to) VALUES (?, ?, ?, ?, ?, ?)");
|
|
60
|
+
// Each recipient's row is owed to every `agent:<recipient>` webhook of the account, held for the recipient while
|
|
61
|
+
// it is waiting for mail (`markWaiting`), so `sendPending` leaves it to the waiting agent.
|
|
62
|
+
const queue = db.prepare(`INSERT INTO webhook_deliveries (webhook_id, message_id, created_at, held_for)
|
|
63
|
+
SELECT id, ?, ?, (SELECT a.id FROM agents a WHERE a.id = ? AND a.waiting_until > ?)
|
|
64
|
+
FROM webhooks WHERE account_id = ? AND event = 'agent' AND name = ? COLLATE NOCASE ORDER BY id`);
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const stored = [];
|
|
67
|
+
for (const r of recipients) {
|
|
68
|
+
const id = insert.run(r.id, from, body, broadcast ? 1 : 0, now, inReplyTo ?? null).lastInsertRowid;
|
|
69
|
+
queue.run(id, now, r.id, now, account, r.name);
|
|
70
|
+
stored.push({ id: Number(id), to: r.name });
|
|
71
|
+
}
|
|
72
|
+
// Sending clears the sender's status line, on its own registration only, so a send from a session whose agent was
|
|
73
|
+
// removed meanwhile never clears the line of a later agent with the same name.
|
|
74
|
+
if (own) {
|
|
75
|
+
db.prepare("UPDATE agents SET status = NULL, status_at = NULL WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL AND person = 0 AND status IS NOT NULL").run(account, own.id, own.registeredAt, own.instance);
|
|
76
|
+
}
|
|
77
|
+
return { delivered: recipients.length, recipients: recipients.map((r) => r.name), messages: stored };
|
|
78
|
+
})
|
|
79
|
+
.immediate();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `sendMessage` from the account's live person `as`, under the person's stored name. Undefined, with nothing stored,
|
|
83
|
+
* if `as` is not a live person of the account, so an agent's name, a removed one and an unknown one are alike. With
|
|
84
|
+
* `only`, a person token's person's row id, throws `WrongPersonError`, storing nothing, unless `as` is that row.
|
|
85
|
+
*/
|
|
86
|
+
export function sendAsPerson(db, account, as, to, body, only) {
|
|
87
|
+
validateBody(body);
|
|
88
|
+
// One transaction, so the person cannot be removed between the check and the send.
|
|
89
|
+
return db
|
|
90
|
+
.transaction(() => {
|
|
91
|
+
const person = actingPerson(db, account, as, only);
|
|
92
|
+
return person && sendMessage(db, account, person.name, to, body);
|
|
93
|
+
})
|
|
94
|
+
.immediate();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `sendMessage` from the agent `own` to the one live agent `to`, as `ask` sends its question: `all` and a person are
|
|
98
|
+
* refused, as a person cannot reply with an id, and an unknown name fails as in `sendMessage`. One transaction, so `to`
|
|
99
|
+
* cannot become a person between the check and the send.
|
|
100
|
+
*/
|
|
101
|
+
export function sendQuestion(db, account, own, to, body) {
|
|
102
|
+
validateBody(body);
|
|
103
|
+
if (to.toLowerCase() === "all")
|
|
104
|
+
throw new ToolError("ask goes to one agent, so to cannot be 'all'.");
|
|
105
|
+
return db
|
|
106
|
+
.transaction(() => {
|
|
107
|
+
const agent = getAgent(db, account, to);
|
|
108
|
+
if (agent?.person)
|
|
109
|
+
throw new ToolError(`'${agent.name}' is a person, who cannot answer ask; use send_message.`);
|
|
110
|
+
return sendMessage(db, account, own.name, to, body, own);
|
|
111
|
+
})
|
|
112
|
+
.immediate();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The earliest message from `from`, the agent asked, to `own`'s live row that answers message `questionId`, marked read
|
|
116
|
+
* as `readInbox` would mark it. Null when there is none yet, undefined when `own` is no longer registered, even if
|
|
117
|
+
* another agent now has its name. Found through the caller's row, so another account's or agent's reply never matches,
|
|
118
|
+
* and by its sender, so an answer from anyone but the agent asked never matches either.
|
|
119
|
+
*/
|
|
120
|
+
export function takeReply(db, account, own, questionId, from) {
|
|
121
|
+
const find = () => {
|
|
122
|
+
const agent = db
|
|
123
|
+
.prepare("SELECT id FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
124
|
+
.get(account, own.id, own.registeredAt, own.instance);
|
|
125
|
+
if (!agent)
|
|
126
|
+
return undefined;
|
|
127
|
+
return db.prepare(TAKE_REPLY_SQL).get(questionId, agent.id, from) ?? null;
|
|
128
|
+
};
|
|
129
|
+
// A plain read first, which never waits for the write lock, so a poll with no reply yet takes no lock; the lock is
|
|
130
|
+
// taken only to mark a reply read, and the caller's row and the reply are found again under it.
|
|
131
|
+
const seen = db.transaction(find)();
|
|
132
|
+
if (!seen)
|
|
133
|
+
return seen;
|
|
134
|
+
return db
|
|
135
|
+
.transaction(() => {
|
|
136
|
+
const r = find();
|
|
137
|
+
if (!r)
|
|
138
|
+
return r;
|
|
139
|
+
db.prepare("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL").run(Date.now(), r.id);
|
|
140
|
+
return {
|
|
141
|
+
id: r.id,
|
|
142
|
+
inReplyTo: r.in_reply_to,
|
|
143
|
+
from: r.sender,
|
|
144
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
145
|
+
body: r.body,
|
|
146
|
+
broadcast: r.broadcast === 1,
|
|
147
|
+
};
|
|
148
|
+
})
|
|
149
|
+
.immediate();
|
|
150
|
+
}
|
|
151
|
+
// Found through messages_by_question, whose rows for one recipient and question are already in id order, so replies
|
|
152
|
+
// to the question sent to other agents are never read. The sender is checked on those rows only.
|
|
153
|
+
export const TAKE_REPLY_SQL = `SELECT id, sender, body, broadcast, created_at, in_reply_to FROM messages
|
|
154
|
+
WHERE in_reply_to = ? AND recipient_id = ? AND sender = ? COLLATE NOCASE ORDER BY id LIMIT 1`;
|
|
155
|
+
/**
|
|
156
|
+
* One page of the caller's messages and mentions together, oldest first. Everything returned is marked read. Unread
|
|
157
|
+
* ones beyond the page stay unread for the next call; with `unreadOnly` false, use `offset`. Reading a mention does
|
|
158
|
+
* not move the caller's read position in its channel. `reader` is the caller's name, or its registration.
|
|
159
|
+
*/
|
|
160
|
+
export function readInbox(db, account, reader, unreadOnly = true, limit = DEFAULT_INBOX_LIMIT, offset = 0) {
|
|
161
|
+
return db
|
|
162
|
+
.transaction(() => {
|
|
163
|
+
// A session passes its registration, so a name taken over since the session checked it reads nothing.
|
|
164
|
+
const agent = (typeof reader === "string"
|
|
165
|
+
? db.prepare("SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL").get(account, reader)
|
|
166
|
+
: db
|
|
167
|
+
.prepare("SELECT id FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
168
|
+
.get(account, reader.id, reader.registeredAt, reader.instance));
|
|
169
|
+
if (!agent)
|
|
170
|
+
return [];
|
|
171
|
+
// Oldest first; a message before a mention of the same millisecond, and each kind in id order.
|
|
172
|
+
const rows = db
|
|
173
|
+
.prepare(`SELECT 0 AS mention, id, sender, body, broadcast, created_at, NULL AS channel, in_reply_to
|
|
174
|
+
FROM messages WHERE recipient_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""}
|
|
175
|
+
UNION ALL
|
|
176
|
+
SELECT 1, p.id, p.sender, p.body, 0, p.created_at, c.name, NULL
|
|
177
|
+
FROM mentions x JOIN channel_posts p ON p.id = x.post_id
|
|
178
|
+
JOIN channels c ON c.id = p.channel_id AND c.account_id = ?
|
|
179
|
+
WHERE x.agent_id = ? ${unreadOnly ? "AND x.read_at IS NULL" : ""}
|
|
180
|
+
ORDER BY created_at, mention, id LIMIT ? OFFSET ?`)
|
|
181
|
+
.all(agent.id, account, agent.id, Math.min(limit, MAX_INBOX_LIMIT), offset);
|
|
182
|
+
const markMessage = db.prepare("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL");
|
|
183
|
+
const markMention = db.prepare("UPDATE mentions SET read_at = ? WHERE agent_id = ? AND post_id = ? AND read_at IS NULL");
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
for (const r of rows) {
|
|
186
|
+
if (r.mention === 1)
|
|
187
|
+
markMention.run(now, agent.id, r.id);
|
|
188
|
+
else
|
|
189
|
+
markMessage.run(now, r.id);
|
|
190
|
+
}
|
|
191
|
+
return rows.map((r) => r.mention === 1
|
|
192
|
+
? { channel: r.channel, id: r.id, from: r.sender, sentAt: new Date(r.created_at).toISOString(), body: r.body }
|
|
193
|
+
: {
|
|
194
|
+
id: r.id,
|
|
195
|
+
inReplyTo: r.in_reply_to,
|
|
196
|
+
from: r.sender,
|
|
197
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
198
|
+
body: r.body,
|
|
199
|
+
broadcast: r.broadcast === 1,
|
|
200
|
+
});
|
|
201
|
+
})
|
|
202
|
+
.immediate();
|
|
203
|
+
}
|
|
204
|
+
export const NOT_YOUR_MESSAGES = "Not every id is a message you sent.";
|
|
205
|
+
// The ids are one bound JSON array; CROSS JOIN keeps them first, so each row is found by its primary key.
|
|
206
|
+
export const MESSAGE_STATUS_SQL = `SELECT m.id, CASE WHEN a.removed_at IS NULL THEN a.name ELSE a.removed_name END AS recipient, m.read_at
|
|
207
|
+
FROM json_each(?) j CROSS JOIN messages m ON m.id = j.value JOIN agents a ON a.id = m.recipient_id AND a.account_id = ?
|
|
208
|
+
WHERE m.sender = ? COLLATE NOCASE AND m.created_at >= ?`;
|
|
209
|
+
/**
|
|
210
|
+
* The recipient and read state of each of `ids`, in the order asked, a repeated id once. `sender` is only a name, so
|
|
211
|
+
* sent means sent under the caller's name since its row was registered, as `readConversation` matches it, to a
|
|
212
|
+
* recipient of the caller's account; a removed recipient is named by the name it had. Any other id, or a caller removed
|
|
213
|
+
* since the session checked it, throws one error, so it does not tell whether an id exists. Reads only.
|
|
214
|
+
*/
|
|
215
|
+
export function messageStatus(db, account, caller, ids) {
|
|
216
|
+
const wanted = [...new Set(ids)];
|
|
217
|
+
// One read transaction, so the caller's row and the messages are read at the same moment.
|
|
218
|
+
return db.transaction(() => {
|
|
219
|
+
const me = db
|
|
220
|
+
.prepare("SELECT name, registered_at FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
221
|
+
.get(account, caller.id, caller.registeredAt, caller.instance);
|
|
222
|
+
if (!me)
|
|
223
|
+
throw new ToolError(NOT_YOUR_MESSAGES);
|
|
224
|
+
const rows = db
|
|
225
|
+
.prepare(MESSAGE_STATUS_SQL)
|
|
226
|
+
.all(JSON.stringify(wanted), account, me.name, me.registered_at);
|
|
227
|
+
if (rows.length !== wanted.length)
|
|
228
|
+
throw new ToolError(NOT_YOUR_MESSAGES);
|
|
229
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
230
|
+
return wanted.map((id) => {
|
|
231
|
+
const r = byId.get(id);
|
|
232
|
+
return { id, to: r.recipient, read: r.read_at !== null, readAt: r.read_at === null ? null : new Date(r.read_at).toISOString() };
|
|
233
|
+
});
|
|
234
|
+
})();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* One page of the direct messages between `reader` and the live agent or person `other` (any case), both directions,
|
|
238
|
+
* newest first. Received means addressed to the reader's own row, so a removed agent's mail never shows. `sender` is
|
|
239
|
+
* only a name, so sent means sent under the reader's name since its row was registered: a removed agent that once had
|
|
240
|
+
* the name sent the rest. Reads only: nothing is marked read. Throws send_message's error for an unknown or removed
|
|
241
|
+
* `other`; returns nothing if the reader's registration is no longer live.
|
|
242
|
+
*/
|
|
243
|
+
export function readConversation(db, account, reader, other, limit = DEFAULT_INBOX_LIMIT, offset = 0) {
|
|
244
|
+
// One read transaction, so both directions and the rows they name are read at the same moment.
|
|
245
|
+
return db.transaction(() => {
|
|
246
|
+
const them = getAgent(db, account, other);
|
|
247
|
+
if (!them)
|
|
248
|
+
throw new ToolError(`No agent named '${other}'.`);
|
|
249
|
+
const me = db
|
|
250
|
+
.prepare("SELECT id, name, registered_at FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
251
|
+
.get(account, reader.id, reader.registeredAt, reader.instance);
|
|
252
|
+
if (!me)
|
|
253
|
+
return [];
|
|
254
|
+
const self = me.name.toLowerCase() === them.name.toLowerCase();
|
|
255
|
+
// Each direction reads the pair's messages_by_pair rows already in page order, so the page stops after
|
|
256
|
+
// limit + offset rows. With oneself the two directions are the same rows, so only the received one runs, and
|
|
257
|
+
// each message appears once.
|
|
258
|
+
const rows = db
|
|
259
|
+
.prepare(`SELECT id, sender, ? AS recipient, body, broadcast, created_at, in_reply_to FROM messages
|
|
260
|
+
WHERE recipient_id = ? AND sender = ? COLLATE NOCASE
|
|
261
|
+
${self ? "" : `UNION ALL
|
|
262
|
+
SELECT id, sender, ?, body, broadcast, created_at, in_reply_to FROM messages
|
|
263
|
+
WHERE recipient_id = (SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL)
|
|
264
|
+
AND sender = ? COLLATE NOCASE AND created_at >= ?`}
|
|
265
|
+
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`)
|
|
266
|
+
.all(me.name, me.id, them.name, ...(self ? [] : [them.name, account, them.name, me.name, me.registered_at]), Math.min(limit, MAX_INBOX_LIMIT), offset);
|
|
267
|
+
return rows.map((r) => ({
|
|
268
|
+
id: r.id,
|
|
269
|
+
inReplyTo: r.in_reply_to,
|
|
270
|
+
from: r.sender,
|
|
271
|
+
to: r.recipient,
|
|
272
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
273
|
+
body: r.body,
|
|
274
|
+
broadcast: r.broadcast === 1,
|
|
275
|
+
}));
|
|
276
|
+
})();
|
|
277
|
+
}
|
|
278
|
+
/** Whether `unread` is anything to wake for: an unread message, an unread post in a joined channel or a mention. */
|
|
279
|
+
export const hasUnread = (unread) => unread.count > 0 || unread.posts > 0 || unread.mentions > 0;
|
|
280
|
+
/**
|
|
281
|
+
* How many unread messages `reader` has and who sent them, how many unread posts are in the channels it has joined and
|
|
282
|
+
* where, and how many unread mentions it has and where, without reading or marking anything. `reader` is a name, as
|
|
283
|
+
* `wait` and GET /wait pass it, or a registration, as `wait_for_mail` passes it, so a registration that has been
|
|
284
|
+
* removed, or whose key was reset, counts nothing even if another agent now has its name; a takeover with the key keeps
|
|
285
|
+
* the same row and counts as before. Undefined if there is no such agent.
|
|
286
|
+
*/
|
|
287
|
+
export function unreadSummary(db, account, reader) {
|
|
288
|
+
// One read transaction, so the messages and the posts are counted at the same moment.
|
|
289
|
+
return db.transaction(() => {
|
|
290
|
+
const agent = (typeof reader === "string"
|
|
291
|
+
? db.prepare("SELECT id FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL").get(account, reader)
|
|
292
|
+
: db
|
|
293
|
+
.prepare("SELECT id FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
294
|
+
.get(account, reader.id, reader.registeredAt, reader.instance));
|
|
295
|
+
if (!agent)
|
|
296
|
+
return undefined;
|
|
297
|
+
// One query each, so a count and its names always describe the same rows.
|
|
298
|
+
const messages = db
|
|
299
|
+
.prepare(`SELECT m.sender, COUNT(*) AS n FROM messages m JOIN agents a ON a.id = m.recipient_id
|
|
300
|
+
WHERE a.account_id = ? AND a.id = ? AND a.removed_at IS NULL AND m.read_at IS NULL GROUP BY m.sender ORDER BY MIN(m.id)`)
|
|
301
|
+
.all(account, agent.id);
|
|
302
|
+
// The agent's own posts never count as unread, as in list_channels.
|
|
303
|
+
const posts = db
|
|
304
|
+
.prepare(`SELECT c.name, COUNT(*) AS n FROM agents a
|
|
305
|
+
JOIN channel_reads r ON r.agent_id = a.id AND r.joined = 1
|
|
306
|
+
JOIN channels c ON c.id = r.channel_id AND c.account_id = a.account_id
|
|
307
|
+
JOIN channel_posts p ON p.channel_id = c.id AND p.id > r.last_read_id AND p.sender_id <> a.id
|
|
308
|
+
WHERE a.account_id = ? AND a.id = ? AND a.removed_at IS NULL GROUP BY c.id ORDER BY MIN(p.id)`)
|
|
309
|
+
.all(account, agent.id);
|
|
310
|
+
const mentions = db
|
|
311
|
+
.prepare(`SELECT c.name, COUNT(*) AS n FROM agents a
|
|
312
|
+
JOIN mentions x ON x.agent_id = a.id AND x.read_at IS NULL
|
|
313
|
+
JOIN channel_posts p ON p.id = x.post_id
|
|
314
|
+
JOIN channels c ON c.id = p.channel_id AND c.account_id = a.account_id
|
|
315
|
+
WHERE a.account_id = ? AND a.id = ? AND a.removed_at IS NULL GROUP BY c.id ORDER BY MIN(p.id)`)
|
|
316
|
+
.all(account, agent.id);
|
|
317
|
+
return {
|
|
318
|
+
count: messages.reduce((sum, r) => sum + r.n, 0),
|
|
319
|
+
senders: messages.map((r) => r.sender),
|
|
320
|
+
posts: posts.reduce((sum, r) => sum + r.n, 0),
|
|
321
|
+
channels: posts.map((r) => r.name),
|
|
322
|
+
mentions: mentions.reduce((sum, r) => sum + r.n, 0),
|
|
323
|
+
mentionChannels: mentions.map((r) => r.name),
|
|
324
|
+
};
|
|
325
|
+
})();
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* One page of the account's live agents in name order, as `list_agents` pages them, each with its unread message, post
|
|
329
|
+
* and mention totals counted as `unreadSummary` counts them, and its status line while it is current at `now`. One
|
|
330
|
+
* query for the page, totals only: no senders or channels.
|
|
331
|
+
*/
|
|
332
|
+
export function listAgentsUnread(db, account, limit, offset, now = Date.now()) {
|
|
333
|
+
const rows = db
|
|
334
|
+
.prepare(`SELECT a.name, a.bio, a.last_seen, a.person,
|
|
335
|
+
CASE WHEN a.person = 0 AND a.status_at > ? THEN a.status END AS status,
|
|
336
|
+
(SELECT COUNT(*) FROM messages m WHERE m.recipient_id = a.id AND m.read_at IS NULL) AS messages,
|
|
337
|
+
(SELECT COUNT(*) FROM channel_reads r
|
|
338
|
+
JOIN channels c ON c.id = r.channel_id AND c.account_id = a.account_id
|
|
339
|
+
JOIN channel_posts p ON p.channel_id = c.id AND p.id > r.last_read_id AND p.sender_id <> a.id
|
|
340
|
+
WHERE r.agent_id = a.id AND r.joined = 1) AS posts,
|
|
341
|
+
(SELECT COUNT(*) FROM mentions x
|
|
342
|
+
JOIN channel_posts p ON p.id = x.post_id
|
|
343
|
+
JOIN channels c ON c.id = p.channel_id AND c.account_id = a.account_id
|
|
344
|
+
WHERE x.agent_id = a.id AND x.read_at IS NULL) AS mentions
|
|
345
|
+
FROM agents a WHERE a.account_id = ? AND a.removed_at IS NULL ORDER BY a.name LIMIT ? OFFSET ?`)
|
|
346
|
+
.all(now - STATUS_TTL_MS, account, limit, offset);
|
|
347
|
+
return rows.map((r) => ({
|
|
348
|
+
name: r.name,
|
|
349
|
+
bio: r.bio,
|
|
350
|
+
lastSeen: new Date(r.last_seen).toISOString(),
|
|
351
|
+
person: r.person === 1,
|
|
352
|
+
status: r.status,
|
|
353
|
+
unreadMessages: r.messages,
|
|
354
|
+
unreadPosts: r.posts,
|
|
355
|
+
unreadMentions: r.mentions,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* One page of the account's messages, oldest first, for the account rather than for one agent. A broadcast is one row
|
|
360
|
+
* per recipient, so it appears once per recipient. A message to a removed agent shows the name it had. With `unreadOnly`,
|
|
361
|
+
* only the messages to an agent, not to a person, whose recipient has not read them: a person has no `read_inbox`, so
|
|
362
|
+
* every message to one would stay among them for good. `starting` is read from the message's own webhook deliveries,
|
|
363
|
+
* found through `webhook_deliveries_delivered_by_message`. Reads only, so nothing is marked read and no delivery is touched.
|
|
364
|
+
*/
|
|
365
|
+
export function listAccountMessages(db, account, limit, offset, unreadOnly = false) {
|
|
366
|
+
const rows = db
|
|
367
|
+
.prepare(`SELECT m.id, m.sender, CASE WHEN a.removed_at IS NULL THEN a.name ELSE a.removed_name END AS recipient,
|
|
368
|
+
m.body, m.broadcast, m.created_at, m.read_at, a.person,
|
|
369
|
+
EXISTS (SELECT 1 FROM webhook_deliveries d
|
|
370
|
+
WHERE d.message_id = m.id AND d.delivered_at IS NOT NULL) AS delivered
|
|
371
|
+
FROM messages m JOIN agents a ON a.id = m.recipient_id
|
|
372
|
+
WHERE a.account_id = ?${unreadOnly ? " AND a.person = 0 AND m.read_at IS NULL" : ""} ORDER BY m.id LIMIT ? OFFSET ?`)
|
|
373
|
+
.all(account, Math.min(limit, MAX_INBOX_LIMIT), offset);
|
|
374
|
+
return rows.map((r) => ({
|
|
375
|
+
id: r.id,
|
|
376
|
+
from: r.sender,
|
|
377
|
+
to: r.recipient,
|
|
378
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
379
|
+
body: r.body,
|
|
380
|
+
broadcast: r.broadcast === 1,
|
|
381
|
+
read: r.read_at !== null,
|
|
382
|
+
starting: r.read_at === null && r.person === 0 && r.delivered === 1,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Deletes the account's messages between `first` and `second`, either way and in any case: exactly the rows
|
|
387
|
+
* `listAccountMessages` lists with that `from` and `to`, broadcasts and messages to a removed agent's old name included.
|
|
388
|
+
* Their webhook deliveries go with them. Returns how many were deleted.
|
|
389
|
+
*/
|
|
390
|
+
export function deleteConversation(db, account, first, second) {
|
|
391
|
+
// One statement per direction, so each reads only the messages_by_sender rows for that pair, not the account's history.
|
|
392
|
+
const fromTo = db.prepare(`DELETE FROM messages
|
|
393
|
+
WHERE recipient_id IN (SELECT id FROM agents WHERE account_id = ?
|
|
394
|
+
AND (CASE WHEN removed_at IS NULL THEN name ELSE removed_name END) = ? COLLATE NOCASE)
|
|
395
|
+
AND sender = ? COLLATE NOCASE`);
|
|
396
|
+
return db.transaction(() => {
|
|
397
|
+
const there = fromTo.run(account, second, first).changes;
|
|
398
|
+
// A conversation with oneself is one direction; running it twice would only find nothing the second time.
|
|
399
|
+
return there + fromTo.run(account, first, second).changes;
|
|
400
|
+
}).immediate();
|
|
401
|
+
}
|
|
402
|
+
export const MAX_SEARCH_LENGTH = 200;
|
|
403
|
+
// A date, or a date and time (to the millisecond) with an optional offset; a time with no offset is UTC, like a date
|
|
404
|
+
// alone.
|
|
405
|
+
const ISO_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
406
|
+
function parseTime(value, field) {
|
|
407
|
+
const m = ISO_TIME.exec(value);
|
|
408
|
+
const ms = m ? Date.parse(value.includes("T") && !m[4] ? `${value}Z` : value) : NaN;
|
|
409
|
+
// Date.parse rolls 2026-02-30 over to March, so the date has to come back unchanged. setUTCFullYear keeps years 0-99,
|
|
410
|
+
// which Date.UTC would read as 1900-1999.
|
|
411
|
+
const day = m && new Date(new Date(0).setUTCFullYear(+m[1], +m[2] - 1, +m[3]));
|
|
412
|
+
if (!m || Number.isNaN(ms) || day.getUTCMonth() !== +m[2] - 1 || day.getUTCDate() !== +m[3]) {
|
|
413
|
+
throw new ToolError(`Invalid ${field}: use an ISO 8601 time such as 2026-10-20 or 2026-10-20T12:00:00Z.`);
|
|
414
|
+
}
|
|
415
|
+
return ms;
|
|
416
|
+
}
|
|
417
|
+
/** Throws a ToolError unless `text` and `filters` are valid search arguments; otherwise the SQL filter they make. */
|
|
418
|
+
function searchQuery(text, filters) {
|
|
419
|
+
if (text.length === 0 || text.length > 2 * MAX_SEARCH_LENGTH || countCodePoints(text) > MAX_SEARCH_LENGTH) {
|
|
420
|
+
throw new ToolError(`Search text must be 1 to ${MAX_SEARCH_LENGTH} characters.`);
|
|
421
|
+
}
|
|
422
|
+
const { channel, from } = filters;
|
|
423
|
+
if (channel !== undefined && !NAME_PATTERN.test(channel)) {
|
|
424
|
+
throw new ToolError("Invalid channel name: use 1-32 characters from letters, digits, '-' and '_'.");
|
|
425
|
+
}
|
|
426
|
+
const since = filters.since === undefined ? null : parseTime(filters.since, "since");
|
|
427
|
+
const until = filters.until === undefined ? null : parseTime(filters.until, "until");
|
|
428
|
+
const pattern = `%${text.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
429
|
+
const where = (t) => `${t}.body LIKE ? ESCAPE '\\' AND (? IS NULL OR ${t}.sender = ? COLLATE NOCASE)
|
|
430
|
+
AND (? IS NULL OR ${t}.created_at >= ?) AND (? IS NULL OR ${t}.created_at < ?)`;
|
|
431
|
+
return { channel, where, params: [pattern, from ?? null, from ?? null, since, since, until, until] };
|
|
432
|
+
}
|
|
433
|
+
/** The posts half of a search, newest first, scoped to the account's channels; its first placeholder is the account. */
|
|
434
|
+
const searchPosts = ({ channel, where }) => `SELECT 1 AS is_post, p.id, p.sender, p.body, 0 AS broadcast, p.created_at, c.name AS channel, NULL AS recipient, NULL AS read_at,
|
|
435
|
+
NULL AS in_reply_to
|
|
436
|
+
FROM channel_posts p JOIN channels c ON c.id = p.channel_id AND c.account_id = ?
|
|
437
|
+
WHERE ${channel === undefined ? "" : "c.name = ? AND "}${where("p")}`;
|
|
438
|
+
// Newest first; at the same millisecond a post before a message, and each kind newest id first.
|
|
439
|
+
const SEARCH_ORDER = "ORDER BY created_at DESC, is_post DESC, id DESC LIMIT ? OFFSET ?";
|
|
440
|
+
/** A post row of a search page, as both searches return it. */
|
|
441
|
+
const toPost = (r) => ({
|
|
442
|
+
kind: "post",
|
|
443
|
+
channel: r.channel,
|
|
444
|
+
id: r.id,
|
|
445
|
+
from: r.sender,
|
|
446
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
447
|
+
body: r.body,
|
|
448
|
+
});
|
|
449
|
+
/**
|
|
450
|
+
* One page of what the caller could already read whose body contains `text` (ASCII letters in any case), newest
|
|
451
|
+
* first: the messages it received and the posts in its account's channels. `reader` is the caller's registration, so
|
|
452
|
+
* a later agent with a removed agent's name finds none of its messages. Reads only: nothing is marked read and no
|
|
453
|
+
* channel read position moves.
|
|
454
|
+
*/
|
|
455
|
+
export function search(db, account, reader, text, filters = {}, limit = DEFAULT_INBOX_LIMIT, offset = 0) {
|
|
456
|
+
const query = searchQuery(text, filters);
|
|
457
|
+
const { channel, where, params } = query;
|
|
458
|
+
return db.transaction(() => {
|
|
459
|
+
const agent = db
|
|
460
|
+
.prepare("SELECT id FROM agents WHERE account_id = ? AND id = ? AND registered_at = ? AND instance IS ? AND removed_at IS NULL")
|
|
461
|
+
.get(account, reader.id, reader.registeredAt, reader.instance);
|
|
462
|
+
if (!agent)
|
|
463
|
+
return [];
|
|
464
|
+
const rows = db
|
|
465
|
+
.prepare(`SELECT * FROM (
|
|
466
|
+
${channel === undefined ? `SELECT 0 AS is_post, m.id, m.sender, m.body, m.broadcast, m.created_at, NULL AS channel, NULL AS recipient, NULL AS read_at, m.in_reply_to FROM messages m WHERE m.recipient_id = ? AND ${where("m")} UNION ALL` : ""}
|
|
467
|
+
${searchPosts(query)})
|
|
468
|
+
${SEARCH_ORDER}`)
|
|
469
|
+
.all(...(channel === undefined ? [agent.id, ...params] : []), account, ...(channel === undefined ? [] : [channel]), ...params, Math.min(limit, MAX_INBOX_LIMIT), offset);
|
|
470
|
+
return rows.map((r) => r.is_post === 1
|
|
471
|
+
? toPost(r)
|
|
472
|
+
: {
|
|
473
|
+
kind: "message",
|
|
474
|
+
id: r.id,
|
|
475
|
+
inReplyTo: r.in_reply_to,
|
|
476
|
+
from: r.sender,
|
|
477
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
478
|
+
body: r.body,
|
|
479
|
+
broadcast: r.broadcast === 1,
|
|
480
|
+
});
|
|
481
|
+
})();
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* `search` for the account rather than for one agent: one page of the account's messages, to any of its agents, and
|
|
485
|
+
* its channels' posts whose body contains `text`, newest first, with the same arguments and errors. A message is
|
|
486
|
+
* scoped through its recipient's account, as in `listAccountMessages`, and a broadcast appears once per recipient.
|
|
487
|
+
* Reads only: nothing is marked read and no channel read position moves.
|
|
488
|
+
*/
|
|
489
|
+
export function searchAccount(db, account, text, filters = {}, limit = DEFAULT_INBOX_LIMIT, offset = 0) {
|
|
490
|
+
const query = searchQuery(text, filters);
|
|
491
|
+
const { channel, where, params } = query;
|
|
492
|
+
// The messages half selects in_reply_to only to match searchPosts' columns; GET /api/search does not return it.
|
|
493
|
+
const rows = db
|
|
494
|
+
.prepare(`SELECT * FROM (
|
|
495
|
+
${channel === undefined ? `SELECT 0 AS is_post, m.id, m.sender, m.body, m.broadcast, m.created_at, NULL AS channel,
|
|
496
|
+
CASE WHEN a.removed_at IS NULL THEN a.name ELSE a.removed_name END AS recipient, m.read_at, m.in_reply_to
|
|
497
|
+
FROM messages m JOIN agents a ON a.id = m.recipient_id AND a.account_id = ? WHERE ${where("m")} UNION ALL` : ""}
|
|
498
|
+
${searchPosts(query)})
|
|
499
|
+
${SEARCH_ORDER}`)
|
|
500
|
+
.all(...(channel === undefined ? [account, ...params] : []), account, ...(channel === undefined ? [] : [channel]), ...params, Math.min(limit, MAX_INBOX_LIMIT), offset);
|
|
501
|
+
return rows.map((r) => r.is_post === 1
|
|
502
|
+
? toPost(r)
|
|
503
|
+
: {
|
|
504
|
+
kind: "message",
|
|
505
|
+
id: r.id,
|
|
506
|
+
from: r.sender,
|
|
507
|
+
to: r.recipient,
|
|
508
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
509
|
+
body: r.body,
|
|
510
|
+
broadcast: r.broadcast === 1,
|
|
511
|
+
read: r.read_at !== null,
|
|
512
|
+
});
|
|
513
|
+
}
|