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/approve.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { STYLE } from "./dashboard.js";
|
|
3
|
+
import { agentNameBase, isLoopback, isMetadataUrl } from "./oauth.js";
|
|
4
|
+
// The page a person approves an MCP client on, served at the authorization endpoint of `serve --public-url` without a
|
|
5
|
+
// token, in the dashboard's look. It first asks GET /oauth/signin whether this browser is signed in to the dashboard
|
|
6
|
+
// (the sign-in cookie, which no page script can read). Signed in, it says who approves ("Approving as maria"), hides the
|
|
7
|
+
// token field and offers an agent name no live agent of the account has, made from the client's name, so one click
|
|
8
|
+
// approves; POST /oauth/approve then carries no token, the server signs it in by the cookie, and refuses it (409) if the
|
|
9
|
+
// cookie now signs in as someone other than the page showed. Only under an https public URL; elsewhere GET /oauth/signin
|
|
10
|
+
// is 404 and the page stays signed out. Not signed in, it links
|
|
11
|
+
// to the dashboard in a new tab, checks again when the page regains focus or on "Check again", and meanwhile asks for a
|
|
12
|
+
// full or person token of the account, sent once in the Authorization header and kept only in the form field, never in
|
|
13
|
+
// storage. On approval it goes to the address the server answers with, the client's redirect URI with a code; refusing
|
|
14
|
+
// goes to the redirect URI with error=access_denied. "This client registers its own agents", when ticked, sends
|
|
15
|
+
// `registers: true` in place of the agent name, which is then neither required nor sent. Client-supplied text is put on
|
|
16
|
+
// the page HTML-escaped by the server, never as markup, and the server's answers with textContent.
|
|
17
|
+
const PAGE_STYLE = `${STYLE}
|
|
18
|
+
main { margin: 10vh auto var(--s4); width: min(32rem, 100% - 2 * var(--s4)); display: flex; flex-direction: column; gap: var(--s3); }
|
|
19
|
+
main h1 { display: flex; align-items: center; gap: var(--s2); }
|
|
20
|
+
main h2, main p { margin: 0; }
|
|
21
|
+
.client { font-family: var(--font-mono); font-weight: 600; overflow-wrap: anywhere; }
|
|
22
|
+
.warn { padding: var(--s2) var(--s3); background: var(--error-bg); color: var(--error); border-radius: var(--radius); }
|
|
23
|
+
#approve { display: flex; flex-direction: column; gap: var(--s1); }
|
|
24
|
+
#approve input:not([type="checkbox"]) { width: 100%; padding: var(--s2); margin-bottom: var(--s2); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
25
|
+
.actions { display: flex; gap: var(--s2); }
|
|
26
|
+
#approve-yes { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
|
|
27
|
+
#approve-yes:hover:enabled { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
|
28
|
+
#approve-note.error { color: var(--error); }
|
|
29
|
+
#signed-in { font-weight: 600; }
|
|
30
|
+
.choice { display: flex; align-items: center; gap: var(--s2); margin-bottom: var(--s2); }
|
|
31
|
+
`;
|
|
32
|
+
const SCRIPT = `
|
|
33
|
+
"use strict";
|
|
34
|
+
const TIMEOUT_MS = 15000;
|
|
35
|
+
const form = document.getElementById("approve");
|
|
36
|
+
const token = document.getElementById("approve-token");
|
|
37
|
+
const tokenField = document.getElementById("token-field");
|
|
38
|
+
const signedIn = document.getElementById("signed-in");
|
|
39
|
+
const signedOut = document.getElementById("signed-out");
|
|
40
|
+
const again = document.getElementById("signin-again");
|
|
41
|
+
const agent = document.getElementById("approve-agent");
|
|
42
|
+
const agentField = document.getElementById("agent-field");
|
|
43
|
+
const registers = document.getElementById("approve-registers");
|
|
44
|
+
const yes = document.getElementById("approve-yes");
|
|
45
|
+
const no = document.getElementById("approve-no");
|
|
46
|
+
const note = document.getElementById("approve-note");
|
|
47
|
+
let busy = false;
|
|
48
|
+
// Whether the last GET /oauth/signin found a sign-in, who it signs in as ({ account, person? }, sent back with an approval
|
|
49
|
+
// so the server refuses one whose sign-in changed since), and the agent name it offered, if the field still holds it as
|
|
50
|
+
// offered: any edit by the person clears it, so a name they type, even the same one, is theirs.
|
|
51
|
+
let cookie = false;
|
|
52
|
+
let who;
|
|
53
|
+
let suggested;
|
|
54
|
+
// Bumped by each check, so only the newest check's answer changes the page.
|
|
55
|
+
let checks = 0;
|
|
56
|
+
function say(text, error) {
|
|
57
|
+
note.textContent = text;
|
|
58
|
+
note.classList.toggle("error", error);
|
|
59
|
+
}
|
|
60
|
+
// Shows the page signed in, saying \`text\`, or, for undefined, signed out with the token field.
|
|
61
|
+
function show(text) {
|
|
62
|
+
cookie = text !== undefined;
|
|
63
|
+
if (!cookie) who = undefined;
|
|
64
|
+
signedIn.textContent = cookie ? text : "";
|
|
65
|
+
signedIn.hidden = !cookie;
|
|
66
|
+
signedOut.hidden = cookie;
|
|
67
|
+
tokenField.hidden = cookie;
|
|
68
|
+
token.required = !cookie;
|
|
69
|
+
if (cookie) token.value = "";
|
|
70
|
+
}
|
|
71
|
+
// Asks GET /oauth/signin, relative to this page as approve is, who this browser is signed in to the dashboard as. An
|
|
72
|
+
// answer that comes after a newer check, or while an approval is in flight, is dropped; no answer leaves the page as it is.
|
|
73
|
+
async function check() {
|
|
74
|
+
const n = ++checks;
|
|
75
|
+
const ac = new AbortController();
|
|
76
|
+
const deadline = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
77
|
+
let res;
|
|
78
|
+
let answer;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch("signin?" + new URLSearchParams({ base: form.dataset.base }), {
|
|
81
|
+
cache: "no-store",
|
|
82
|
+
credentials: "same-origin",
|
|
83
|
+
referrerPolicy: "no-referrer",
|
|
84
|
+
signal: ac.signal,
|
|
85
|
+
});
|
|
86
|
+
answer = await res.json().catch(() => undefined);
|
|
87
|
+
} catch {
|
|
88
|
+
res = undefined;
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(deadline);
|
|
91
|
+
}
|
|
92
|
+
if (n !== checks || busy || !res) return;
|
|
93
|
+
if (res.ok && answer && typeof answer.account === "string") {
|
|
94
|
+
who = typeof answer.person === "string" ? { account: answer.account, person: answer.person } : { account: answer.account };
|
|
95
|
+
show(who.person !== undefined ? "Approving as " + who.person : "Approving with a full token of account " + who.account);
|
|
96
|
+
if (typeof answer.name === "string" && (agent.value.trim() === "" || agent.value.trim() === suggested)) {
|
|
97
|
+
agent.value = answer.name;
|
|
98
|
+
suggested = answer.name;
|
|
99
|
+
}
|
|
100
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
101
|
+
show(undefined);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
form.addEventListener("submit", async (event) => {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
if (busy) return;
|
|
107
|
+
busy = true;
|
|
108
|
+
checks++;
|
|
109
|
+
yes.disabled = true;
|
|
110
|
+
say("Approving…", false);
|
|
111
|
+
const ac = new AbortController();
|
|
112
|
+
const deadline = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
113
|
+
const signed = cookie;
|
|
114
|
+
const as = who;
|
|
115
|
+
const own = registers.checked;
|
|
116
|
+
const name = agent.value.trim();
|
|
117
|
+
let res;
|
|
118
|
+
let answer;
|
|
119
|
+
try {
|
|
120
|
+
// Relative to this page, /oauth/authorize under the public URL, so it stays under the public URL's path. Signed in,
|
|
121
|
+
// it sends no token, and the server reads the sign-in cookie; otherwise only the typed token, and no cookie.
|
|
122
|
+
res = await fetch("approve", {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: signed ? { "Content-Type": "application/json" } : { Authorization: "Bearer " + token.value.trim(), "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
request: form.dataset.request,
|
|
127
|
+
state: form.dataset.state === undefined ? null : form.dataset.state,
|
|
128
|
+
...(own ? { registers: true } : { agent: name, fresh: suggested !== undefined && name === suggested }),
|
|
129
|
+
...(signed && { as }),
|
|
130
|
+
}),
|
|
131
|
+
cache: "no-store",
|
|
132
|
+
credentials: signed ? "same-origin" : "omit",
|
|
133
|
+
referrerPolicy: "no-referrer",
|
|
134
|
+
signal: ac.signal,
|
|
135
|
+
});
|
|
136
|
+
answer = await res.json().catch(() => undefined);
|
|
137
|
+
} catch {
|
|
138
|
+
res = undefined;
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(deadline);
|
|
141
|
+
}
|
|
142
|
+
busy = false;
|
|
143
|
+
yes.disabled = false;
|
|
144
|
+
if (res && res.ok && answer && typeof answer.location === "string") {
|
|
145
|
+
token.value = "";
|
|
146
|
+
say("Approved. Returning to the client…", false);
|
|
147
|
+
return location.assign(answer.location);
|
|
148
|
+
}
|
|
149
|
+
if (!res) return say("The server did not answer. Try again.", true);
|
|
150
|
+
if (signed && (res.status === 401 || res.status === 403)) {
|
|
151
|
+
show(undefined);
|
|
152
|
+
return say("This browser is no longer signed in. Sign in on the dashboard and check again, or enter a full or person token of the account.", true);
|
|
153
|
+
}
|
|
154
|
+
// The browser's sign-in changed since the page showed it: show who it is now before anyone approves.
|
|
155
|
+
if (signed && res.status === 409) {
|
|
156
|
+
say(answer && typeof answer.message === "string" ? answer.message : "This browser is now signed in as someone else.", true);
|
|
157
|
+
return check();
|
|
158
|
+
}
|
|
159
|
+
if (res.status === 401) return say("The server refused that token. Enter a full or person token of the account.", true);
|
|
160
|
+
if (res.status === 403) return say("Only a full or person token can approve a client; agent and read-only tokens cannot.", true);
|
|
161
|
+
say(answer && typeof answer.message === "string" ? answer.message : "The server could not approve this (" + res.status + "). Try again.", true);
|
|
162
|
+
});
|
|
163
|
+
no.addEventListener("click", () => location.assign(no.dataset.location));
|
|
164
|
+
agent.addEventListener("input", () => {
|
|
165
|
+
suggested = undefined;
|
|
166
|
+
});
|
|
167
|
+
// Ticked, the client names its own agents, so the agent name is hidden, not required and not sent.
|
|
168
|
+
registers.addEventListener("change", () => {
|
|
169
|
+
agentField.hidden = registers.checked;
|
|
170
|
+
agent.required = !registers.checked;
|
|
171
|
+
});
|
|
172
|
+
// Without the sign-in (no https public URL) the page only takes a typed token.
|
|
173
|
+
if (form.dataset.signin !== undefined) {
|
|
174
|
+
again.addEventListener("click", () => check());
|
|
175
|
+
// Coming back from signing in on the dashboard in another tab checks again.
|
|
176
|
+
addEventListener("focus", () => check());
|
|
177
|
+
check();
|
|
178
|
+
}
|
|
179
|
+
`;
|
|
180
|
+
const hash = (text) => `'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`;
|
|
181
|
+
/** Headers for the approval and error pages: the inline style and script run by hash, and nothing frames them. */
|
|
182
|
+
export const APPROVE_HEADERS = {
|
|
183
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
184
|
+
"Content-Security-Policy": [
|
|
185
|
+
"default-src 'none'",
|
|
186
|
+
`script-src ${hash(SCRIPT)}`,
|
|
187
|
+
`style-src ${hash(PAGE_STYLE)}`,
|
|
188
|
+
"connect-src 'self'",
|
|
189
|
+
"font-src 'self'",
|
|
190
|
+
"base-uri 'none'",
|
|
191
|
+
"form-action 'none'",
|
|
192
|
+
"frame-ancestors 'none'",
|
|
193
|
+
].join("; "),
|
|
194
|
+
"X-Content-Type-Options": "nosniff",
|
|
195
|
+
"Referrer-Policy": "no-referrer",
|
|
196
|
+
"Cache-Control": "no-store",
|
|
197
|
+
};
|
|
198
|
+
const escape = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
199
|
+
const MARK = '<svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path class="bubble" d="M8 4h16a6 6 0 0 1 6 6v8a6 6 0 0 1-6 6H14l-7 5v-5.2A6 6 0 0 1 2 18v-8a6 6 0 0 1 6-6z"/><path class="lines" d="M9 12.5h14v2.5H9zm0 5h9v2.5H9z"/></svg>';
|
|
200
|
+
const page = (body) => `<!doctype html>
|
|
201
|
+
<html lang="en">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="utf-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
205
|
+
<title>Connect a client · message-cp</title>
|
|
206
|
+
<style>${PAGE_STYLE}</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<main>
|
|
210
|
+
<h1>${MARK}message-cp</h1>
|
|
211
|
+
${body}
|
|
212
|
+
</main>
|
|
213
|
+
</body>
|
|
214
|
+
</html>
|
|
215
|
+
`;
|
|
216
|
+
/** The page shown instead of the approval page for a request that cannot be sent back to its client. */
|
|
217
|
+
export const errorPage = (message) => page(`<p role="alert">${escape(message)}</p>`);
|
|
218
|
+
/**
|
|
219
|
+
* The approval page for `a`. `deny` is where refusing goes: the redirect URI with error=access_denied and the state.
|
|
220
|
+
* It shows the client's name as it registered it, or as its metadata document gives it with the host of that document's
|
|
221
|
+
* URL, and the redirect URI's host, and warns when that host is this machine.
|
|
222
|
+
*/
|
|
223
|
+
export function approvePage(a, deny, signin) {
|
|
224
|
+
const host = new URL(a.redirectUri).host;
|
|
225
|
+
const loopback = isLoopback(new URL(a.redirectUri));
|
|
226
|
+
const name = a.client.clientName ? escape(a.client.clientName) : "An unnamed client";
|
|
227
|
+
// A client known by its metadata document's URL is named with that URL's host, which this server fetched it from.
|
|
228
|
+
const from = isMetadataUrl(a.client.clientId) ? new URL(a.client.clientId).host : undefined;
|
|
229
|
+
const who = `<span class="client">${name}</span>${from === undefined ? "" : `, from <span class="client">${escape(from)}</span>,`}`;
|
|
230
|
+
const state = a.state === null ? "" : ` data-state="${escape(a.state)}"`;
|
|
231
|
+
return page(`<h2>Connect a client</h2>
|
|
232
|
+
<p>${who} asks to act as an agent of your account. Once you approve, it is sent to <span class="client">${escape(host)}</span> with a code it exchanges for an agent token.</p>
|
|
233
|
+
${loopback
|
|
234
|
+
? `<p class="warn">That address is on the machine the client runs on. Approve only if you just started connecting from a client on this machine: any program running there could have asked.</p>`
|
|
235
|
+
: ""}
|
|
236
|
+
<p class="dim">${from === undefined ? "The name is the client's own; this server has not checked it." : `The name is the client's own; this server has checked only that its description is at ${escape(from)}.`}</p>
|
|
237
|
+
<form id="approve" data-request="${escape(a.request)}"${state} data-base="${escape(agentNameBase(a.client.clientName))}"${signin ? " data-signin" : ""}>
|
|
238
|
+
<p id="signed-in" role="status" hidden></p>
|
|
239
|
+
<div id="signed-out"${signin ? "" : " hidden"}>
|
|
240
|
+
<p>This browser is not signed in to message-cp. <a href="../" target="_blank" rel="noopener">Sign in on the dashboard</a> in a new tab, which leaves this page open, then come back here or <button id="signin-again" type="button">Check again</button>. Or enter a token below.</p>
|
|
241
|
+
</div>
|
|
242
|
+
<div id="token-field">
|
|
243
|
+
<label for="approve-token">Full or person token of the account</label>
|
|
244
|
+
<input id="approve-token" type="password" autocomplete="off" required>
|
|
245
|
+
</div>
|
|
246
|
+
<div id="agent-field">
|
|
247
|
+
<label for="approve-agent">Agent name</label>
|
|
248
|
+
<input id="approve-agent" type="text" autocomplete="off" required maxlength="32" pattern="[A-Za-z0-9_\\-]{1,32}" placeholder="for example claude-desktop">
|
|
249
|
+
<p class="dim">A new name makes that agent; an existing agent's name, typed in, connects the client as it, leaving its key and sessions alone. A suggested name is never an existing agent's.</p>
|
|
250
|
+
</div>
|
|
251
|
+
<label class="choice"><input id="approve-registers" type="checkbox"> This client registers its own agents</label>
|
|
252
|
+
<p class="dim">Ticked, each session of the client registers its own new name, and acts again only as the agents it registered, never as another agent or a person.</p>
|
|
253
|
+
<div class="actions">
|
|
254
|
+
<button id="approve-yes" type="submit">Approve</button>
|
|
255
|
+
<button id="approve-no" type="button" data-location="${escape(deny)}">Refuse</button>
|
|
256
|
+
</div>
|
|
257
|
+
<p id="approve-note" class="note" role="status"></p>
|
|
258
|
+
</form>
|
|
259
|
+
<script>${SCRIPT}</script>`);
|
|
260
|
+
}
|
package/dist/channels.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { NAME_PATTERN, NOT_REGISTERED, ToolError, actingPerson, nameOfIdentity } from "./agents.js";
|
|
2
|
+
import { DEFAULT_INBOX_LIMIT, MAX_INBOX_LIMIT, validateBody } from "./messages.js";
|
|
3
|
+
export const DEFAULT_CHANNEL_LIMIT = DEFAULT_INBOX_LIMIT;
|
|
4
|
+
export const MAX_CHANNEL_LIMIT = MAX_INBOX_LIMIT;
|
|
5
|
+
const toPost = (r) => ({
|
|
6
|
+
id: r.id,
|
|
7
|
+
from: r.sender,
|
|
8
|
+
sentAt: new Date(r.created_at).toISOString(),
|
|
9
|
+
body: r.body,
|
|
10
|
+
});
|
|
11
|
+
// The agent-name pattern; unlike agent names, `all` is an ordinary channel name.
|
|
12
|
+
function validateChannelName(name) {
|
|
13
|
+
if (!NAME_PATTERN.test(name)) {
|
|
14
|
+
throw new ToolError("Invalid channel name: use 1-32 characters from letters, digits, '-' and '_'.");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function findChannel(db, account, name) {
|
|
18
|
+
validateChannelName(name);
|
|
19
|
+
const row = db.prepare("SELECT id, name FROM channels WHERE account_id = ? AND name = ?").get(account, name);
|
|
20
|
+
if (!row)
|
|
21
|
+
throw new ToolError(`No channel named '${name}'.`);
|
|
22
|
+
return row;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Throws unless `member`'s registration is still live. Called inside each tool's transaction, so an agent removed by
|
|
26
|
+
* another process after the session last checked can neither post nor read.
|
|
27
|
+
*/
|
|
28
|
+
function checkLive(db, account, member) {
|
|
29
|
+
if (nameOfIdentity(db, account, member) === undefined)
|
|
30
|
+
throw new ToolError(NOT_REGISTERED);
|
|
31
|
+
}
|
|
32
|
+
/** Moves `agent`'s read position in `channel` forward to `postId`; never back. */
|
|
33
|
+
function advance(db, agent, channel, postId) {
|
|
34
|
+
db.prepare(`INSERT INTO channel_reads (agent_id, channel_id, last_read_id) VALUES (?, ?, ?)
|
|
35
|
+
ON CONFLICT(agent_id, channel_id) DO UPDATE SET last_read_id = MAX(last_read_id, excluded.last_read_id)`).run(agent, channel, postId);
|
|
36
|
+
}
|
|
37
|
+
/** Creates the account's channel `channel` if it does not exist yet, and returns it. */
|
|
38
|
+
function ensureChannel(db, account, channel, now) {
|
|
39
|
+
db.prepare("INSERT INTO channels (account_id, name, created_at) VALUES (?, ?, ?) ON CONFLICT(account_id, name) DO NOTHING")
|
|
40
|
+
.run(account, channel, now);
|
|
41
|
+
return findChannel(db, account, channel);
|
|
42
|
+
}
|
|
43
|
+
// `@` and a name, not inside a longer word (`a@bob`) and not cut from a longer name.
|
|
44
|
+
const MENTION = /(?<![A-Za-z0-9_-])@([A-Za-z0-9_-]{1,32})(?![A-Za-z0-9_-])/g;
|
|
45
|
+
/** The distinct names `body` mentions as `@<name>`, lower-cased since names compare without case, `all` left out. */
|
|
46
|
+
export function mentionedNames(body) {
|
|
47
|
+
const names = new Set();
|
|
48
|
+
for (const [, name] of body.matchAll(MENTION))
|
|
49
|
+
names.add(name.toLowerCase());
|
|
50
|
+
names.delete("all");
|
|
51
|
+
return [...names];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Appends a post to the account's channel `channel`, creating the channel if this is its first post. If the member had
|
|
55
|
+
* read everything before it, its read position moves past its own post, so the channel stays read for it. Each live
|
|
56
|
+
* agent of the account the post names as `@<name>`, but the member, gets a mention, whether or not it has joined.
|
|
57
|
+
*/
|
|
58
|
+
export function post(db, account, member, channel, body) {
|
|
59
|
+
validateChannelName(channel);
|
|
60
|
+
validateBody(body, "Post");
|
|
61
|
+
return db
|
|
62
|
+
.transaction(() => {
|
|
63
|
+
checkLive(db, account, member);
|
|
64
|
+
// Posting clears the poster's status line; checkLive has just found its row live.
|
|
65
|
+
db.prepare("UPDATE agents SET status = NULL, status_at = NULL WHERE id = ? AND status IS NOT NULL").run(member.id);
|
|
66
|
+
return append(db, account, member, channel, body);
|
|
67
|
+
})
|
|
68
|
+
.immediate();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* `post` from the account's live person `as`, under the person's stored name. Undefined, with nothing stored, if `as`
|
|
72
|
+
* is not a live person of the account, so an agent's name, a removed one and an unknown one are alike. With `only`, a
|
|
73
|
+
* person token's person's row id, throws `WrongPersonError`, storing nothing, unless `as` is that row.
|
|
74
|
+
*/
|
|
75
|
+
export function postAsPerson(db, account, as, channel, body, only) {
|
|
76
|
+
validateChannelName(channel);
|
|
77
|
+
validateBody(body, "Post");
|
|
78
|
+
return db
|
|
79
|
+
.transaction(() => {
|
|
80
|
+
const person = actingPerson(db, account, as, only);
|
|
81
|
+
return person && append(db, account, person, channel, body);
|
|
82
|
+
})
|
|
83
|
+
.immediate();
|
|
84
|
+
}
|
|
85
|
+
/** The body of `post` once the sender is known to be live: `member` is its row in `agents` and its stored name. */
|
|
86
|
+
function append(db, account, member, channel, body) {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const found = ensureChannel(db, account, channel, now);
|
|
89
|
+
const last = db.prepare("SELECT MAX(id) AS id FROM channel_posts WHERE channel_id = ?").get(found.id);
|
|
90
|
+
const read = db
|
|
91
|
+
.prepare("SELECT last_read_id FROM channel_reads WHERE agent_id = ? AND channel_id = ?")
|
|
92
|
+
.get(member.id, found.id);
|
|
93
|
+
const id = Number(db
|
|
94
|
+
.prepare("INSERT INTO channel_posts (channel_id, sender_id, sender, body, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
95
|
+
.run(found.id, member.id, member.name, body, now).lastInsertRowid);
|
|
96
|
+
if (last.id === null || (read !== undefined && read.last_read_id >= last.id))
|
|
97
|
+
advance(db, member.id, found.id, id);
|
|
98
|
+
const names = body.includes("@") ? mentionedNames(body) : [];
|
|
99
|
+
if (names.length > 0) {
|
|
100
|
+
const mention = db.prepare(`INSERT INTO mentions (agent_id, post_id)
|
|
101
|
+
SELECT id, ? FROM agents WHERE account_id = ? AND name = ? AND removed_at IS NULL AND id <> ?`);
|
|
102
|
+
for (const name of names)
|
|
103
|
+
mention.run(id, account, name, member.id);
|
|
104
|
+
}
|
|
105
|
+
// Owed to every `#<channel>` webhook of the account, and to every `mention:<agent>` webhook of an agent it mentioned,
|
|
106
|
+
// held for that agent while it is waiting for mail (`markWaiting`), as a direct message is.
|
|
107
|
+
// Mention rows only ever name agents of this account; the account check on `webhooks` says so where it matters.
|
|
108
|
+
db.prepare(`INSERT INTO webhook_deliveries (webhook_id, post_id, created_at)
|
|
109
|
+
SELECT id, ?, ? FROM webhooks WHERE account_id = ? AND event = 'channel' AND name = ? COLLATE NOCASE ORDER BY id`).run(id, now, account, found.name);
|
|
110
|
+
if (names.length > 0) {
|
|
111
|
+
db.prepare(`INSERT INTO webhook_deliveries (webhook_id, post_id, created_at, held_for)
|
|
112
|
+
SELECT webhooks.id, ?, ?, CASE WHEN agents.waiting_until > ? THEN agents.id END FROM mentions
|
|
113
|
+
JOIN agents ON agents.id = mentions.agent_id
|
|
114
|
+
JOIN webhooks ON webhooks.account_id = agents.account_id AND webhooks.event = 'mention' AND webhooks.name = agents.name COLLATE NOCASE
|
|
115
|
+
WHERE mentions.post_id = ? AND webhooks.account_id = ? ORDER BY webhooks.id`).run(id, now, now, id, account);
|
|
116
|
+
}
|
|
117
|
+
return { channel: found.name, id, sentAt: new Date(now).toISOString() };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Joins `member` to the account's channel `channel`, creating the channel if nobody has posted to it yet, so that its
|
|
121
|
+
* unread posts wake the member. A channel the member has never read is joined at its latest post, so its history
|
|
122
|
+
* does not wake it; one it has read before keeps its read position.
|
|
123
|
+
*/
|
|
124
|
+
export function joinChannel(db, account, member, channel) {
|
|
125
|
+
validateChannelName(channel);
|
|
126
|
+
return db
|
|
127
|
+
.transaction(() => {
|
|
128
|
+
checkLive(db, account, member);
|
|
129
|
+
const found = ensureChannel(db, account, channel, Date.now());
|
|
130
|
+
db.prepare(`INSERT INTO channel_reads (agent_id, channel_id, last_read_id, joined)
|
|
131
|
+
SELECT ?, ?, COALESCE(MAX(id), 0), 1 FROM channel_posts WHERE channel_id = ?
|
|
132
|
+
ON CONFLICT(agent_id, channel_id) DO UPDATE SET joined = 1`).run(member.id, found.id, found.id);
|
|
133
|
+
return { channel: found.name, joined: true };
|
|
134
|
+
})
|
|
135
|
+
.immediate();
|
|
136
|
+
}
|
|
137
|
+
/** Leaves the account's channel `channel`, so its posts no longer wake `member`. The read position stays where it was. */
|
|
138
|
+
export function leaveChannel(db, account, member, channel) {
|
|
139
|
+
return db
|
|
140
|
+
.transaction(() => {
|
|
141
|
+
checkLive(db, account, member);
|
|
142
|
+
const found = findChannel(db, account, channel);
|
|
143
|
+
db.prepare("UPDATE channel_reads SET joined = 0 WHERE agent_id = ? AND channel_id = ?").run(member.id, found.id);
|
|
144
|
+
return { channel: found.name, joined: false };
|
|
145
|
+
})
|
|
146
|
+
.immediate();
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One page of `channel`, oldest first. By default the posts after the reader's read position, which then moves past
|
|
150
|
+
* the last one returned; an agent that has never read the channel starts at its first post. The reader's own posts are
|
|
151
|
+
* included, so a page reads in order, though they never count as unread. With `unreadOnly` false,
|
|
152
|
+
* the whole history paged by `offset`, leaving the read position where it was.
|
|
153
|
+
*/
|
|
154
|
+
export function readChannel(db, account, reader, channel, unreadOnly = true, limit = DEFAULT_CHANNEL_LIMIT, offset) {
|
|
155
|
+
if (unreadOnly && offset !== undefined)
|
|
156
|
+
throw new ToolError("offset pages through history: pass unread_only false with it.");
|
|
157
|
+
const read = db.transaction(() => {
|
|
158
|
+
checkLive(db, account, reader);
|
|
159
|
+
const found = findChannel(db, account, channel);
|
|
160
|
+
const pageSize = Math.min(limit, MAX_CHANNEL_LIMIT);
|
|
161
|
+
let rows;
|
|
162
|
+
if (unreadOnly) {
|
|
163
|
+
rows = db
|
|
164
|
+
.prepare(`SELECT p.id, p.sender, p.body, p.created_at FROM channel_posts p
|
|
165
|
+
LEFT JOIN channel_reads r ON r.channel_id = p.channel_id AND r.agent_id = ?
|
|
166
|
+
WHERE p.channel_id = ? AND p.id > COALESCE(r.last_read_id, 0) ORDER BY p.id LIMIT ?`)
|
|
167
|
+
.all(reader.id, found.id, pageSize);
|
|
168
|
+
if (rows.length > 0)
|
|
169
|
+
advance(db, reader.id, found.id, rows[rows.length - 1].id);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
rows = db
|
|
173
|
+
.prepare("SELECT id, sender, body, created_at FROM channel_posts WHERE channel_id = ? ORDER BY id LIMIT ? OFFSET ?")
|
|
174
|
+
.all(found.id, pageSize, offset ?? 0);
|
|
175
|
+
}
|
|
176
|
+
return { channel: found.name, posts: rows.map(toPost) };
|
|
177
|
+
});
|
|
178
|
+
// Only reading unread posts writes, by moving the read position; history needs no write lock.
|
|
179
|
+
return unreadOnly ? read.immediate() : read();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* One page of the account's channels by name, each with its post count, last post time, how many posts `reader`
|
|
183
|
+
* has not read and whether `reader` has joined it. The reader's own posts never count as unread.
|
|
184
|
+
*/
|
|
185
|
+
export function listChannels(db, account, reader, limit = DEFAULT_CHANNEL_LIMIT, offset = 0) {
|
|
186
|
+
// The page of channels is chosen first, so only its channels' posts are counted.
|
|
187
|
+
const rows = db.transaction(() => {
|
|
188
|
+
checkLive(db, account, reader);
|
|
189
|
+
return db
|
|
190
|
+
.prepare(`SELECT c.name, COUNT(p.id) AS posts, MAX(p.created_at) AS last_post_at,
|
|
191
|
+
COALESCE(SUM(p.id > COALESCE(r.last_read_id, 0) AND p.sender_id <> ?), 0) AS unread,
|
|
192
|
+
COALESCE(r.joined, 0) AS joined
|
|
193
|
+
FROM (SELECT id, name FROM channels WHERE account_id = ? ORDER BY name LIMIT ? OFFSET ?) c
|
|
194
|
+
LEFT JOIN channel_posts p ON p.channel_id = c.id
|
|
195
|
+
LEFT JOIN channel_reads r ON r.channel_id = c.id AND r.agent_id = ?
|
|
196
|
+
GROUP BY c.id ORDER BY c.name`)
|
|
197
|
+
.all(reader.id, account, Math.min(limit, MAX_CHANNEL_LIMIT), offset, reader.id);
|
|
198
|
+
})();
|
|
199
|
+
return rows.map((r) => ({
|
|
200
|
+
name: r.name,
|
|
201
|
+
posts: r.posts,
|
|
202
|
+
lastPostAt: r.last_post_at === null ? null : new Date(r.last_post_at).toISOString(),
|
|
203
|
+
unread: r.unread,
|
|
204
|
+
joined: r.joined === 1,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* One page of the account's channels by name, each with its post count, last post time and joined live agents, for
|
|
209
|
+
* the account rather than for one agent. Reads only, so nothing is marked read.
|
|
210
|
+
*/
|
|
211
|
+
export function listAccountChannels(db, account, limit = DEFAULT_CHANNEL_LIMIT, offset = 0) {
|
|
212
|
+
// One read transaction, so the page and its members describe the same moment.
|
|
213
|
+
return db.transaction(() => {
|
|
214
|
+
const rows = db
|
|
215
|
+
.prepare(`SELECT c.id, c.name, COUNT(p.id) AS posts, MAX(p.created_at) AS last_post_at
|
|
216
|
+
FROM (SELECT id, name FROM channels WHERE account_id = ? ORDER BY name LIMIT ? OFFSET ?) c
|
|
217
|
+
LEFT JOIN channel_posts p ON p.channel_id = c.id
|
|
218
|
+
GROUP BY c.id ORDER BY c.name`)
|
|
219
|
+
.all(account, Math.min(limit, MAX_CHANNEL_LIMIT), offset);
|
|
220
|
+
const members = db.prepare(`SELECT a.name FROM channel_reads r JOIN agents a ON a.id = r.agent_id
|
|
221
|
+
WHERE r.channel_id = ? AND r.joined = 1 AND a.account_id = ? AND a.removed_at IS NULL ORDER BY a.name`);
|
|
222
|
+
return rows.map((r) => ({
|
|
223
|
+
name: r.name,
|
|
224
|
+
posts: r.posts,
|
|
225
|
+
lastPostAt: r.last_post_at === null ? null : new Date(r.last_post_at).toISOString(),
|
|
226
|
+
members: members.pluck().all(r.id, account),
|
|
227
|
+
}));
|
|
228
|
+
})();
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* One page of the account's channel `channel`, oldest first, paged by `limit` and `offset`, for the account rather than for one
|
|
232
|
+
* agent, each post with whether each agent of the account it mentions has read the mention with `read_inbox`. With
|
|
233
|
+
* `unreadOnly`, only the posts with at least one unread mention of an agent. Reads only, so no read position moves and
|
|
234
|
+
* nothing is marked read.
|
|
235
|
+
*/
|
|
236
|
+
export function readAccountChannel(db, account, channel, limit = DEFAULT_CHANNEL_LIMIT, offset = 0, unreadOnly = false) {
|
|
237
|
+
// With unreadOnly, the posts are found from the account's agents and their unread mentions (mentions_unread), so only
|
|
238
|
+
// unread rows are read, never the channel's whole history; a post with several unread mentions is listed once.
|
|
239
|
+
const page = unreadOnly
|
|
240
|
+
? `SELECT DISTINCT p.id, p.sender, p.body, p.created_at
|
|
241
|
+
FROM agents a CROSS JOIN mentions x ON x.agent_id = a.id AND x.read_at IS NULL CROSS JOIN channel_posts p ON p.id = x.post_id
|
|
242
|
+
WHERE p.channel_id = ? AND a.account_id = ? AND a.person = 0 ORDER BY p.id LIMIT ? OFFSET ?`
|
|
243
|
+
: "SELECT id, sender, body, created_at FROM channel_posts WHERE channel_id = ? ORDER BY id LIMIT ? OFFSET ?";
|
|
244
|
+
return db.transaction(() => {
|
|
245
|
+
const found = findChannel(db, account, channel);
|
|
246
|
+
const params = [found.id, ...(unreadOnly ? [account] : []), Math.min(limit, MAX_CHANNEL_LIMIT), offset];
|
|
247
|
+
const rows = db.prepare(page).all(...params);
|
|
248
|
+
// The page's mentions in one statement, with the page as a subquery rather than a list of ids.
|
|
249
|
+
const mentions = db
|
|
250
|
+
.prepare(`SELECT x.post_id, CASE WHEN a.removed_at IS NULL THEN a.name ELSE a.removed_name END AS name, x.read_at
|
|
251
|
+
FROM (${page}) pg JOIN mentions x ON x.post_id = pg.id JOIN agents a ON a.id = x.agent_id
|
|
252
|
+
WHERE a.account_id = ? AND a.person = 0 ORDER BY x.post_id, name COLLATE NOCASE, a.id`)
|
|
253
|
+
.all(...params, account);
|
|
254
|
+
const byPost = new Map();
|
|
255
|
+
for (const m of mentions) {
|
|
256
|
+
const list = byPost.get(m.post_id) ?? [];
|
|
257
|
+
list.push({ name: m.name, read: m.read_at !== null });
|
|
258
|
+
byPost.set(m.post_id, list);
|
|
259
|
+
}
|
|
260
|
+
return { channel: found.name, posts: rows.map((r) => ({ ...toPost(r), mentions: byPost.get(r.id) ?? [] })) };
|
|
261
|
+
})();
|
|
262
|
+
}
|