@timqi/pier 0.1.2 → 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 +63 -59
- package/dist/agent/events.js +1 -2
- package/dist/agent/listing.js +38 -15
- package/dist/agent/packages.js +347 -0
- package/dist/agent/pi.js +33 -59
- 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/core/types.js +7 -0
- package/dist/db.js +28 -0
- package/dist/extensions/index.js +1 -20
- package/dist/main.js +34 -42
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +5 -0
- 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 +63 -85
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/config.js +25 -11
- package/dist/web/explorer.js +5 -3
- package/dist/web/instance.js +6 -18
- package/dist/web/packages.js +84 -0
- package/dist/web/public/assets/{activity-DOr8dWeX.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-DleegoiC.js +1 -0
- 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-vJvV1Sx0.js +4 -0
- 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-Bs-gol9o.css +2 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/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-C5FveZ75.js +1 -0
- 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-CJJ2j5Ks.js +3 -0
- 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-R2Pv0Abg.js +4 -0
- 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/server.js +3 -1
- package/dist/web/vault.js +68 -0
- package/docs/deploy.md +42 -8
- 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-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js +0 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +0 -85
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
- 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-Cv0A-e08.js +0 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
package/dist/agent/pi.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
|
|
3
3
|
// Pi SDK. No Pi type may appear in an exported signature.
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
|
-
import { basename, dirname, join } from "node:path";
|
|
6
|
-
import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader,
|
|
5
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
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";
|
|
@@ -112,32 +112,34 @@ const bashTimeoutDefault = (pi) => {
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
};
|
|
115
|
+
/** What the last session open stood down, by bundled extension name: the
|
|
116
|
+
* registry (agent/packages.ts) shows it as the row's state. Known only at
|
|
117
|
+
* open, so a built-in no session has loaded since reads nothing. */
|
|
118
|
+
const shadowed = new Map();
|
|
119
|
+
export const shadowedBuiltin = (name) => shadowed.get(name) ?? null;
|
|
115
120
|
/** Pi loads a bundled extension and its on-disk twin both, leaving two tools
|
|
116
121
|
* of one name; the copy the user put there wins, and the journal says so (§5). */
|
|
117
122
|
export const standDownShadowed = (base) => {
|
|
118
123
|
const inline = (ext) => ext.path.startsWith("<inline:");
|
|
119
|
-
const onDisk = new
|
|
120
|
-
if (!onDisk.size)
|
|
121
|
-
return base;
|
|
124
|
+
const onDisk = new Map(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()].map((tool) => [tool, ext.path])));
|
|
122
125
|
return {
|
|
123
126
|
...base,
|
|
124
127
|
extensions: base.extensions.filter((ext) => {
|
|
125
|
-
|
|
126
|
-
if (!clash || !clash.length)
|
|
128
|
+
if (!inline(ext))
|
|
127
129
|
return true;
|
|
128
|
-
|
|
130
|
+
const name = ext.path.slice("<inline:".length, -1);
|
|
131
|
+
const clash = [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
|
|
132
|
+
if (!clash.length) {
|
|
133
|
+
shadowed.delete(name);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
const from = onDisk.get(clash[0]);
|
|
137
|
+
shadowed.set(name, `stood down — ${clash.join(", ")} from ${from}`);
|
|
138
|
+
log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from ${from}`);
|
|
129
139
|
return false;
|
|
130
140
|
}),
|
|
131
141
|
};
|
|
132
142
|
};
|
|
133
|
-
/** A skill's description is resident in every prompt; one pointing at a tool
|
|
134
|
-
* this session was not given is a route the agent cannot take. */
|
|
135
|
-
export const standDownUndocumented = (tools, skills) => {
|
|
136
|
-
const gone = new Set(tools.filter((tool) => tool.skill && !(tool.available?.() ?? true)).map((tool) => tool.skill));
|
|
137
|
-
if (!gone.size)
|
|
138
|
-
return skills;
|
|
139
|
-
return skills.filter((skill) => !gone.has(skill.name));
|
|
140
|
-
};
|
|
141
143
|
export class PiSession {
|
|
142
144
|
pi;
|
|
143
145
|
pinned;
|
|
@@ -390,34 +392,34 @@ export class PiSession {
|
|
|
390
392
|
}
|
|
391
393
|
}
|
|
392
394
|
export class PiAgentFactory {
|
|
393
|
-
extraTools;
|
|
394
395
|
instructions;
|
|
395
396
|
skillPaths;
|
|
396
397
|
credentials;
|
|
397
398
|
providerConfig;
|
|
398
399
|
pinned;
|
|
399
|
-
|
|
400
|
+
pier;
|
|
400
401
|
titleModel;
|
|
401
402
|
listings;
|
|
402
|
-
constructor(
|
|
403
|
-
/**
|
|
404
|
-
*
|
|
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
|
|
405
406
|
* instructions still win. */
|
|
406
407
|
instructions = () => "",
|
|
407
408
|
/** Loaded per session, never installed into the user's skill directories. */
|
|
408
409
|
skillPaths = [],
|
|
409
410
|
/** Optional only for bare test factories; an OAuth refresh persists here,
|
|
410
411
|
* never to a plaintext auth.json. */
|
|
411
|
-
credentials, providerConfig = new PiConfigStore(), pinned = () => [],
|
|
412
|
+
credentials, providerConfig = new PiConfigStore(), pinned = () => [],
|
|
413
|
+
/** The built-in `pier` package's switches: bundled extensions on, Pier's own skills off. */
|
|
414
|
+
pier = () => ({ extensions: [], skillsOff: [] }), titleModel = () => undefined,
|
|
412
415
|
/** Injected so a test needs no session directory or database. */
|
|
413
416
|
listings = new IndexedListing()) {
|
|
414
|
-
this.extraTools = extraTools;
|
|
415
417
|
this.instructions = instructions;
|
|
416
418
|
this.skillPaths = skillPaths;
|
|
417
419
|
this.credentials = credentials;
|
|
418
420
|
this.providerConfig = providerConfig;
|
|
419
421
|
this.pinned = pinned;
|
|
420
|
-
this.
|
|
422
|
+
this.pier = pier;
|
|
421
423
|
this.titleModel = titleModel;
|
|
422
424
|
this.listings = listings;
|
|
423
425
|
}
|
|
@@ -605,19 +607,22 @@ export class PiAgentFactory {
|
|
|
605
607
|
await (await this.authRuntime()).logout(providerId, { signal: AbortSignal.timeout(15_000) });
|
|
606
608
|
}
|
|
607
609
|
async resourceLoader(cwd) {
|
|
610
|
+
const { extensions, skillsOff } = this.pier();
|
|
608
611
|
const loader = new DefaultResourceLoader({
|
|
609
612
|
cwd,
|
|
610
613
|
agentDir: defaultAgentDir(),
|
|
611
614
|
// The user's SYSTEM.md is appended after Pier's baseline, so it still wins.
|
|
612
615
|
systemPromptOverride: pierSystemPrompt,
|
|
613
616
|
additionalSkillPaths: this.skillPaths,
|
|
617
|
+
// Only Pier's own skills answer to the off-list; a user's skill of the
|
|
618
|
+
// same name is Pi's to switch (settings.json).
|
|
614
619
|
skillsOverride: (base) => ({
|
|
615
620
|
...base,
|
|
616
|
-
skills:
|
|
621
|
+
skills: base.skills.filter((skill) => !skillsOff.includes(skill.name) || !this.skillPaths.some((dir) => skill.filePath.startsWith(dir + sep))),
|
|
617
622
|
}),
|
|
618
623
|
extensionFactories: [
|
|
619
624
|
{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
|
|
620
|
-
...inlineExtensions(
|
|
625
|
+
...inlineExtensions(extensions),
|
|
621
626
|
],
|
|
622
627
|
extensionsOverride: standDownShadowed,
|
|
623
628
|
agentsFilesOverride: (current) => {
|
|
@@ -633,39 +638,9 @@ export class PiAgentFactory {
|
|
|
633
638
|
return loader;
|
|
634
639
|
}
|
|
635
640
|
open(cwd, sessionManager, opts = { cwd }) {
|
|
636
|
-
return this.providerConfig.
|
|
641
|
+
return this.providerConfig.withWrite(() => this.openSnapshot(cwd, sessionManager, opts));
|
|
637
642
|
}
|
|
638
643
|
async openSnapshot(cwd, sessionManager, opts) {
|
|
639
|
-
let live;
|
|
640
|
-
// Per open: an unconfigured tool would cost context on every turn and answer nothing.
|
|
641
|
-
const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
|
|
642
|
-
const customTools = active.map((tool) => defineTool({
|
|
643
|
-
name: tool.name,
|
|
644
|
-
label: tool.label,
|
|
645
|
-
description: tool.description,
|
|
646
|
-
parameters: tool.parameters,
|
|
647
|
-
execute: async (_id, params, signal) => {
|
|
648
|
-
const caller = live?.sessionId ?? "unknown";
|
|
649
|
-
log.debug(`tool ${tool.name} called by ${caller}`);
|
|
650
|
-
try {
|
|
651
|
-
return {
|
|
652
|
-
content: [
|
|
653
|
-
{
|
|
654
|
-
type: "text",
|
|
655
|
-
// Pretty-printing costs ~20% more tokens and buys the model nothing.
|
|
656
|
-
text: JSON.stringify(await tool.execute(params, caller, signal)),
|
|
657
|
-
},
|
|
658
|
-
],
|
|
659
|
-
details: {},
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
catch (err) {
|
|
663
|
-
// Pi turns this into tool-result text: the right recovery, the wrong record.
|
|
664
|
-
log.warn(`tool ${tool.name} failed for ${caller}`, err);
|
|
665
|
-
throw err;
|
|
666
|
-
}
|
|
667
|
-
},
|
|
668
|
-
}));
|
|
669
644
|
// A locked store is a refusal with a reason here, not "provider not
|
|
670
645
|
// configured" later. Before appendSessionInfo, so nothing is written.
|
|
671
646
|
this.credentials?.assertUnlocked();
|
|
@@ -681,11 +656,10 @@ export class PiAgentFactory {
|
|
|
681
656
|
const created = await createAgentSession({
|
|
682
657
|
cwd,
|
|
683
658
|
sessionManager,
|
|
684
|
-
customTools,
|
|
685
659
|
modelRuntime: runtime,
|
|
686
660
|
resourceLoader: await this.resourceLoader(cwd),
|
|
687
661
|
});
|
|
688
|
-
live = created.session;
|
|
662
|
+
const live = created.session;
|
|
689
663
|
// Pi defaults to one follow-up per turn boundary, so N queued messages cost
|
|
690
664
|
// N turns. The agent's setter flips only the in-memory queue;
|
|
691
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
|
}
|