@timqi/pier 0.0.17 → 0.0.20
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/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/channels/dedup.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// the second copy had already appeared and the map has an invariant that is
|
|
5
5
|
// easy to lose on a rewrite: it is fed by every message in every chat the bot
|
|
6
6
|
// is in, so it must be bounded and time-limited, never grow-only.
|
|
7
|
+
/** Fraction of `max` a full map is cut back to, so the eviction walk is paid
|
|
8
|
+
* once per that many messages instead of once per message. */
|
|
9
|
+
const KEEP = 0.9;
|
|
7
10
|
export class Dedup {
|
|
8
11
|
log;
|
|
9
12
|
ttlMs;
|
|
@@ -28,8 +31,13 @@ export class Dedup {
|
|
|
28
31
|
// is a forgotten id under extreme load (a redelivery slips through,
|
|
29
32
|
// which downstream handling tolerates); unbounded memory is worse.
|
|
30
33
|
// Map iterates in insertion order, so the front is the oldest.
|
|
34
|
+
//
|
|
35
|
+
// Evicting down to `KEEP` rather than to exactly `max` is what makes
|
|
36
|
+
// the walk amortized: at the bound, freeing one slot per message meant
|
|
37
|
+
// re-walking the whole map on every message from then on.
|
|
38
|
+
const keep = Math.floor(this.max * KEEP);
|
|
31
39
|
for (const [id] of this.seen) {
|
|
32
|
-
if (this.seen.size
|
|
40
|
+
if (this.seen.size <= keep)
|
|
33
41
|
break;
|
|
34
42
|
this.seen.delete(id);
|
|
35
43
|
}
|
package/dist/channels/lark.js
CHANGED
|
@@ -44,7 +44,6 @@ const DRAIN_TIMEOUT_MS = 5000;
|
|
|
44
44
|
/** How long a delivered event id is remembered, against redelivery. */
|
|
45
45
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
46
46
|
const DEDUP_MAX = 2000;
|
|
47
|
-
const SWEEP_EVERY_MS = 60_000;
|
|
48
47
|
/**
|
|
49
48
|
* A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
|
|
50
49
|
* the session. This pair is the only definition of the format; control.ts
|
|
@@ -87,7 +86,6 @@ export class LarkChannel {
|
|
|
87
86
|
out;
|
|
88
87
|
socket;
|
|
89
88
|
running = false;
|
|
90
|
-
sweptAt = 0;
|
|
91
89
|
constructor(deps) {
|
|
92
90
|
this.deps = deps;
|
|
93
91
|
const config = deps.store.get("lark");
|
|
@@ -143,10 +141,8 @@ export class LarkChannel {
|
|
|
143
141
|
onEvent(event, onMessage) {
|
|
144
142
|
if (!this.running)
|
|
145
143
|
return;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
void this.receipts.sweep();
|
|
149
|
-
}
|
|
144
|
+
// Asked on every event, throttled inside receipts.ts.
|
|
145
|
+
void this.receipts.sweep();
|
|
150
146
|
// Our own echo or another app's message.
|
|
151
147
|
if (event.senderType === "app")
|
|
152
148
|
return;
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
// it finds at startup (nothing in memory can be its own yet), and sweeps its
|
|
9
9
|
// own stragglers on a timer.
|
|
10
10
|
import { pierDb } from "../db.js";
|
|
11
|
+
/**
|
|
12
|
+
* How often the straggler sweep may really run. Adapters ask on the inbound
|
|
13
|
+
* path — per envelope, or per `getUpdates` round trip — and the books only
|
|
14
|
+
* change on the scale of `staleMs`, so a busy chat would otherwise run this
|
|
15
|
+
* query hundreds of times a minute. Throttled here rather than in each
|
|
16
|
+
* adapter, which is where the same timestamp had been copied twice already.
|
|
17
|
+
*/
|
|
18
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
11
19
|
const toReceipt = (row) => ({
|
|
12
20
|
conversationId: row.conversation_id,
|
|
13
21
|
chatId: row.chat_id,
|
|
@@ -68,6 +76,7 @@ export class Receipts {
|
|
|
68
76
|
staleMs;
|
|
69
77
|
/** In-flight `setReaction` per marked message. Only this process's own. */
|
|
70
78
|
applying = new Map();
|
|
79
|
+
sweptAt = 0;
|
|
71
80
|
constructor(api, ledger, log, emoji,
|
|
72
81
|
/** After this, a receipt's turn is assumed never to settle. */
|
|
73
82
|
staleMs) {
|
|
@@ -106,14 +115,20 @@ export class Receipts {
|
|
|
106
115
|
* ours yet — and past `staleMs` a receipt's turn is never going to settle.
|
|
107
116
|
*/
|
|
108
117
|
sweep(all = false) {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
// `all` is the startup sweep: it takes everything, so it is never skipped.
|
|
120
|
+
if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
this.sweptAt = now;
|
|
109
123
|
return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
|
|
110
124
|
}
|
|
111
125
|
async clear(receipts) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
126
|
+
// Wait together, then launch clears in booking order: if one apply is slow,
|
|
127
|
+
// it must not let a later receipt clear first.
|
|
128
|
+
await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
|
|
129
|
+
for (const { chatId, messageId } of receipts)
|
|
130
|
+
this.applying.delete(`${chatId}:${messageId}`);
|
|
131
|
+
await Promise.all(receipts.map(({ chatId, messageId }) => this.api.setReaction(chatId, messageId, null)
|
|
132
|
+
.catch((err) => this.log(`reaction clear failed: ${String(err)}`))));
|
|
118
133
|
}
|
|
119
134
|
}
|
|
@@ -27,19 +27,25 @@ const tsToNumber = (ts) => Number(ts);
|
|
|
27
27
|
const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
|
|
28
28
|
/** Minute precision in the transcript: the exact time is in the `ts` beside it. */
|
|
29
29
|
const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
|
|
30
|
+
/** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
|
|
31
|
+
const MAX_SECONDS = 4_102_444_800;
|
|
30
32
|
/** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
|
|
31
33
|
export function toTs(value) {
|
|
32
34
|
if (value === undefined || value === "")
|
|
33
35
|
return undefined;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const parsed = Date.parse(trimmed);
|
|
40
|
-
if (Number.isNaN(parsed))
|
|
36
|
+
const raw = typeof value === "number" ? String(value) : value.trim();
|
|
37
|
+
// A ts is passed through untouched — it is an id, not a number to reformat.
|
|
38
|
+
const numeric = /^\d+(\.\d+)?$/.test(raw);
|
|
39
|
+
const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
|
|
40
|
+
if (Number.isNaN(seconds))
|
|
41
41
|
throw new Error(`not a time: ${value}`);
|
|
42
|
-
|
|
42
|
+
// Milliseconds are the mistake worth naming: Slack takes the window without
|
|
43
|
+
// complaint, finds nothing that far in the future, and answers with an empty
|
|
44
|
+
// read that is indistinguishable from a channel where nobody spoke.
|
|
45
|
+
if (seconds > MAX_SECONDS) {
|
|
46
|
+
throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
|
|
47
|
+
}
|
|
48
|
+
return numeric ? raw : String(seconds);
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Whether a session opened now is given the tool at all: the same two switches
|
|
@@ -91,6 +97,8 @@ export function slackToolSpec(execute, available) {
|
|
|
91
97
|
text: Type.Optional(Type.String()),
|
|
92
98
|
}),
|
|
93
99
|
available,
|
|
100
|
+
// Stands down with the tool: without Slack, its manual is a route to nowhere.
|
|
101
|
+
skill: "pier-slack",
|
|
94
102
|
execute,
|
|
95
103
|
};
|
|
96
104
|
}
|
|
@@ -165,10 +173,12 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
165
173
|
// own bounds are inclusive-ish, so the boundary message is dropped here
|
|
166
174
|
// rather than trusted to the API.
|
|
167
175
|
const after = toTs(input.after);
|
|
176
|
+
// Both reads take it, so it is read once: a cap that worked on a channel and
|
|
177
|
+
// was ignored on a thread would be the more expensive kind of surprise.
|
|
178
|
+
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
168
179
|
if (input.operation === "read_channel") {
|
|
169
180
|
const since = after ?? toTs(input.since);
|
|
170
181
|
const until = toTs(input.until);
|
|
171
|
-
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
172
182
|
return readChannel(deps, client, channel, since, until, after, limit);
|
|
173
183
|
}
|
|
174
184
|
if (input.operation === "read_thread") {
|
|
@@ -177,7 +187,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
177
187
|
: at?.threadTs;
|
|
178
188
|
if (!threadTs)
|
|
179
189
|
throw new Error("thread_ts is required outside a Slack thread");
|
|
180
|
-
return readThread(deps, client, channel, threadTs, after);
|
|
190
|
+
return readThread(deps, client, channel, threadTs, after, limit);
|
|
181
191
|
}
|
|
182
192
|
if (input.operation === "read_message") {
|
|
183
193
|
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
@@ -204,6 +214,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
204
214
|
at: sent.ts ? tsToIso(sent.ts) : null,
|
|
205
215
|
// Returned so a follow-up can reply under what was just posted.
|
|
206
216
|
threadTs: threadTs ?? sent.ts,
|
|
217
|
+
...inertMention(text),
|
|
207
218
|
};
|
|
208
219
|
}
|
|
209
220
|
if (input.operation === "edit") {
|
|
@@ -211,14 +222,23 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
211
222
|
// and Slack keeps no visible record of what it said before.
|
|
212
223
|
const ts = required(input.ts, "ts");
|
|
213
224
|
const text = messageText(input.text);
|
|
225
|
+
// Defaulted from `here` as `post` is: a correction is nearly always to a
|
|
226
|
+
// reply the agent made in this thread, and `conversations.history` cannot
|
|
227
|
+
// see inside a thread — without this the read below always comes up empty.
|
|
228
|
+
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
229
|
+
const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
230
|
+
// Read before the write, because after it the old wording exists nowhere:
|
|
231
|
+
// Slack keeps no version, so this log is the only record of what was
|
|
232
|
+
// replaced. Best-effort — a failed read must not block the correction.
|
|
233
|
+
const was = await previousText(client, channel, ts, inThread);
|
|
214
234
|
try {
|
|
215
235
|
await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
|
|
216
236
|
}
|
|
217
237
|
catch (err) {
|
|
218
238
|
throw new Error(explain(err));
|
|
219
239
|
}
|
|
220
|
-
deps.log(`slack tool edited ${ts} in ${channel}`);
|
|
221
|
-
return { channel, ts, edited: true };
|
|
240
|
+
deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
|
|
241
|
+
return { channel, ts, edited: true, ...inertMention(text) };
|
|
222
242
|
}
|
|
223
243
|
if (input.operation === "delete") {
|
|
224
244
|
// Never defaulted from `here`: the thread's ts is the parent message, and
|
|
@@ -237,6 +257,50 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
|
237
257
|
}
|
|
238
258
|
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
239
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The one message at `ts`. A `ts` is unique only within its conversation, and
|
|
262
|
+
* `conversations.history` never returns what was posted inside a thread — so a
|
|
263
|
+
* reply has to be asked for through its thread, and only the caller knows.
|
|
264
|
+
*/
|
|
265
|
+
async function oneMessage(client, channel, ts, threadTs) {
|
|
266
|
+
const page = threadTs
|
|
267
|
+
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
268
|
+
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
269
|
+
return page.messages.find((msg) => msg.ts === ts);
|
|
270
|
+
}
|
|
271
|
+
/** One line of what a message said, for the log an edit leaves behind. */
|
|
272
|
+
async function previousText(client, channel, ts, threadTs) {
|
|
273
|
+
try {
|
|
274
|
+
const text = (await oneMessage(client, channel, ts, threadTs))?.text;
|
|
275
|
+
return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Not swallowed: the caller logs that the old wording was not captured,
|
|
279
|
+
// which is the fact that matters. Refusing the edit over it would be worse.
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* A plain `@alice` is the one Slack mistake that looks like it worked: the
|
|
285
|
+
* message goes up, renders as text, and notifies nobody. Reported after the
|
|
286
|
+
* fact rather than refused — a name in prose is legitimate, an unping is not
|
|
287
|
+
* worth losing the message over.
|
|
288
|
+
*/
|
|
289
|
+
function inertMention(text) {
|
|
290
|
+
const prose = text
|
|
291
|
+
.replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
|
|
292
|
+
.replace(/<[^>]*>/g, ""); // already Slack syntax
|
|
293
|
+
const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
|
|
294
|
+
if (!hit)
|
|
295
|
+
return {};
|
|
296
|
+
const as = hit.startsWith("@")
|
|
297
|
+
? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
|
|
298
|
+
: "<#C…>";
|
|
299
|
+
return {
|
|
300
|
+
hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
|
|
301
|
+
`Edit this ts if it was meant to reach someone.`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
240
304
|
/** Accept a `#name` or a bare name as well as an id — models prefer names. */
|
|
241
305
|
function resolveChannel(deps, given) {
|
|
242
306
|
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
@@ -265,30 +329,27 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
|
|
|
265
329
|
messages: await lines(deps, client, window),
|
|
266
330
|
};
|
|
267
331
|
}
|
|
268
|
-
async function readThread(deps, client, channel, threadTs, after) {
|
|
332
|
+
async function readThread(deps, client, channel, threadTs, after, limit) {
|
|
269
333
|
const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
|
|
270
|
-
const
|
|
334
|
+
const all = newerThan(transcript(fetched.messages), after);
|
|
335
|
+
// Oldest first, as in a channel read: a thread cut at its newest end still
|
|
336
|
+
// reads as a thread, and the cut has to be said either way — a long thread
|
|
337
|
+
// that answers as if it were complete is the read nobody double-checks.
|
|
338
|
+
const messages = all.slice(0, limit);
|
|
271
339
|
return {
|
|
272
340
|
channel,
|
|
273
341
|
// Hoisted: every line in a thread carries the same one.
|
|
274
342
|
threadTs,
|
|
275
343
|
count: messages.length,
|
|
344
|
+
...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
|
|
276
345
|
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
277
346
|
format: LINE_FORMAT,
|
|
278
347
|
messages: await lines(deps, client, messages),
|
|
279
348
|
};
|
|
280
349
|
}
|
|
281
|
-
/**
|
|
282
|
-
* One message, because that is sometimes the whole question. A `ts` is unique
|
|
283
|
-
* only within its conversation, and `conversations.history` never returns what
|
|
284
|
-
* was posted inside a thread — so a reply has to be asked for through its
|
|
285
|
-
* thread, and saying which one is the caller's job.
|
|
286
|
-
*/
|
|
350
|
+
/** One message, because that is sometimes the whole question. */
|
|
287
351
|
async function readMessage(deps, client, channel, ts, threadTs) {
|
|
288
|
-
const
|
|
289
|
-
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
290
|
-
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
291
|
-
const found = page.messages.find((msg) => msg.ts === ts);
|
|
352
|
+
const found = await oneMessage(client, channel, ts, threadTs);
|
|
292
353
|
if (!found) {
|
|
293
354
|
throw new Error(threadTs
|
|
294
355
|
? `no message ${ts} in thread ${threadTs}`
|
|
@@ -342,7 +403,7 @@ function explain(err) {
|
|
|
342
403
|
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
343
404
|
return {
|
|
344
405
|
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
345
|
-
not_in_channel: "Pier's bot is not in that channel; someone has to invite
|
|
406
|
+
not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
|
|
346
407
|
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
347
408
|
ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
|
|
348
409
|
thread_not_found: "no thread with that ts in this channel",
|
package/dist/channels/slack.js
CHANGED
|
@@ -52,8 +52,6 @@ const DRAIN_TIMEOUT_MS = 5000;
|
|
|
52
52
|
*/
|
|
53
53
|
const DEDUP_TTL_MS = 5 * 60_000;
|
|
54
54
|
const DEDUP_MAX = 2000;
|
|
55
|
-
/** How often the straggler sweep may run; see onEnvelope. */
|
|
56
|
-
const SWEEP_EVERY_MS = 60_000;
|
|
57
55
|
/**
|
|
58
56
|
* Commands that may appear as a bare word, and exactly how many arguments each
|
|
59
57
|
* takes. Both halves are load-bearing, because Slack gives us no leading `/` to
|
|
@@ -132,7 +130,6 @@ export class SlackChannel {
|
|
|
132
130
|
out;
|
|
133
131
|
socket;
|
|
134
132
|
running = false;
|
|
135
|
-
sweptAt = 0;
|
|
136
133
|
constructor(deps) {
|
|
137
134
|
this.deps = deps;
|
|
138
135
|
const config = deps.store.get("slack");
|
|
@@ -195,13 +192,8 @@ export class SlackChannel {
|
|
|
195
192
|
onEnvelope(env, onMessage) {
|
|
196
193
|
if (!this.running)
|
|
197
194
|
return;
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
// hundreds of times a minute for something that changes every 30.
|
|
201
|
-
if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
|
|
202
|
-
this.sweptAt = Date.now();
|
|
203
|
-
void this.receipts.sweep();
|
|
204
|
-
}
|
|
195
|
+
// Asked on every envelope, throttled inside receipts.ts.
|
|
196
|
+
void this.receipts.sweep();
|
|
205
197
|
if (env.type === "events_api") {
|
|
206
198
|
const payload = env.payload;
|
|
207
199
|
const event = payload?.event;
|
|
@@ -124,6 +124,7 @@ export class TelegramChannel {
|
|
|
124
124
|
const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
|
|
125
125
|
if (!this.running)
|
|
126
126
|
return;
|
|
127
|
+
// Asked on every round trip, throttled inside receipts.ts.
|
|
127
128
|
void this.receipts.sweep();
|
|
128
129
|
if (!updates.length && Date.now() - startedAt < 1000) {
|
|
129
130
|
await new Promise((r) => setTimeout(r, 1000));
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Fetch one configuration document without turning an operator URL into an
|
|
2
|
+
// arbitrary network proxy. DNS is checked once and the TLS request is pinned
|
|
3
|
+
// to that address; redirects never inherit the token or bypass the check.
|
|
4
|
+
import { lookup } from "node:dns/promises";
|
|
5
|
+
import { request } from "node:https";
|
|
6
|
+
import { BlockList, isIP } from "node:net";
|
|
7
|
+
import { checkServerIdentity } from "node:tls";
|
|
8
|
+
export const CONFIG_SYNC_BYTES = 1024 * 1024;
|
|
9
|
+
const blockedV4 = new BlockList();
|
|
10
|
+
const blockedV6 = new BlockList();
|
|
11
|
+
for (const [address, prefix] of [
|
|
12
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
13
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
|
|
14
|
+
["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
15
|
+
])
|
|
16
|
+
blockedV4.addSubnet(address, prefix, "ipv4");
|
|
17
|
+
for (const [address, prefix] of [
|
|
18
|
+
["::", 96], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48],
|
|
19
|
+
["100::", 64], ["2001::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
|
|
20
|
+
])
|
|
21
|
+
blockedV6.addSubnet(address, prefix, "ipv6");
|
|
22
|
+
const globalV6 = new BlockList();
|
|
23
|
+
globalV6.addSubnet("2000::", 3, "ipv6");
|
|
24
|
+
export function configSourceUrl(raw) {
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(raw.trim());
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error("A valid HTTPS source URL is required");
|
|
31
|
+
}
|
|
32
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || raw.length > 4096) {
|
|
33
|
+
throw new Error("Source must be HTTPS, without credentials or a fragment");
|
|
34
|
+
}
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
export function publicConfigAddress(address) {
|
|
38
|
+
const family = isIP(address);
|
|
39
|
+
return family !== 0 && (family !== 6 || globalV6.check(address, "ipv6")) &&
|
|
40
|
+
!(family === 4 ? blockedV4 : blockedV6).check(address, family === 4 ? "ipv4" : "ipv6");
|
|
41
|
+
}
|
|
42
|
+
export async function downloadConfig(raw, etag, signal) {
|
|
43
|
+
const url = configSourceUrl(raw);
|
|
44
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
45
|
+
const addresses = isIP(hostname)
|
|
46
|
+
? [{ address: hostname, family: isIP(hostname) }]
|
|
47
|
+
: await Promise.race([
|
|
48
|
+
lookup(hostname, { all: true }),
|
|
49
|
+
new Promise((_, reject) => {
|
|
50
|
+
if (signal.aborted)
|
|
51
|
+
reject(new Error("Configuration download aborted"));
|
|
52
|
+
else
|
|
53
|
+
signal.addEventListener("abort", () => reject(new Error("Configuration download aborted")), { once: true });
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
if (!addresses.length || addresses.some(({ address }) => !publicConfigAddress(address))) {
|
|
57
|
+
throw new Error("Source must resolve to a public address");
|
|
58
|
+
}
|
|
59
|
+
signal.throwIfAborted();
|
|
60
|
+
const address = addresses[0];
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const req = request({
|
|
63
|
+
hostname: address.address, family: address.family, port: url.port || 443,
|
|
64
|
+
servername: isIP(hostname) ? undefined : hostname,
|
|
65
|
+
checkServerIdentity: (_host, cert) => checkServerIdentity(hostname, cert),
|
|
66
|
+
path: `${url.pathname}${url.search}`, method: "GET", signal,
|
|
67
|
+
headers: { host: url.host, accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
68
|
+
}, (res) => {
|
|
69
|
+
const status = res.statusCode;
|
|
70
|
+
if (status !== 200 && status !== 304) {
|
|
71
|
+
res.destroy();
|
|
72
|
+
reject(new Error(status === 404 || status === 410
|
|
73
|
+
? "Source link was revoked or does not exist"
|
|
74
|
+
: `Source returned HTTP ${String(status)}`));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers["content-type"] ?? "")) {
|
|
78
|
+
res.destroy();
|
|
79
|
+
reject(new Error("Source did not return JSON"));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let size = 0;
|
|
84
|
+
res.on("data", (chunk) => {
|
|
85
|
+
size += chunk.length;
|
|
86
|
+
if (size > CONFIG_SYNC_BYTES)
|
|
87
|
+
res.destroy(new Error("Configuration exceeds 1 MiB"));
|
|
88
|
+
else
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
});
|
|
91
|
+
res.on("error", () => reject(new Error(size > CONFIG_SYNC_BYTES ? "Configuration exceeds 1 MiB" : "Configuration download failed")));
|
|
92
|
+
res.on("end", () => resolve({ status, etag: res.headers.etag ?? null, body: Buffer.concat(chunks).toString("utf8") }));
|
|
93
|
+
});
|
|
94
|
+
req.on("error", () => reject(new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source")));
|
|
95
|
+
req.end();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The single hourly task owned by the configuration subscription. The task
|
|
2
|
+
// history records manual and scheduled checks; its enabled bit follows the
|
|
3
|
+
// subscription, never a second independently editable switch.
|
|
4
|
+
const OWNER = "config-sync";
|
|
5
|
+
export function configSyncTask(tasks, sync) {
|
|
6
|
+
let taskId = null;
|
|
7
|
+
let reconciliation = Promise.resolve();
|
|
8
|
+
const reconcile = () => {
|
|
9
|
+
const next = reconciliation.then(async () => {
|
|
10
|
+
const owned = tasks.list().filter((task) => task.creator === OWNER && !task.archived);
|
|
11
|
+
for (const extra of owned.slice(1))
|
|
12
|
+
tasks.archive(extra.id, OWNER);
|
|
13
|
+
const status = sync.status();
|
|
14
|
+
if (!owned.length && !status.sourceUrl)
|
|
15
|
+
return;
|
|
16
|
+
const draft = {
|
|
17
|
+
name: "Configuration sync",
|
|
18
|
+
description: "Checks the configuration source from Settings > Agent every hour.",
|
|
19
|
+
enabled: status.enabled,
|
|
20
|
+
trigger: { type: "cron", expression: "23 * * * *", timezone: "UTC" },
|
|
21
|
+
action: { type: "system", name: OWNER },
|
|
22
|
+
callback: { type: "none" },
|
|
23
|
+
timeoutSeconds: 30,
|
|
24
|
+
};
|
|
25
|
+
const task = owned[0];
|
|
26
|
+
if (!task)
|
|
27
|
+
taskId = (await tasks.create(draft, OWNER)).id;
|
|
28
|
+
else {
|
|
29
|
+
taskId = task.id;
|
|
30
|
+
if (Object.entries(draft).some(([key, value]) => JSON.stringify(task[key]) !== JSON.stringify(value))) {
|
|
31
|
+
await tasks.update(task.id, draft, OWNER);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// A failed reconciliation must be retryable, but the caller still receives
|
|
36
|
+
// the original rejection and reports it on its originating surface.
|
|
37
|
+
reconciliation = next.then(() => undefined, () => undefined);
|
|
38
|
+
return next;
|
|
39
|
+
};
|
|
40
|
+
const run = async () => {
|
|
41
|
+
await reconcile();
|
|
42
|
+
if (!taskId || !sync.status().enabled)
|
|
43
|
+
throw new Error("Configuration subscription is paused");
|
|
44
|
+
const active = tasks.activeRun(taskId);
|
|
45
|
+
const started = active ?? tasks.run(taskId, null, "manual");
|
|
46
|
+
const done = await tasks.waitForRun(started.id);
|
|
47
|
+
if (done.state !== "succeeded")
|
|
48
|
+
throw new Error(done.error ?? `Configuration sync ${done.state}`);
|
|
49
|
+
return done.result?.type === "system" ? done.result.text : "Configuration sync finished";
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
reconcile,
|
|
53
|
+
run,
|
|
54
|
+
status: () => ({
|
|
55
|
+
...sync.status(), taskId,
|
|
56
|
+
nextRunAt: taskId ? tasks.get(taskId).nextRunAt : null,
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
}
|