@timqi/pier 0.1.3 → 0.1.4
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 +8 -3
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +1 -2
- package/dist/agent/packages.js +1 -7
- package/dist/agent/pi.js +6 -47
- package/dist/channels/config.js +30 -20
- package/dist/channels/routes.js +0 -3
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +1 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +9 -17
- package/dist/channels/types.js +0 -1
- package/dist/cli.js +158 -7
- package/dist/core/identity.js +20 -11
- package/dist/core/reply.js +16 -15
- package/dist/core/router.js +4 -1
- package/dist/db.js +28 -0
- package/dist/main.js +19 -32
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/socket.js +95 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +215 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +323 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +17 -0
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/vault.js +68 -0
- package/docs/deploy.md +33 -10
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +10 -6
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +48 -145
- package/skills/pier-vault/SKILL.md +36 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
- package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
- package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
- package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
package/README.md
CHANGED
|
@@ -37,8 +37,8 @@ otherwise).
|
|
|
37
37
|
**The first start generates a password and prints it once.** Lost it?
|
|
38
38
|
`sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'` and restart. Open
|
|
39
39
|
`http://localhost:3141`, sign in; **Console → Settings** configures Models,
|
|
40
|
-
Agent, Channels, the public URL, password and master key; **New session**
|
|
41
|
-
the directory the agent's shell runs in.
|
|
40
|
+
Agent, Channels, Vault, the public URL, password and master key; **New session**
|
|
41
|
+
picks the directory the agent's shell runs in.
|
|
42
42
|
|
|
43
43
|
## Configure Pi
|
|
44
44
|
|
|
@@ -67,7 +67,12 @@ Console → Settings:
|
|
|
67
67
|
copy of yours registering the same tool makes Pier's stand down) and the
|
|
68
68
|
managed CLI tools (`rtk`, `rg`, `fd`, `wt`, `jq`, or your own as a
|
|
69
69
|
[ubix](https://github.com/timqi/ubix) block), installed into
|
|
70
|
-
`~/.pier/tools/bin`, first on every session's PATH
|
|
70
|
+
`~/.pier/tools/bin`, first on every session's PATH beside the `pier` shim
|
|
71
|
+
Pier writes there at start.
|
|
72
|
+
- **Vault** — named secrets an agent's commands receive through `pier vault
|
|
73
|
+
run` without the value entering its context (`docs/design/07-vault.md`);
|
|
74
|
+
`pier slack` and `pier task` reach the running Pier the same way
|
|
75
|
+
(`docs/design/08-cli-socket.md`).
|
|
71
76
|
|
|
72
77
|
On first credential access an existing `auth.json` is imported into the sealed
|
|
73
78
|
store and renamed `auth.json.imported`; literal keys in `models.json` likewise
|
package/dist/agent/config.js
CHANGED
|
@@ -15,9 +15,15 @@ const PROJECT_FILES = ["AGENTS.md"];
|
|
|
15
15
|
const READONLY_FILES = ["settings.json"];
|
|
16
16
|
const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
|
|
17
17
|
/** What a first boot writes (docs/deploy.md says why each key): the list the
|
|
18
|
-
* Console installs into,
|
|
19
|
-
* person to survey
|
|
20
|
-
|
|
18
|
+
* Console installs into, Pi's attribution headers off — a server is not a
|
|
19
|
+
* person to survey — and a retry budget of minutes, because an unattended
|
|
20
|
+
* instance has nobody to re-ask after Pi's 14-second default gives up. Every
|
|
21
|
+
* other key stays Pi's default by omission. */
|
|
22
|
+
const SEED_SETTINGS = {
|
|
23
|
+
packages: [],
|
|
24
|
+
enableInstallTelemetry: false,
|
|
25
|
+
retry: { maxRetries: 5, baseDelayMs: 5000 },
|
|
26
|
+
};
|
|
21
27
|
/** Pier owns the Pi runtime dir; main.ts exports it as PI_CODING_AGENT_DIR. */
|
|
22
28
|
export const defaultAgentDir = () => process.env.PI_CODING_AGENT_DIR ?? pierPath("pi");
|
|
23
29
|
/** Stable mask: mapped back by field, without exposing key fragments. */
|
package/dist/agent/events.js
CHANGED
|
@@ -33,8 +33,7 @@ function systemOrigin(message) {
|
|
|
33
33
|
}
|
|
34
34
|
if (origin.kind === "task-message" &&
|
|
35
35
|
typeof origin.messageId === "string" &&
|
|
36
|
-
(origin.messageKind === "steer" || origin.messageKind === "follow_up"
|
|
37
|
-
origin.messageKind === "progress" || origin.messageKind === "decision" || origin.messageKind === "reply"))
|
|
36
|
+
(origin.messageKind === "steer" || origin.messageKind === "follow_up"))
|
|
38
37
|
return shape;
|
|
39
38
|
return null;
|
|
40
39
|
}
|
package/dist/agent/packages.js
CHANGED
|
@@ -14,8 +14,6 @@ const log = logger("packages");
|
|
|
14
14
|
/** Set by how long a released fix may sit unnoticed, like update.ts. */
|
|
15
15
|
const CHECK_EVERY_MS = 24 * 60 * 60_000;
|
|
16
16
|
const KINDS = { extensions: "extension", skills: "skill" };
|
|
17
|
-
/** The state line for a skill whose tool this instance is not given (docs/design/03). */
|
|
18
|
-
const FOLLOWS_TOOL = "follows Channels → agent tool";
|
|
19
17
|
/** Written by `rtk init -g --agent pi` (the rtk block's `post_install` hook): a `local` file whose
|
|
20
18
|
* switch is the rtk tool's, so a settings.json pattern would fight the tool. */
|
|
21
19
|
const RTK_FILE = join("extensions", "rtk.ts");
|
|
@@ -160,16 +158,12 @@ export class PiPackageStore {
|
|
|
160
158
|
kind: "extension", name, path: `<inline:${name}>`, enabled: on.includes(name),
|
|
161
159
|
state: on.includes(name) ? shadowedBuiltin(name) : null,
|
|
162
160
|
}));
|
|
163
|
-
const gone = new Set(this.pier.tools.filter((t) => t.skill && !(t.available?.() ?? true)).map((t) => t.skill));
|
|
164
161
|
for (const dir of this.skillDirs) {
|
|
165
162
|
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory())) {
|
|
166
163
|
const path = join(dir, entry.name, "SKILL.md");
|
|
167
164
|
if (!existsSync(path))
|
|
168
165
|
continue;
|
|
169
|
-
pkg.resources.push({
|
|
170
|
-
kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name),
|
|
171
|
-
state: gone.has(entry.name) ? FOLLOWS_TOOL : null,
|
|
172
|
-
});
|
|
166
|
+
pkg.resources.push({ kind: "skill", name: entry.name, path, enabled: !off.includes(entry.name), state: null });
|
|
173
167
|
}
|
|
174
168
|
}
|
|
175
169
|
}
|
package/dist/agent/pi.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
|
-
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader,
|
|
6
|
+
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { inlineExtensions } from "../extensions/index.js";
|
|
8
8
|
import { SESSION_TITLE_MAX } from "../core/types.js";
|
|
9
9
|
import { logger } from "../log.js";
|
|
@@ -140,14 +140,6 @@ export const standDownShadowed = (base) => {
|
|
|
140
140
|
}),
|
|
141
141
|
};
|
|
142
142
|
};
|
|
143
|
-
/** A skill's description is resident in every prompt; one pointing at a tool
|
|
144
|
-
* this session was not given is a route the agent cannot take. */
|
|
145
|
-
export const standDownUndocumented = (tools, skills) => {
|
|
146
|
-
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
147
|
-
if (!gone.size)
|
|
148
|
-
return skills;
|
|
149
|
-
return skills.filter((skill) => !gone.has(skill.name));
|
|
150
|
-
};
|
|
151
143
|
export class PiSession {
|
|
152
144
|
pi;
|
|
153
145
|
pinned;
|
|
@@ -400,7 +392,6 @@ export class PiSession {
|
|
|
400
392
|
}
|
|
401
393
|
}
|
|
402
394
|
export class PiAgentFactory {
|
|
403
|
-
extraTools;
|
|
404
395
|
instructions;
|
|
405
396
|
skillPaths;
|
|
406
397
|
credentials;
|
|
@@ -409,9 +400,9 @@ export class PiAgentFactory {
|
|
|
409
400
|
pier;
|
|
410
401
|
titleModel;
|
|
411
402
|
listings;
|
|
412
|
-
constructor(
|
|
413
|
-
/**
|
|
414
|
-
*
|
|
403
|
+
constructor(
|
|
404
|
+
/** Read per session open, so a Console change reaches the next session
|
|
405
|
+
* without a restart; appended as a context file so the user's own
|
|
415
406
|
* instructions still win. */
|
|
416
407
|
instructions = () => "",
|
|
417
408
|
/** Loaded per session, never installed into the user's skill directories. */
|
|
@@ -423,7 +414,6 @@ export class PiAgentFactory {
|
|
|
423
414
|
pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
|
|
424
415
|
/** Injected so a test needs no session directory or database. */
|
|
425
416
|
listings = new IndexedListing()) {
|
|
426
|
-
this.extraTools = extraTools;
|
|
427
417
|
this.instructions = instructions;
|
|
428
418
|
this.skillPaths = skillPaths;
|
|
429
419
|
this.credentials = credentials;
|
|
@@ -628,7 +618,7 @@ export class PiAgentFactory {
|
|
|
628
618
|
// same name is Pi's to switch (settings.json).
|
|
629
619
|
skillsOverride: (base) => ({
|
|
630
620
|
...base,
|
|
631
|
-
skills:
|
|
621
|
+
skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
|
|
632
622
|
}),
|
|
633
623
|
extensionFactories: [
|
|
634
624
|
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
@@ -651,36 +641,6 @@ export class PiAgentFactory {
|
|
|
651
641
|
return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
|
|
652
642
|
}
|
|
653
643
|
async openSnapshot(cwd, sessionManager, opts) {
|
|
654
|
-
let live;
|
|
655
|
-
// Per open: an unconfigured tool would cost context on every turn and answer nothing.
|
|
656
|
-
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
657
|
-
const customTools = active.map((tool) => defineTool({
|
|
658
|
-
name: tool.name,
|
|
659
|
-
label: tool.label,
|
|
660
|
-
description: tool.description,
|
|
661
|
-
parameters: tool.parameters,
|
|
662
|
-
execute: async (_id, params, signal) => {
|
|
663
|
-
const caller = live?.sessionId ?? "unknown";
|
|
664
|
-
log.debug(`tool ${tool.name} called by ${caller}`);
|
|
665
|
-
try {
|
|
666
|
-
return {
|
|
667
|
-
content: [
|
|
668
|
-
{
|
|
669
|
-
type: "text",
|
|
670
|
-
// Pretty-printing costs ~20% more tokens and buys the model nothing.
|
|
671
|
-
text: JSON.stringify(await tool.execute(params, caller, signal)),
|
|
672
|
-
},
|
|
673
|
-
],
|
|
674
|
-
details: {},
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
catch (err) {
|
|
678
|
-
// Pi turns this into tool-result text: the right recovery, the wrong record.
|
|
679
|
-
log.warn(`tool ${tool.name} failed for ${caller}`, err);
|
|
680
|
-
throw err;
|
|
681
|
-
}
|
|
682
|
-
},
|
|
683
|
-
}));
|
|
684
644
|
// A locked store is a refusal with a reason here, not "provider not
|
|
685
645
|
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
686
646
|
this.credentials?.assertUnlocked();
|
|
@@ -696,11 +656,10 @@ export class PiAgentFactory {
|
|
|
696
656
|
const created = await createAgentSession({
|
|
697
657
|
cwd,
|
|
698
658
|
sessionManager,
|
|
699
|
-
customTools,
|
|
700
659
|
modelRuntime: runtime,
|
|
701
660
|
resourceLoader: await this.resourceLoader(cwd),
|
|
702
661
|
});
|
|
703
|
-
live = created.session;
|
|
662
|
+
const live = created.session;
|
|
704
663
|
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
705
664
|
// N turns. The agent's setter flips only the in-memory queue;
|
|
706
665
|
// `session.setFollowUpMode` would persist it to Pi's settings.json.
|
package/dist/channels/config.js
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
// Channel config persistence and the permission gate every adapter shares: one
|
|
2
2
|
// JSON document per platform, so a surface configuring one reads and writes one row.
|
|
3
3
|
import { randomInt } from "node:crypto";
|
|
4
|
-
import { pierDb } from "../db.js";
|
|
5
|
-
import { isSealed } from "../secrets.js";
|
|
6
4
|
import { defaultChannelConfig, } from "./types.js";
|
|
7
5
|
const BIND_CODE_TTL_MS = 10 * 60_000;
|
|
8
6
|
/** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
|
|
9
7
|
* needs the TTL; five wrong tries void the code instead. */
|
|
10
8
|
const BIND_CODE_TRIES = 5;
|
|
11
9
|
const BIND_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
10
|
+
/** Where each platform's credentials live in the vault — fixed names, so a
|
|
11
|
+
* skill and the Console agree without a setting. Migration 24 spells the same
|
|
12
|
+
* table in SQL. Telegram authenticates with one token. */
|
|
13
|
+
const CREDENTIAL_NAMES = {
|
|
14
|
+
telegram: { token: "TELEGRAM_TOKEN" },
|
|
15
|
+
slack: { token: "SLACK_TOKEN", appToken: "SLACK_APP_TOKEN" },
|
|
16
|
+
lark: { token: "LARK_APP_ID", appToken: "LARK_APP_SECRET" },
|
|
17
|
+
};
|
|
18
|
+
const credentials = (platform) => Object.entries(CREDENTIAL_NAMES[platform]);
|
|
12
19
|
export class ChannelStore {
|
|
13
|
-
secrets;
|
|
14
20
|
db;
|
|
21
|
+
vault;
|
|
15
22
|
cache = new Map();
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
/** The row never holds a credential: `token`/`appToken` are filled from the
|
|
24
|
+
* vault on read and filed there on save. A locked vault throws rather than
|
|
25
|
+
* serving a token it cannot read. */
|
|
26
|
+
constructor(db, vault) {
|
|
20
27
|
this.db = db;
|
|
28
|
+
this.vault = vault;
|
|
21
29
|
}
|
|
22
30
|
/** Private: handed out, a caller could mutate config without saving. */
|
|
23
31
|
cached(platform) {
|
|
@@ -28,11 +36,8 @@ export class ChannelStore {
|
|
|
28
36
|
const config = row
|
|
29
37
|
? { ...defaultChannelConfig(), ...JSON.parse(row.json) }
|
|
30
38
|
: defaultChannelConfig();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (isSealed(config[key]))
|
|
34
|
-
config[key] = this.secrets.decrypt(config[key]);
|
|
35
|
-
}
|
|
39
|
+
for (const [key, name] of credentials(platform)) {
|
|
40
|
+
config[key] = this.vault.get(name) ?? "";
|
|
36
41
|
}
|
|
37
42
|
this.cache.set(platform, config);
|
|
38
43
|
return config;
|
|
@@ -41,16 +46,21 @@ export class ChannelStore {
|
|
|
41
46
|
return structuredClone(this.cached(platform));
|
|
42
47
|
}
|
|
43
48
|
save(platform, config) {
|
|
49
|
+
// The cache holds plaintext; the row holds neither key. Only a changed
|
|
50
|
+
// credential touches the vault, so its `updated` is the rotation, not the
|
|
51
|
+
// last chat discovered; an emptied field removes the row.
|
|
52
|
+
const before = this.cached(platform);
|
|
53
|
+
for (const [key, name] of credentials(platform)) {
|
|
54
|
+
if (config[key] === before[key])
|
|
55
|
+
continue;
|
|
56
|
+
if (config[key])
|
|
57
|
+
this.vault.seal(name, config[key]);
|
|
58
|
+
else
|
|
59
|
+
this.vault.remove(name);
|
|
60
|
+
}
|
|
44
61
|
// Cloned, so the caller's object cannot reach into the cache later.
|
|
45
62
|
this.cache.set(platform, structuredClone(config));
|
|
46
|
-
|
|
47
|
-
const stored = this.secrets ? structuredClone(config) : config;
|
|
48
|
-
if (this.secrets) {
|
|
49
|
-
for (const key of ["token", "appToken"]) {
|
|
50
|
-
if (stored[key])
|
|
51
|
-
stored[key] = this.secrets.encrypt(stored[key]);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
63
|
+
const { token: _token, appToken: _appToken, ...stored } = config;
|
|
54
64
|
this.db.prepare(`
|
|
55
65
|
INSERT INTO channels(platform, json) VALUES (?, ?)
|
|
56
66
|
ON CONFLICT(platform) DO UPDATE SET json = excluded.json
|
package/dist/channels/routes.js
CHANGED
|
@@ -70,9 +70,6 @@ export function registerChannelRoutes(app, store, runtime) {
|
|
|
70
70
|
enabled: asBool(body.enabled),
|
|
71
71
|
token: kept(asString(body.token), current.token),
|
|
72
72
|
appToken: kept(asString(body.appToken), current.appToken),
|
|
73
|
-
// Absent means "on": a client that predates the field must not silently
|
|
74
|
-
// switch off a capability the operator never touched.
|
|
75
|
-
agentTool: body.agentTool === undefined ? current.agentTool : asBool(body.agentTool),
|
|
76
73
|
requireMention: asBool(body.requireMention),
|
|
77
74
|
requireBind: asBool(body.requireBind),
|
|
78
75
|
topicMode: asBool(body.topicMode),
|
|
@@ -21,18 +21,24 @@ export class SlackApi {
|
|
|
21
21
|
appToken;
|
|
22
22
|
log;
|
|
23
23
|
openSocket;
|
|
24
|
+
retries;
|
|
24
25
|
socketRunning = false;
|
|
25
26
|
constructor(token, appToken, log = () => { },
|
|
26
27
|
/** Injected in tests. */
|
|
27
|
-
openSocket = (url) => new WebSocket(url)
|
|
28
|
+
openSocket = (url) => new WebSocket(url),
|
|
29
|
+
/** How many rate-limit waits one call sits through. The adapter takes one:
|
|
30
|
+
* a turn must not hang for minutes. A batch process (`pier slack`) takes more. */
|
|
31
|
+
retries = 1) {
|
|
28
32
|
this.token = token;
|
|
29
33
|
this.appToken = appToken;
|
|
30
34
|
this.log = log;
|
|
31
35
|
this.openSocket = openSocket;
|
|
36
|
+
this.retries = retries;
|
|
32
37
|
}
|
|
33
38
|
/** Slack accepts a JSON body only on write methods; a read method silently
|
|
34
39
|
* ignores it and reports the parameter missing (`users.info` answers
|
|
35
|
-
* `user_not_found`). So reads go form-encoded.
|
|
40
|
+
* `user_not_found`). So reads go form-encoded. Public for the long tail of
|
|
41
|
+
* read methods `pier slack` needs and the adapter never calls. */
|
|
36
42
|
async read(method, params) {
|
|
37
43
|
const form = new URLSearchParams();
|
|
38
44
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -41,7 +47,7 @@ export class SlackApi {
|
|
|
41
47
|
}
|
|
42
48
|
return this.call(method, form);
|
|
43
49
|
}
|
|
44
|
-
async call(method, payload, token = this.token,
|
|
50
|
+
async call(method, payload, token = this.token, tries = this.retries) {
|
|
45
51
|
const form = payload instanceof URLSearchParams;
|
|
46
52
|
const res = await fetch(`${BASE}/${method}`, {
|
|
47
53
|
method: "POST",
|
|
@@ -54,16 +60,17 @@ export class SlackApi {
|
|
|
54
60
|
body: form ? payload.toString() : JSON.stringify(payload),
|
|
55
61
|
signal: AbortSignal.timeout(30_000),
|
|
56
62
|
});
|
|
63
|
+
const body = (await res.json().catch(() => null));
|
|
57
64
|
// A long turn split into chunks hits ~1 msg/s per channel; the header
|
|
58
|
-
// carries the exact wait.
|
|
59
|
-
|
|
65
|
+
// carries the exact wait. Some methods say `ratelimited` in a 200 body
|
|
66
|
+
// instead. Obeyed `retries` times; the next one throws.
|
|
67
|
+
if ((res.status === 429 || body?.error === "ratelimited") && tries > 0) {
|
|
60
68
|
const after = Number(res.headers.get("retry-after") ?? "1");
|
|
61
69
|
if (Number.isFinite(after) && after <= 60) {
|
|
62
70
|
await new Promise((r) => setTimeout(r, (after + 1) * 1000));
|
|
63
|
-
return this.call(method, payload, token,
|
|
71
|
+
return this.call(method, payload, token, tries - 1);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
|
-
const body = (await res.json().catch(() => null));
|
|
67
74
|
if (!body)
|
|
68
75
|
throw new Error(`slack ${method}: ${res.status} with no JSON body`);
|
|
69
76
|
if (!body.ok)
|
|
@@ -259,7 +266,7 @@ export class SlackApi {
|
|
|
259
266
|
}
|
|
260
267
|
/** Three calls is Slack's current upload (`files.upload` is retired); the
|
|
261
268
|
* upload host is not the Web API and answers plain text. */
|
|
262
|
-
async uploadFile(channel, threadTs, file) {
|
|
269
|
+
async uploadFile(channel, threadTs, file, comment) {
|
|
263
270
|
const slot = await this.read("files.getUploadURLExternal", { filename: file.name, length: file.bytes.length }).catch((err) => {
|
|
264
271
|
// The manifest is only applied when an app is created; name the fix.
|
|
265
272
|
if (!/missing_scope/.test(String(err)))
|
|
@@ -279,10 +286,12 @@ export class SlackApi {
|
|
|
279
286
|
});
|
|
280
287
|
if (!put.ok)
|
|
281
288
|
throw new Error(`slack file upload: ${put.status}`);
|
|
282
|
-
await this.call("files.completeUploadExternal", {
|
|
289
|
+
const done = await this.call("files.completeUploadExternal", {
|
|
283
290
|
files: [{ id: slot.file_id, title: file.name }],
|
|
284
291
|
channel_id: channel,
|
|
285
|
-
thread_ts: threadTs,
|
|
292
|
+
thread_ts: threadTs || undefined,
|
|
293
|
+
initial_comment: comment,
|
|
286
294
|
});
|
|
295
|
+
return { id: done.files?.[0]?.id ?? slot.file_id };
|
|
287
296
|
}
|
|
288
297
|
}
|