@timqi/pier 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
|
@@ -1,26 +1,13 @@
|
|
|
1
|
-
// Reaction receipts:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// halves live in Telegram, not in Pier, so anything ending the process between
|
|
5
|
-
// them leaves the emoji on a user's message with nobody left to clear it —
|
|
6
|
-
// and so does a message whose session never started a turn at all. Making the
|
|
7
|
-
// pending set durable is what closes the loop: an adapter clears every receipt
|
|
8
|
-
// it finds at startup (nothing in memory can be its own yet), and sweeps its
|
|
9
|
-
// own stragglers on a timer.
|
|
1
|
+
// Reaction receipts: a 👀 goes on an inbound message and comes off when its
|
|
2
|
+
// turn settles. Durable, because the emoji lives on the platform: a process
|
|
3
|
+
// ending between the two halves would leave it with nobody to clear it.
|
|
10
4
|
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
|
-
*/
|
|
5
|
+
/** Adapters ask on every inbound envelope; the books change on the scale of `staleMs`. */
|
|
18
6
|
const SWEEP_EVERY_MS = 60_000;
|
|
19
7
|
const toReceipt = (row) => ({
|
|
20
8
|
conversationId: row.conversation_id,
|
|
21
9
|
chatId: row.chat_id,
|
|
22
|
-
//
|
|
23
|
-
// coercing keeps a numeric-looking id from arriving as a number.
|
|
10
|
+
// The column is TEXT, but a numeric-looking id could still arrive as a number.
|
|
24
11
|
messageId: String(row.message_id),
|
|
25
12
|
});
|
|
26
13
|
export class ReceiptLedger {
|
|
@@ -30,7 +17,6 @@ export class ReceiptLedger {
|
|
|
30
17
|
this.platform = platform;
|
|
31
18
|
this.db = db;
|
|
32
19
|
}
|
|
33
|
-
/** Re-marking the same message replaces the row rather than duplicating it. */
|
|
34
20
|
add(receipt) {
|
|
35
21
|
this.db.prepare(`
|
|
36
22
|
INSERT INTO receipts(platform, conversation_id, chat_id, message_id, created_at)
|
|
@@ -39,14 +25,17 @@ export class ReceiptLedger {
|
|
|
39
25
|
conversation_id = excluded.conversation_id, created_at = excluded.created_at
|
|
40
26
|
`).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, Date.now());
|
|
41
27
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
28
|
+
/** Returned once, then gone. `bookedBy` claims only what was on the books by
|
|
29
|
+
* then — see `Receipts.settle`. */
|
|
30
|
+
take(conversationId, bookedBy) {
|
|
31
|
+
const scope = bookedBy === undefined ? "" : " AND created_at <= ?";
|
|
32
|
+
const args = bookedBy === undefined ? [] : [bookedBy];
|
|
44
33
|
const rows = this.db.prepare(`
|
|
45
34
|
SELECT conversation_id, chat_id, message_id FROM receipts
|
|
46
|
-
WHERE platform = ? AND conversation_id =
|
|
47
|
-
`).all(this.platform, conversationId);
|
|
48
|
-
this.db.prepare(
|
|
49
|
-
.run(this.platform, conversationId);
|
|
35
|
+
WHERE platform = ? AND conversation_id = ?${scope}
|
|
36
|
+
`).all(this.platform, conversationId, ...args);
|
|
37
|
+
this.db.prepare(`DELETE FROM receipts WHERE platform = ? AND conversation_id = ?${scope}`)
|
|
38
|
+
.run(this.platform, conversationId, ...args);
|
|
50
39
|
return rows.map(toReceipt);
|
|
51
40
|
}
|
|
52
41
|
/** Claim receipts older than `ageMs`; `0` claims everything (startup sweep). */
|
|
@@ -61,20 +50,15 @@ export class ReceiptLedger {
|
|
|
61
50
|
return rows.map(toReceipt);
|
|
62
51
|
}
|
|
63
52
|
}
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* booked synchronously (so an instant turn cannot clear an unbooked one) while
|
|
68
|
-
* the platform call is in flight, and the clear must wait for that call to land
|
|
69
|
-
* or the reaction stays up forever.
|
|
70
|
-
*/
|
|
53
|
+
/** A receipt is booked synchronously (an instant turn must not clear an
|
|
54
|
+
* unbooked one) while the platform call is in flight, and the clear waits for
|
|
55
|
+
* that call to land or the reaction stays up forever. */
|
|
71
56
|
export class Receipts {
|
|
72
57
|
api;
|
|
73
58
|
ledger;
|
|
74
59
|
log;
|
|
75
60
|
emoji;
|
|
76
61
|
staleMs;
|
|
77
|
-
/** In-flight `setReaction` per marked message. Only this process's own. */
|
|
78
62
|
applying = new Map();
|
|
79
63
|
sweptAt = 0;
|
|
80
64
|
constructor(api, ledger, log, emoji,
|
|
@@ -91,40 +75,33 @@ export class Receipts {
|
|
|
91
75
|
.catch((err) => this.log(`reaction failed: ${String(err)}`)));
|
|
92
76
|
this.ledger.add({ conversationId, chatId, messageId });
|
|
93
77
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
78
|
+
/** Only the messages *this* turn was working on: a message queued mid-turn
|
|
79
|
+
* is still owed an answer. `meta` says when the turn began; no meta clears
|
|
80
|
+
* everything (the refusal paths have no turn to scope by). */
|
|
81
|
+
settle(conversationId, meta) {
|
|
82
|
+
const began = meta && meta.completedAt - meta.durationMs;
|
|
83
|
+
return this.clear(this.ledger.take(conversationId, began));
|
|
97
84
|
}
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
* until the stale sweep. The try/finally was copied into all three
|
|
102
|
-
* adapters' send() before landing here; the error still propagates, because
|
|
103
|
-
* a failed delivery is the router's to report.
|
|
104
|
-
*/
|
|
105
|
-
async settleAfter(conversationId, deliver) {
|
|
85
|
+
/** Settles whatever happens; the error still propagates, because a failed
|
|
86
|
+
* delivery is the router's to report. */
|
|
87
|
+
async settleAfter(conversationId, deliver, meta) {
|
|
106
88
|
try {
|
|
107
89
|
await deliver();
|
|
108
90
|
}
|
|
109
91
|
finally {
|
|
110
|
-
await this.settle(conversationId);
|
|
92
|
+
await this.settle(conversationId, meta);
|
|
111
93
|
}
|
|
112
94
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Everything on the books at startup is orphaned — nothing in memory can be
|
|
115
|
-
* ours yet — and past `staleMs` a receipt's turn is never going to settle.
|
|
116
|
-
*/
|
|
95
|
+
/** `all` is the startup sweep: everything on the books is orphaned then. */
|
|
117
96
|
sweep(all = false) {
|
|
118
97
|
const now = Date.now();
|
|
119
|
-
// `all` is the startup sweep: it takes everything, so it is never skipped.
|
|
120
98
|
if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
|
|
121
99
|
return Promise.resolve();
|
|
122
100
|
this.sweptAt = now;
|
|
123
101
|
return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
|
|
124
102
|
}
|
|
125
103
|
async clear(receipts) {
|
|
126
|
-
//
|
|
127
|
-
// it must not let a later receipt clear first.
|
|
104
|
+
// A slow apply must not let a later receipt clear first.
|
|
128
105
|
await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
|
|
129
106
|
for (const { chatId, messageId } of receipts)
|
|
130
107
|
this.applying.delete(`${chatId}:${messageId}`);
|
package/dist/channels/routes.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
// Settings → Channels HTTP surface
|
|
2
|
-
// global defaults, bound users and discovered chats travel together, so the
|
|
3
|
-
// UI never has to stitch two half-configs.
|
|
1
|
+
// Settings → Channels HTTP surface: one document per platform.
|
|
4
2
|
import { isThinkingLevel } from "../core/types.js";
|
|
5
3
|
import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
|
|
6
4
|
/** Stable mask: recomputable at write time, so "unchanged" is detectable. */
|
|
@@ -17,12 +15,8 @@ function asModel(v) {
|
|
|
17
15
|
const id = asString(ref.id);
|
|
18
16
|
return provider && id ? { provider, id } : null;
|
|
19
17
|
}
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
* stored list, not the payload, is what makes the save non-destructive: chats
|
|
23
|
-
* are discovered, so one that appeared while the operator had the page open
|
|
24
|
-
* must survive their save instead of being deleted by a stale client list.
|
|
25
|
-
*/
|
|
18
|
+
/** Iterates the stored list, not the payload: a chat discovered while the
|
|
19
|
+
* operator had the page open must survive their save. */
|
|
26
20
|
function parseChats(raw, known) {
|
|
27
21
|
if (!Array.isArray(raw))
|
|
28
22
|
return known;
|
package/dist/channels/runtime.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
// Channel lifecycle: which adapters are running
|
|
2
|
-
//
|
|
3
|
-
// config change.
|
|
1
|
+
// Channel lifecycle: which adapters are running; one call for the Console to
|
|
2
|
+
// apply a config change.
|
|
4
3
|
import { logger } from "../log.js";
|
|
5
4
|
import { LarkChannel } from "./lark.js";
|
|
6
5
|
import { SlackChannel } from "./slack.js";
|
|
7
6
|
import { TelegramChannel } from "./telegram.js";
|
|
8
|
-
/** Platforms with an adapter, and what each needs before it can start. */
|
|
9
7
|
const ADAPTERS = [
|
|
10
8
|
{ platform: "telegram", needsAppToken: false, build: (deps) => new TelegramChannel(deps) },
|
|
11
9
|
{ platform: "slack", needsAppToken: true, build: (deps) => new SlackChannel(deps) },
|
|
12
|
-
// Lark's "token" is the App ID and "appToken" the App Secret
|
|
13
|
-
// needs both before it can start, same gate as Slack's two credentials.
|
|
10
|
+
// Lark's "token" is the App ID and "appToken" the App Secret.
|
|
14
11
|
{ platform: "lark", needsAppToken: true, build: (deps) => new LarkChannel(deps) },
|
|
15
12
|
];
|
|
16
|
-
//
|
|
17
|
-
// is a warning sink the adapters share, and "slack started" is not a warning.
|
|
13
|
+
// The injected sink is for warnings; "slack started" is not one.
|
|
18
14
|
const log = logger("channels");
|
|
19
15
|
// The parameter below shadows `log` inside its own default expression.
|
|
20
16
|
const warn = (m) => log.warn(m);
|
|
@@ -32,10 +28,8 @@ export class ChannelRuntime {
|
|
|
32
28
|
this.control = control;
|
|
33
29
|
this.log = log;
|
|
34
30
|
}
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
* adapters; platforms restart in parallel so a hung start on one cannot
|
|
38
|
-
* stall the other, and a failure is logged, never swallowed. */
|
|
31
|
+
/** Serialized: two concurrent Console saves would race into duplicate live
|
|
32
|
+
* adapters. Platforms restart in parallel so a hung start cannot stall another. */
|
|
39
33
|
reload() {
|
|
40
34
|
const run = this.reloading.catch(() => { }).then(async () => {
|
|
41
35
|
if (this.stopped)
|
|
@@ -55,24 +49,20 @@ export class ChannelRuntime {
|
|
|
55
49
|
const existing = this.live.get(platform);
|
|
56
50
|
if (existing) {
|
|
57
51
|
this.live.delete(platform);
|
|
58
|
-
// Never fatal
|
|
59
|
-
// refuses to close is exactly what makes the next start behave oddly.
|
|
52
|
+
// Never fatal: the config still has to be applied.
|
|
60
53
|
await existing.stop().catch((err) => this.log(`${platform} did not stop cleanly: ${String(err)}`));
|
|
61
54
|
}
|
|
62
55
|
const config = this.store.get(platform);
|
|
63
56
|
if (!config.enabled || !config.token)
|
|
64
57
|
return;
|
|
65
58
|
if (needsAppToken && !config.appToken) {
|
|
66
|
-
//
|
|
67
|
-
// indistinguishable from a broken adapter.
|
|
59
|
+
// "Enabled but nothing happens" is indistinguishable from a broken adapter.
|
|
68
60
|
this.log(`${platform}: enabled but no app token, not starting`);
|
|
69
61
|
return;
|
|
70
62
|
}
|
|
71
63
|
const channel = build({
|
|
72
64
|
store: this.store,
|
|
73
65
|
log: (m) => this.log(`${platform}: ${m}`),
|
|
74
|
-
// The runtime owns the router, so channel control (stop, the settings
|
|
75
|
-
// panel) is wired here instead of widening the Channel seam.
|
|
76
66
|
control: this.control,
|
|
77
67
|
});
|
|
78
68
|
try {
|
|
@@ -88,9 +78,8 @@ export class ChannelRuntime {
|
|
|
88
78
|
this.live.set(platform, channel);
|
|
89
79
|
log.info(`${platform} started`);
|
|
90
80
|
}
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
* platform is not running, so the caller can say so instead of dropping it. */
|
|
81
|
+
/** For restart-note delivery (src/drain.ts), which has no session to report
|
|
82
|
+
* through; false means the platform is not running. */
|
|
94
83
|
async notify(platform, conversationId, text) {
|
|
95
84
|
const channel = this.live.get(platform);
|
|
96
85
|
if (!channel)
|
|
@@ -99,8 +88,8 @@ export class ChannelRuntime {
|
|
|
99
88
|
return true;
|
|
100
89
|
}
|
|
101
90
|
async stop() {
|
|
102
|
-
//
|
|
103
|
-
//
|
|
91
|
+
// An in-flight restart could otherwise register an adapter after `live`
|
|
92
|
+
// was cleared — running, unstoppable.
|
|
104
93
|
this.stopped = true;
|
|
105
94
|
await this.reloading.catch(() => { });
|
|
106
95
|
for (const channel of this.live.values()) {
|
|
@@ -1,37 +1,18 @@
|
|
|
1
|
-
// Thin Slack client: HTTP shapes and the Socket Mode transport, no policy
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// not require a public inbound URL. It needs two credentials — an app-level
|
|
7
|
-
// token (`xapp-`) opens the socket, the bot token (`xoxb-`) signs every Web API
|
|
8
|
-
// call — which is why ChannelConfig carries `appToken` beside `token`.
|
|
9
|
-
//
|
|
10
|
-
// No SDK: `apps.connections.open` plus Node's built-in WebSocket is the whole
|
|
11
|
-
// protocol, and @slack/socket-mode would pull a dependency tree to wrap it.
|
|
1
|
+
// Thin Slack client: HTTP shapes and the Socket Mode transport, no policy; the
|
|
2
|
+
// one file that talks to slack.com. Socket Mode so Pier needs no public inbound
|
|
3
|
+
// URL: the app-level token (`xapp-`) opens the socket, the bot token (`xoxb-`)
|
|
4
|
+
// signs every Web API call. No SDK: `apps.connections.open` plus Node's
|
|
5
|
+
// WebSocket is the whole protocol.
|
|
12
6
|
import { readCapped } from "../core/inbox.js";
|
|
13
7
|
const BASE = "https://slack.com/api";
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* Slack has no capability API to ask up front, so the only detection is a
|
|
20
|
-
* failed send. That makes the test's *narrowness* the whole safety property:
|
|
21
|
-
* the caller latches the answer for the process, so anything matched here
|
|
22
|
-
* degrades every later message too. `invalid_arguments` is deliberately NOT
|
|
23
|
-
* matched even though avibe lists it — avibe retries per message, where a
|
|
24
|
-
* broad match costs one fallback; latching turns the same breadth into a
|
|
25
|
-
* permanent downgrade triggered by an unrelated bad argument (a malformed
|
|
26
|
-
* `thread_ts` would silently cost the whole process its rendering). A wrong
|
|
27
|
-
* call should surface as an error, not as a quieter renderer.
|
|
28
|
-
*/
|
|
8
|
+
/** A workspace that predates the `markdown` block answers with one of these;
|
|
9
|
+
* there is no capability API, so a failed send is the only detection. The
|
|
10
|
+
* caller latches the answer for the process, so the match must stay narrow:
|
|
11
|
+
* `invalid_arguments` is deliberately not matched — a malformed `thread_ts`
|
|
12
|
+
* would otherwise permanently downgrade the renderer. */
|
|
29
13
|
export const isBlockRejection = (err) => /invalid_blocks|unsupported_block_type/.test(String(err));
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
* ended: Slack answers "too many connections" by accepting the socket and
|
|
33
|
-
* closing it straight away, which is not an error the loop would otherwise see.
|
|
34
|
-
*/
|
|
14
|
+
/** Younger than this was a failed attempt: Slack answers "too many
|
|
15
|
+
* connections" by accepting the socket and closing it straight away. */
|
|
35
16
|
const MIN_CONNECTION_MS = 5000;
|
|
36
17
|
const RECONNECT_FLOOR_MS = 1000;
|
|
37
18
|
const RECONNECT_MAX_MS = 30_000;
|
|
@@ -42,20 +23,16 @@ export class SlackApi {
|
|
|
42
23
|
openSocket;
|
|
43
24
|
socketRunning = false;
|
|
44
25
|
constructor(token, appToken, log = () => { },
|
|
45
|
-
/** Injected in tests
|
|
26
|
+
/** Injected in tests. */
|
|
46
27
|
openSocket = (url) => new WebSocket(url)) {
|
|
47
28
|
this.token = token;
|
|
48
29
|
this.appToken = appToken;
|
|
49
30
|
this.log = log;
|
|
50
31
|
this.openSocket = openSocket;
|
|
51
32
|
}
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* then reports the missing parameter — `users.info` answers `user_not_found`,
|
|
56
|
-
* which reads like "no such person" rather than "you sent the id in a place I
|
|
57
|
-
* do not look". So reads go form-encoded. This was worth four broken calls.
|
|
58
|
-
*/
|
|
33
|
+
/** Slack accepts a JSON body only on write methods; a read method silently
|
|
34
|
+
* ignores it and reports the parameter missing (`users.info` answers
|
|
35
|
+
* `user_not_found`). So reads go form-encoded. */
|
|
59
36
|
async read(method, params) {
|
|
60
37
|
const form = new URLSearchParams();
|
|
61
38
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -77,9 +54,8 @@ export class SlackApi {
|
|
|
77
54
|
body: form ? payload.toString() : JSON.stringify(payload),
|
|
78
55
|
signal: AbortSignal.timeout(30_000),
|
|
79
56
|
});
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// reply into a late one; a second 429 is a real problem and throws.
|
|
57
|
+
// A long turn split into chunks hits ~1 msg/s per channel; the header
|
|
58
|
+
// carries the exact wait. Obeyed once; a second 429 throws.
|
|
83
59
|
if (res.status === 429 && retry) {
|
|
84
60
|
const after = Number(res.headers.get("retry-after") ?? "1");
|
|
85
61
|
if (Number.isFinite(after) && after <= 60) {
|
|
@@ -99,32 +75,26 @@ export class SlackApi {
|
|
|
99
75
|
return { userId: body.user_id ?? "" };
|
|
100
76
|
}
|
|
101
77
|
// --- Socket Mode -----------------------------------------------------------
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
* cycles a connection every few hours with `disconnect: refresh_requested`,
|
|
105
|
-
* so the loop reopens until `close()` clears the flag.
|
|
106
|
-
*/
|
|
78
|
+
/** Slack cycles a connection every few hours with `disconnect:
|
|
79
|
+
* refresh_requested`, so the loop reopens until `close()` clears the flag. */
|
|
107
80
|
async connect(onEnvelope) {
|
|
108
81
|
this.socketRunning = true;
|
|
109
82
|
let socket;
|
|
110
83
|
const run = async () => {
|
|
111
84
|
let backoff = RECONNECT_FLOOR_MS;
|
|
112
85
|
while (this.socketRunning) {
|
|
113
|
-
// Set once the socket exists,
|
|
114
|
-
// make a connection that died instantly look like a healthy one.
|
|
86
|
+
// Set once the socket exists, not before `apps.connections.open`.
|
|
115
87
|
let connectedAt = 0;
|
|
116
88
|
try {
|
|
117
89
|
const open = await this.call("apps.connections.open", {}, this.appToken);
|
|
118
90
|
if (!open.url)
|
|
119
91
|
throw new Error("apps.connections.open returned no url");
|
|
120
|
-
// stop() may have landed while that call was in flight.
|
|
121
|
-
// would leave a live socket nobody holds a reference to.
|
|
92
|
+
// stop() may have landed while that call was in flight.
|
|
122
93
|
if (!this.socketRunning)
|
|
123
94
|
return;
|
|
124
95
|
socket = this.openSocket(open.url);
|
|
125
96
|
connectedAt = Date.now();
|
|
126
|
-
// Resolves on close, never rejects: a dropped socket is normal
|
|
127
|
-
// the loop's job is to reopen it, not to treat it as an error.
|
|
97
|
+
// Resolves on close, never rejects: a dropped socket is normal.
|
|
128
98
|
await new Promise((resolve) => {
|
|
129
99
|
const ws = socket;
|
|
130
100
|
ws.onmessage = (ev) => {
|
|
@@ -133,13 +103,11 @@ export class SlackApi {
|
|
|
133
103
|
env = JSON.parse(String(ev.data));
|
|
134
104
|
}
|
|
135
105
|
catch {
|
|
136
|
-
// Validate at the boundary: log and drop, never half-handle.
|
|
137
106
|
this.log(`unparseable socket frame dropped`);
|
|
138
107
|
return;
|
|
139
108
|
}
|
|
140
|
-
// Ack first
|
|
141
|
-
//
|
|
142
|
-
// acknowledged.
|
|
109
|
+
// Ack first: a turn outlives the deadline, and an unacked
|
|
110
|
+
// envelope is redelivered.
|
|
143
111
|
if (env.envelope_id) {
|
|
144
112
|
try {
|
|
145
113
|
ws.send(JSON.stringify({ envelope_id: env.envelope_id }));
|
|
@@ -151,8 +119,6 @@ export class SlackApi {
|
|
|
151
119
|
if (env.type === "hello")
|
|
152
120
|
return;
|
|
153
121
|
if (env.type === "disconnect") {
|
|
154
|
-
// Expected: Slack recycles connections. Closing resolves the
|
|
155
|
-
// promise below and the loop reopens.
|
|
156
122
|
this.log(`socket disconnect (${env.reason ?? "no reason"}), reconnecting`);
|
|
157
123
|
ws.close();
|
|
158
124
|
return;
|
|
@@ -170,10 +136,7 @@ export class SlackApi {
|
|
|
170
136
|
}
|
|
171
137
|
if (!this.socketRunning)
|
|
172
138
|
return;
|
|
173
|
-
//
|
|
174
|
-
// next attempt starts from the floor again; one that died young — or
|
|
175
|
-
// threw — backs off, because reopening instantly would hammer
|
|
176
|
-
// apps.connections.open in a tight loop.
|
|
139
|
+
// A socket that lived a while was healthy; one that died young backs off.
|
|
177
140
|
if (connectedAt && Date.now() - connectedAt >= MIN_CONNECTION_MS) {
|
|
178
141
|
backoff = RECONNECT_FLOOR_MS;
|
|
179
142
|
}
|
|
@@ -219,7 +182,6 @@ export class SlackApi {
|
|
|
219
182
|
await this.call("reactions.add", { channel, timestamp: ts, name });
|
|
220
183
|
}
|
|
221
184
|
catch (err) {
|
|
222
|
-
// The reaction is already where we want it; that is a success.
|
|
223
185
|
if (!String(err).includes("already_reacted"))
|
|
224
186
|
throw err;
|
|
225
187
|
}
|
|
@@ -249,7 +211,7 @@ export class SlackApi {
|
|
|
249
211
|
}
|
|
250
212
|
async page(method, params) {
|
|
251
213
|
const body = await this.read(method, params);
|
|
252
|
-
//
|
|
214
|
+
// Slack sends `""` for "no more" rather than omitting it.
|
|
253
215
|
const next = body.response_metadata?.next_cursor;
|
|
254
216
|
return { messages: body.messages ?? [], nextCursor: next || undefined };
|
|
255
217
|
}
|
|
@@ -267,26 +229,21 @@ export class SlackApi {
|
|
|
267
229
|
return this.page("conversations.replies", {
|
|
268
230
|
channel,
|
|
269
231
|
ts,
|
|
270
|
-
// Slack drops the boundary message unless asked; the caller
|
|
271
|
-
// filters for itself, the same as `history` above.
|
|
232
|
+
// Slack drops the boundary message unless asked; the caller filters for itself.
|
|
272
233
|
oldest: query.oldest,
|
|
273
234
|
inclusive: true,
|
|
274
235
|
limit: query.limit ?? 200,
|
|
275
236
|
cursor: query.cursor,
|
|
276
237
|
});
|
|
277
238
|
}
|
|
278
|
-
/**
|
|
279
|
-
* Slack file URLs are private: they need the bot token as a bearer header and
|
|
280
|
-
* answer HTML (a login page) rather than an error when it is missing.
|
|
281
|
-
*/
|
|
282
|
-
/** A read method, so form-encoded (see read()); `id` is the `F…` id. */
|
|
283
239
|
async filesInfo(id) {
|
|
284
240
|
const body = await this.read("files.info", { file: id });
|
|
285
|
-
// `ok` without a file would leave the caller downloading `undefined`.
|
|
286
241
|
if (!body.file)
|
|
287
242
|
throw new Error("slack files.info: no file in the response");
|
|
288
243
|
return body.file;
|
|
289
244
|
}
|
|
245
|
+
/** File URLs need the bot token as a bearer header, and answer HTML (a login
|
|
246
|
+
* page) rather than an error without it. */
|
|
290
247
|
async downloadFile(file, maxBytes) {
|
|
291
248
|
const url = file.url_private_download ?? file.url_private;
|
|
292
249
|
if (!url)
|
|
@@ -298,21 +255,13 @@ export class SlackApi {
|
|
|
298
255
|
if (!res.ok)
|
|
299
256
|
throw new Error(`slack file download: ${res.status}`);
|
|
300
257
|
const mimeType = res.headers.get("content-type")?.split(";")[0] ?? file.mimetype ?? "application/octet-stream";
|
|
301
|
-
// Bounded mid-stream: the event's size metadata is the platform's word.
|
|
302
258
|
return { bytes: await readCapped(res.body, maxBytes), mimeType };
|
|
303
259
|
}
|
|
304
|
-
/**
|
|
305
|
-
*
|
|
306
|
-
* one-shot URL, POST the bytes to it (that host is not the Web API and
|
|
307
|
-
* answers with plain text, not JSON), then tell Slack where the file goes.
|
|
308
|
-
* `files.upload` did it in one, and is retired.
|
|
309
|
-
*/
|
|
260
|
+
/** Three calls is Slack's current upload (`files.upload` is retired); the
|
|
261
|
+
* upload host is not the Web API and answers plain text. */
|
|
310
262
|
async uploadFile(channel, threadTs, file) {
|
|
311
|
-
// A read method: form-encoded, or Slack ignores the body (see read()).
|
|
312
263
|
const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
|
|
313
|
-
//
|
|
314
|
-
// this reads as a mysterious refusal in the chat. Name the fix instead:
|
|
315
|
-
// the manifest is only applied when an app is *created*.
|
|
264
|
+
// The manifest is only applied when an app is created; name the fix.
|
|
316
265
|
if (!/missing_scope/.test(String(err)))
|
|
317
266
|
throw err;
|
|
318
267
|
throw new Error("the Slack app is missing the files:write scope — add it under " +
|
|
@@ -324,8 +273,7 @@ export class SlackApi {
|
|
|
324
273
|
const put = await fetch(slot.upload_url, {
|
|
325
274
|
method: "POST",
|
|
326
275
|
headers: { "content-type": "application/octet-stream" },
|
|
327
|
-
//
|
|
328
|
-
// ArrayBuffer, and a Buffer read off disk is the wider ArrayBufferLike.
|
|
276
|
+
// A request body must be backed by an ArrayBuffer, not a Buffer's ArrayBufferLike.
|
|
329
277
|
body: new Uint8Array(file.bytes),
|
|
330
278
|
signal: AbortSignal.timeout(120_000),
|
|
331
279
|
});
|
|
@@ -1,15 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// per message and per read. Neither answer changes in practice, so the cache is
|
|
6
|
-
// process-lifetime and shared: the adapter and the agent-facing tool ask the
|
|
7
|
-
// same instance, which is why a repeated `read_thread` costs no lookups at all.
|
|
8
|
-
//
|
|
9
|
-
// Failures are logged and fall back to the id, never swallowed: without
|
|
10
|
-
// `users:read` this fails for every message, and the only symptom used to be an
|
|
11
|
-
// agent telling the human "Slack does not expose your display name" — a scope
|
|
12
|
-
// problem wearing a product problem's clothes.
|
|
1
|
+
// User display names and channel kind/name, cached for the process and shared
|
|
2
|
+
// by the adapter and the agent-facing tool. Failures fall back to the id and
|
|
3
|
+
// are logged: without `users:read` every lookup fails, and the symptom is
|
|
4
|
+
// otherwise a scope problem wearing a product problem's clothes.
|
|
13
5
|
export class SlackDirectory {
|
|
14
6
|
log;
|
|
15
7
|
channels = new Map();
|
|
@@ -17,11 +9,8 @@ export class SlackDirectory {
|
|
|
17
9
|
constructor(log) {
|
|
18
10
|
this.log = log;
|
|
19
11
|
}
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
* both. The event usually settles the kind for free (`channel_type`), and a
|
|
23
|
-
* `D`-prefixed id is always a DM — but only the lookup knows the name.
|
|
24
|
-
*/
|
|
12
|
+
/** The event usually settles the kind for free (`channel_type`, or a
|
|
13
|
+
* `D`-prefixed id); only the lookup knows the name. */
|
|
25
14
|
async channel(api, channel, event) {
|
|
26
15
|
const cached = this.channels.get(channel);
|
|
27
16
|
if (cached)
|
|
@@ -31,8 +20,6 @@ export class SlackDirectory {
|
|
|
31
20
|
: channel.startsWith("D")
|
|
32
21
|
? "dm"
|
|
33
22
|
: undefined;
|
|
34
|
-
// A DM's name comes from its member, not from the channel, so a DM settled
|
|
35
|
-
// by the event needs no lookup at all.
|
|
36
23
|
if (fromEvent === "dm") {
|
|
37
24
|
const facts = { kind: fromEvent };
|
|
38
25
|
this.channels.set(channel, facts);
|
|
@@ -42,8 +29,7 @@ export class SlackDirectory {
|
|
|
42
29
|
this.log(`conversations.info failed for ${channel}: ${String(err)}`);
|
|
43
30
|
return undefined;
|
|
44
31
|
});
|
|
45
|
-
// Uncached on failure, so the next message retries
|
|
46
|
-
// guess for the life of the process.
|
|
32
|
+
// Uncached on failure, so the next message retries.
|
|
47
33
|
if (!info)
|
|
48
34
|
return { kind: fromEvent ?? "group" };
|
|
49
35
|
const facts = {
|
|
@@ -53,7 +39,6 @@ export class SlackDirectory {
|
|
|
53
39
|
this.channels.set(channel, facts);
|
|
54
40
|
return facts;
|
|
55
41
|
}
|
|
56
|
-
/** Display name for a user id; the id itself when Slack will not say. */
|
|
57
42
|
async user(api, userId) {
|
|
58
43
|
const hit = this.users.get(userId);
|
|
59
44
|
if (hit !== undefined)
|
|
@@ -65,7 +50,6 @@ export class SlackDirectory {
|
|
|
65
50
|
this.users.set(userId, name);
|
|
66
51
|
return name;
|
|
67
52
|
}
|
|
68
|
-
/** Names for every speaker in a transcript, in one pass. */
|
|
69
53
|
async names(api, userIds) {
|
|
70
54
|
const out = new Map();
|
|
71
55
|
for (const id of userIds) {
|