@timqi/pier 0.1.3 → 0.1.5
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 +12 -7
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +13 -5
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +4 -20
- package/dist/agent/pi.js +18 -86
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +32 -24
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -7
- package/dist/channels/runtime.js +21 -7
- 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 +11 -1
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +38 -31
- package/dist/channels/types.js +1 -3
- package/dist/cli.js +166 -7
- package/dist/core/identity.js +61 -17
- package/dist/core/reply.js +19 -15
- package/dist/core/router.js +23 -3
- package/dist/db.js +33 -0
- package/dist/main.js +61 -41
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +3 -12
- package/dist/socket.js +99 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +225 -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 +335 -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 +18 -3
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
- package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
- package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
- package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
- package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
- package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
- package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css +2 -0
- package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
- package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
- package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
- package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
- package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
- package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
- package/dist/web/public/assets/tasks-BkW7YShZ.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/push.js +4 -10
- package/dist/web/vault.js +68 -0
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +34 -11
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +32 -16
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +60 -148
- package/skills/pier-vault/SKILL.md +36 -0
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- 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 +0 -2
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.gz +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/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /package/dist/{extensions/web → websearch}/provider.js +0 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// `pier slack <subcommand> …`: Slack from a shell for an agent session, with
|
|
2
|
+
// the bot token handed in by cli.ts (env or vault) and never printed. Reads
|
|
3
|
+
// paginate fully and can land on disk (`--out`); every failure is one line on
|
|
4
|
+
// stderr: `slack: <method>: <code>` with Slack's own code verbatim, or
|
|
5
|
+
// `slack: <check>: <why>` for what this file refuses before asking Slack.
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, resolve } from "node:path";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { isBlockRejection, SlackApi, } from "./slack-api.js";
|
|
10
|
+
import { SlackDirectory } from "./slack-directory.js";
|
|
11
|
+
import { markdown, MARKDOWN_MAX } from "./slack-render.js";
|
|
12
|
+
import { header, localStamp, ordered, renderMessage, transcript, } from "./slack-transcript.js";
|
|
13
|
+
/** 429s one call sits through before giving up: a batch process can wait. */
|
|
14
|
+
const RETRIES = 4;
|
|
15
|
+
const PAGE = 200;
|
|
16
|
+
/** Epoch seconds at the year 2100: past this, the caller meant milliseconds,
|
|
17
|
+
* and Slack would answer an empty read indistinguishable from a quiet channel. */
|
|
18
|
+
const MAX_SECONDS = 4_102_444_800;
|
|
19
|
+
/** Downloads are not the inbox: a cap only against a runaway response. */
|
|
20
|
+
const DOWNLOAD_MAX = 1 << 30;
|
|
21
|
+
class SlackCliError extends Error {
|
|
22
|
+
constructor(where, code) {
|
|
23
|
+
super(`${where}: ${code}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const displayName = (u) => u.profile?.display_name || u.real_name || u.name || u.id;
|
|
27
|
+
// --- targets ---------------------------------------------------------------
|
|
28
|
+
const PERMALINK = /^https:\/\/[\w.-]+\.slack\.com\/archives\/([A-Z0-9]+)\/p(\d{6,})/;
|
|
29
|
+
/** `{channel, ts, thread}` from a pasted message link; `p<digits>` is the ts without its dot. */
|
|
30
|
+
export function permalink(url) {
|
|
31
|
+
const m = PERMALINK.exec(url);
|
|
32
|
+
if (!m)
|
|
33
|
+
return undefined;
|
|
34
|
+
const digits = m[2];
|
|
35
|
+
const thread = new URL(url).searchParams.get("thread_ts") ?? undefined;
|
|
36
|
+
return { channel: m[1], ts: `${digits.slice(0, -6)}.${digits.slice(-6)}`, ...(thread ? { thread } : {}) };
|
|
37
|
+
}
|
|
38
|
+
// --- time ------------------------------------------------------------------
|
|
39
|
+
const ISO = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6})\d*)?)?)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
40
|
+
/** ISO 8601 (naive = local), epoch seconds, or a Slack ts → a Slack ts. */
|
|
41
|
+
export function toTs(value) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return undefined;
|
|
44
|
+
const raw = value.trim();
|
|
45
|
+
let seconds;
|
|
46
|
+
if (/^\d+(\.\d+)?$/.test(raw)) {
|
|
47
|
+
seconds = Number(raw);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const m = ISO.exec(raw);
|
|
51
|
+
if (!m)
|
|
52
|
+
throw new SlackCliError("time", `not a time: ${value}`);
|
|
53
|
+
const [, y, mo, d, h = "0", mi = "0", s = "0", frac = "0", zone] = m;
|
|
54
|
+
const parts = [Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s), Number(frac.padEnd(3, "0").slice(0, 3))];
|
|
55
|
+
if (!zone) {
|
|
56
|
+
seconds = new Date(...parts).getTime() / 1000;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const offset = zone === "Z" ? 0 : (zone.startsWith("-") ? -1 : 1) * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(-2)));
|
|
60
|
+
seconds = Date.UTC(...parts) / 1000 - offset * 60;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (seconds > MAX_SECONDS) {
|
|
64
|
+
throw new SlackCliError("time", `${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
|
|
65
|
+
}
|
|
66
|
+
// A ts is an id as well as a time: never rewritten.
|
|
67
|
+
return /^\d+\.\d+$/.test(raw) ? raw : seconds.toFixed(6);
|
|
68
|
+
}
|
|
69
|
+
// --- the client --------------------------------------------------------------
|
|
70
|
+
class Client {
|
|
71
|
+
io;
|
|
72
|
+
api;
|
|
73
|
+
directory;
|
|
74
|
+
users;
|
|
75
|
+
constructor(token, io) {
|
|
76
|
+
this.io = io;
|
|
77
|
+
this.api = new SlackApi(token, "", (m) => io.stderr(`slack: ${m}`), undefined, RETRIES);
|
|
78
|
+
this.directory = new SlackDirectory((m) => io.stderr(`slack: ${m}`));
|
|
79
|
+
}
|
|
80
|
+
/** Every page of a cursor-paginated list. */
|
|
81
|
+
async pages(load) {
|
|
82
|
+
const out = [];
|
|
83
|
+
let cursor;
|
|
84
|
+
do {
|
|
85
|
+
const page = await load(cursor);
|
|
86
|
+
out.push(...page.items);
|
|
87
|
+
cursor = page.next;
|
|
88
|
+
} while (cursor);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
listPages(method, key, params) {
|
|
92
|
+
return this.pages(async (cursor) => {
|
|
93
|
+
const body = await this.api.read(method, { ...params, limit: PAGE, cursor });
|
|
94
|
+
return { items: body[key] ?? [], next: body.response_metadata?.next_cursor || undefined };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
messages(load) {
|
|
98
|
+
return this.pages(async (cursor) => {
|
|
99
|
+
const page = await load(cursor);
|
|
100
|
+
return { items: page.messages, next: page.nextCursor };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** Once per invocation, for name → user; a transcript resolves ids one by one. */
|
|
104
|
+
async members() {
|
|
105
|
+
this.users ??= await this.listPages("users.list", "members", {});
|
|
106
|
+
return this.users;
|
|
107
|
+
}
|
|
108
|
+
/** An id as is; `#name` or `name` looked up, since people say channels by name. */
|
|
109
|
+
async channel(given) {
|
|
110
|
+
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
111
|
+
return given;
|
|
112
|
+
const wanted = given.replace(/^#/, "").toLowerCase();
|
|
113
|
+
const convs = await this.listPages("conversations.list", "channels", {
|
|
114
|
+
exclude_archived: true,
|
|
115
|
+
types: "public_channel,private_channel",
|
|
116
|
+
});
|
|
117
|
+
const hit = convs.find((c) => (c.name ?? "").toLowerCase() === wanted);
|
|
118
|
+
if (!hit)
|
|
119
|
+
throw new SlackCliError("channel", `no channel named ${given} — see \`channels\``);
|
|
120
|
+
return hit.id;
|
|
121
|
+
}
|
|
122
|
+
/** Display names for every user who spoke; bots name themselves. */
|
|
123
|
+
names(messages) {
|
|
124
|
+
const ids = messages.flatMap((m) => [m, ...(m.replies ?? [])]).map((m) => m.user ?? "").filter(Boolean);
|
|
125
|
+
return this.directory.names(this.api, ids);
|
|
126
|
+
}
|
|
127
|
+
emit(lines, out, summary) {
|
|
128
|
+
if (out) {
|
|
129
|
+
writeFileSync(out, `${lines.join("\n")}\n`);
|
|
130
|
+
this.io.stdout(`wrote ${summary} to ${resolve(out)}`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.io.stdout(lines.join("\n"));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Raw API objects for a second script to process, so the model never reads them. */
|
|
137
|
+
emitJson(data, out, summary) {
|
|
138
|
+
this.emit([JSON.stringify(data)], out, summary);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// --- arguments -----------------------------------------------------------
|
|
142
|
+
const OPTIONS = {
|
|
143
|
+
since: { type: "string" },
|
|
144
|
+
until: { type: "string" },
|
|
145
|
+
after: { type: "string" },
|
|
146
|
+
thread: { type: "string" },
|
|
147
|
+
out: { type: "string" },
|
|
148
|
+
dir: { type: "string" },
|
|
149
|
+
comment: { type: "string" },
|
|
150
|
+
threads: { type: "boolean" },
|
|
151
|
+
ts: { type: "boolean" },
|
|
152
|
+
ids: { type: "boolean" },
|
|
153
|
+
json: { type: "boolean" },
|
|
154
|
+
};
|
|
155
|
+
/** What a pasted link stands for: the `message` itself, the `thread` it
|
|
156
|
+
* belongs to, or the place a `reply` goes. Resolves `channel` in place. */
|
|
157
|
+
async function target(client, cmd, a, linkIs = "message") {
|
|
158
|
+
const link = permalink(a.channel);
|
|
159
|
+
const hasTs = cmd.args.includes("ts?");
|
|
160
|
+
const hasThread = cmd.flags.includes("thread");
|
|
161
|
+
if (link) {
|
|
162
|
+
a.channel = link.channel;
|
|
163
|
+
if (linkIs === "reply") {
|
|
164
|
+
a.thread = a.thread ?? link.thread ?? link.ts;
|
|
165
|
+
}
|
|
166
|
+
else if (a.ts === undefined) {
|
|
167
|
+
a.ts = linkIs === "thread" ? link.thread ?? link.ts : link.ts;
|
|
168
|
+
if (hasThread && a.thread === undefined && link.thread && link.thread !== link.ts)
|
|
169
|
+
a.thread = link.thread;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
a.channel = await client.channel(a.channel);
|
|
174
|
+
}
|
|
175
|
+
if (hasTs && a.ts === undefined) {
|
|
176
|
+
throw new SlackCliError("ts", "required — a message ts, or a Slack link in place of <channel> <ts>");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function readText(io, arg) {
|
|
180
|
+
const text = arg === "-" ? io.stdin() : arg;
|
|
181
|
+
if (!text.trim())
|
|
182
|
+
throw new SlackCliError("text", "empty");
|
|
183
|
+
if (text.length > MARKDOWN_MAX) {
|
|
184
|
+
throw new SlackCliError("text", `${text.length} chars; Slack takes ${MARKDOWN_MAX} per message — split it across replies`);
|
|
185
|
+
}
|
|
186
|
+
return text;
|
|
187
|
+
}
|
|
188
|
+
/** A plain `@alice` looks like it worked and notifies nobody; said, not refused — a name in prose is fine. */
|
|
189
|
+
function warnInertMention(io, text) {
|
|
190
|
+
const prose = text.replace(/```[\s\S]*?```|`[^`]*`|<[^>]*>/g, "");
|
|
191
|
+
const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose);
|
|
192
|
+
if (!hit)
|
|
193
|
+
return;
|
|
194
|
+
const word = hit[1];
|
|
195
|
+
const needs = word.startsWith("#")
|
|
196
|
+
? "<#C…>"
|
|
197
|
+
: ["here", "channel", "everyone"].includes(word.slice(1)) ? `<!${word.slice(1)}>` : "<@U…>";
|
|
198
|
+
io.stderr(`slack: note: ${word} is plain text and notified nobody — Slack needs ${needs}; edit this ts if it was meant to reach someone`);
|
|
199
|
+
}
|
|
200
|
+
/** Slack's own markdown renderer; a workspace that predates the block gets plain text. */
|
|
201
|
+
async function send(withBlocks, plain) {
|
|
202
|
+
try {
|
|
203
|
+
return await withBlocks();
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (!isBlockRejection(err))
|
|
207
|
+
throw err;
|
|
208
|
+
return plain();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const COMMANDS = {
|
|
212
|
+
whoami: {
|
|
213
|
+
args: [],
|
|
214
|
+
flags: [],
|
|
215
|
+
help: "your bot's user id and team — the id your own messages carry",
|
|
216
|
+
async run(client) {
|
|
217
|
+
const a = await client.api.read("auth.test", {});
|
|
218
|
+
client.io.stdout(`user ${a.user_id ?? ""} bot ${a.bot_id ?? ""} team ${a.team_id ?? ""} (${a.user ?? ""} @ ${a.team ?? ""})`);
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
channels: {
|
|
222
|
+
args: [],
|
|
223
|
+
flags: ["json", "out"],
|
|
224
|
+
help: "every conversation the bot can reach",
|
|
225
|
+
async run(client, a) {
|
|
226
|
+
const convs = await client.listPages("conversations.list", "channels", {
|
|
227
|
+
exclude_archived: true,
|
|
228
|
+
types: "public_channel,private_channel,mpim,im",
|
|
229
|
+
});
|
|
230
|
+
// Channels the bot is in first, DMs last, names alphabetical.
|
|
231
|
+
const rank = (c) => `${c.is_member === false ? 1 : 0}${c.is_im || c.is_mpim ? 1 : 0}${c.name ?? ""}`;
|
|
232
|
+
convs.sort((x, y) => rank(x).localeCompare(rank(y)));
|
|
233
|
+
const summary = `${convs.length} conversations`;
|
|
234
|
+
if (a.json)
|
|
235
|
+
return client.emitJson(convs, a.out, summary);
|
|
236
|
+
const lines = [];
|
|
237
|
+
for (const c of convs) {
|
|
238
|
+
const label = c.is_im
|
|
239
|
+
? `dm ${c.user ? await client.directory.user(client.api, c.user) : ""}`
|
|
240
|
+
: c.is_mpim ? `group ${c.name ?? ""}` : `#${c.name ?? ""}`;
|
|
241
|
+
const notes = [c.is_private && !c.is_im && !c.is_mpim ? "private" : "", c.is_member === false ? "not a member" : ""];
|
|
242
|
+
lines.push([`${c.id} ${label}`, ...notes].filter(Boolean).join(" · "));
|
|
243
|
+
}
|
|
244
|
+
client.emit(lines, a.out, summary);
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
user: {
|
|
248
|
+
args: ["who"],
|
|
249
|
+
flags: ["json"],
|
|
250
|
+
help: "one person by id or name: id, display name, real name, title, timezone",
|
|
251
|
+
async run(client, a) {
|
|
252
|
+
let u;
|
|
253
|
+
if (/^[UW][A-Z0-9]+$/.test(a.who)) {
|
|
254
|
+
const body = await client.api.read("users.info", { user: a.who });
|
|
255
|
+
u = body.user ?? { id: a.who };
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const wanted = a.who.replace(/^@/, "").toLowerCase();
|
|
259
|
+
const hits = (await client.members()).filter((m) => [m.profile?.display_name, m.real_name, m.name].some((n) => (n ?? "").toLowerCase() === wanted));
|
|
260
|
+
if (hits.length !== 1) {
|
|
261
|
+
const list = hits.map((h) => `${h.id} ${displayName(h)}`).join(", ");
|
|
262
|
+
throw new SlackCliError("user", `${hits.length} users named ${a.who}${list ? `: ${list}` : ""}`);
|
|
263
|
+
}
|
|
264
|
+
u = hits[0];
|
|
265
|
+
}
|
|
266
|
+
if (a.json)
|
|
267
|
+
return client.emitJson(u, undefined, "");
|
|
268
|
+
const name = displayName(u);
|
|
269
|
+
client.io.stdout([
|
|
270
|
+
u.id,
|
|
271
|
+
name,
|
|
272
|
+
u.real_name && u.real_name !== name ? `(${u.real_name})` : "",
|
|
273
|
+
u.profile?.title ?? "",
|
|
274
|
+
u.tz ?? "",
|
|
275
|
+
u.is_bot ? "bot" : "",
|
|
276
|
+
u.deleted ? "deleted" : "",
|
|
277
|
+
].filter(Boolean).join(" "));
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
history: {
|
|
281
|
+
args: ["channel"],
|
|
282
|
+
flags: ["since", "until", "after", "threads", "ts", "ids", "json", "out"],
|
|
283
|
+
help: "top-level messages, oldest first, every page",
|
|
284
|
+
async run(client, a) {
|
|
285
|
+
await target(client, this, a);
|
|
286
|
+
const after = toTs(a.after);
|
|
287
|
+
const since = after ?? toTs(a.since);
|
|
288
|
+
const until = toTs(a.until);
|
|
289
|
+
const msgs = ordered(await client.messages((cursor) => client.api.history(a.channel, { oldest: since, latest: until, limit: PAGE, cursor })), after);
|
|
290
|
+
let threads = 0;
|
|
291
|
+
for (const m of msgs) {
|
|
292
|
+
if (!a.threads || !m.reply_count)
|
|
293
|
+
continue;
|
|
294
|
+
threads += 1;
|
|
295
|
+
const replies = ordered(await client.messages((cursor) => client.api.replies(a.channel, m.ts, { limit: PAGE, cursor })));
|
|
296
|
+
m.replies = replies.filter((r) => r.ts !== m.ts);
|
|
297
|
+
}
|
|
298
|
+
const summary = `${msgs.length} messages${a.threads ? `, ${threads} threads expanded` : ""}`;
|
|
299
|
+
if (a.json)
|
|
300
|
+
return client.emitJson(msgs, a.out, summary);
|
|
301
|
+
const scope = `${a.channel} ${since ? localStamp(since) : "start"} → ${until ? localStamp(until) : "now"}`;
|
|
302
|
+
const lines = [header(scope, msgs.length, msgs.at(-1)?.ts), ...transcript(msgs, await client.names(msgs), a.render)];
|
|
303
|
+
client.emit(lines, a.out, summary);
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
thread: {
|
|
307
|
+
args: ["channel", "ts?"],
|
|
308
|
+
flags: ["after", "ts", "ids", "json", "out"],
|
|
309
|
+
help: "one thread, oldest first",
|
|
310
|
+
async run(client, a) {
|
|
311
|
+
await target(client, this, a, "thread");
|
|
312
|
+
const after = toTs(a.after);
|
|
313
|
+
const msgs = ordered(await client.messages((cursor) => client.api.replies(a.channel, a.ts, { oldest: after, limit: PAGE, cursor })), after);
|
|
314
|
+
const summary = `${msgs.length} messages`;
|
|
315
|
+
if (a.json)
|
|
316
|
+
return client.emitJson(msgs, a.out, summary);
|
|
317
|
+
const lines = [
|
|
318
|
+
header(`${a.channel}/${a.ts}`, msgs.length, msgs.at(-1)?.ts),
|
|
319
|
+
...transcript(msgs, await client.names(msgs), { ...a.render, thread: true }),
|
|
320
|
+
];
|
|
321
|
+
client.emit(lines, a.out, summary);
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
message: {
|
|
325
|
+
args: ["channel", "ts?"],
|
|
326
|
+
flags: ["thread", "ts", "ids", "json"],
|
|
327
|
+
help: "one message; a reply inside a thread is found through its thread",
|
|
328
|
+
async run(client, a) {
|
|
329
|
+
await target(client, this, a);
|
|
330
|
+
const pick = (page) => page.messages.find((m) => m.ts === a.ts);
|
|
331
|
+
// A reply lives only in its thread: history cannot see it, replies can.
|
|
332
|
+
let found = a.thread
|
|
333
|
+
? pick(await client.api.replies(a.channel, a.thread, { oldest: a.ts, limit: 20 }))
|
|
334
|
+
: pick(await client.api.history(a.channel, { oldest: a.ts, latest: a.ts, limit: 1 }));
|
|
335
|
+
if (!found && !a.thread)
|
|
336
|
+
found = pick(await client.api.replies(a.channel, a.ts, { limit: 1 }));
|
|
337
|
+
if (!found) {
|
|
338
|
+
throw new SlackCliError("message", `no message ${a.ts} in ${a.channel}${a.thread ? "" : " — a reply inside a thread may need --thread"}`);
|
|
339
|
+
}
|
|
340
|
+
if (a.json)
|
|
341
|
+
return client.emitJson(found, undefined, "");
|
|
342
|
+
client.emit(renderMessage(found, await client.names([found]), a.render), undefined, "");
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
permalink: {
|
|
346
|
+
args: ["channel", "ts?"],
|
|
347
|
+
flags: [],
|
|
348
|
+
help: "a message's link, for citing it",
|
|
349
|
+
async run(client, a) {
|
|
350
|
+
await target(client, this, a);
|
|
351
|
+
const body = await client.api.read("chat.getPermalink", { channel: a.channel, message_ts: a.ts });
|
|
352
|
+
client.io.stdout(body.permalink ?? "");
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
file: {
|
|
356
|
+
args: ["id"],
|
|
357
|
+
flags: ["dir"],
|
|
358
|
+
help: "download an upload by F… id, prints the path",
|
|
359
|
+
async run(client, a) {
|
|
360
|
+
const file = await client.api.filesInfo(a.id);
|
|
361
|
+
const name = (file.name ?? a.id).replace(/[^\p{L}\p{N}_.-]+/gu, "_").replace(/^[._]+|[._]+$/g, "") || a.id;
|
|
362
|
+
const dir = a.dir ?? ".";
|
|
363
|
+
mkdirSync(dir, { recursive: true });
|
|
364
|
+
const path = resolve(dir, `${a.id}-${name}`);
|
|
365
|
+
const { bytes } = await client.api.downloadFile(file, DOWNLOAD_MAX);
|
|
366
|
+
writeFileSync(path, bytes);
|
|
367
|
+
client.io.stdout(path);
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
post: {
|
|
371
|
+
args: ["channel", "text"],
|
|
372
|
+
flags: ["thread"],
|
|
373
|
+
help: "post markdown (`-` = stdin), prints the ts",
|
|
374
|
+
async run(client, a) {
|
|
375
|
+
await target(client, this, a, "reply");
|
|
376
|
+
const text = readText(client.io, a.text);
|
|
377
|
+
const base = { channel: a.channel, thread_ts: a.thread, text };
|
|
378
|
+
const sent = await send(() => client.api.postMessage({ ...base, blocks: [markdown(text)] }), () => client.api.postMessage(base));
|
|
379
|
+
client.io.stdout(sent.ts);
|
|
380
|
+
warnInertMention(client.io, text);
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
edit: {
|
|
384
|
+
args: ["channel", "ts?", "text"],
|
|
385
|
+
flags: [],
|
|
386
|
+
help: "replace a message outright",
|
|
387
|
+
async run(client, a) {
|
|
388
|
+
await target(client, this, a);
|
|
389
|
+
const text = readText(client.io, a.text);
|
|
390
|
+
const base = { channel: a.channel, ts: a.ts, text };
|
|
391
|
+
await send(() => client.api.updateMessage({ ...base, blocks: [markdown(text)] }), () => client.api.updateMessage(base));
|
|
392
|
+
client.io.stdout(a.ts);
|
|
393
|
+
warnInertMention(client.io, text);
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
delete: {
|
|
397
|
+
args: ["channel", "ts?"],
|
|
398
|
+
flags: [],
|
|
399
|
+
help: "delete a message — no undo",
|
|
400
|
+
async run(client, a) {
|
|
401
|
+
await target(client, this, a);
|
|
402
|
+
await client.api.deleteMessage(a.channel, a.ts);
|
|
403
|
+
client.io.stdout(`deleted ${a.ts}`);
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
react: {
|
|
407
|
+
args: ["channel", "ts?", "emoji"],
|
|
408
|
+
flags: [],
|
|
409
|
+
help: "add a reaction (`+1` or `:+1:`)",
|
|
410
|
+
async run(client, a) {
|
|
411
|
+
await target(client, this, a);
|
|
412
|
+
const name = a.emoji.replace(/^:|:$/g, "");
|
|
413
|
+
await client.api.addReaction(a.channel, a.ts, name);
|
|
414
|
+
client.io.stdout(`:${name}: on ${a.ts}`);
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
upload: {
|
|
418
|
+
args: ["channel", "path"],
|
|
419
|
+
flags: ["thread", "comment"],
|
|
420
|
+
help: "share a file from disk, prints its F… id",
|
|
421
|
+
async run(client, a) {
|
|
422
|
+
await target(client, this, a, "reply");
|
|
423
|
+
const name = basename(a.path);
|
|
424
|
+
const { id } = await client.api.uploadFile(a.channel, a.thread, { name, bytes: readFileSync(a.path) }, a.comment);
|
|
425
|
+
client.io.stdout(`${id} ${name} → ${a.channel}${a.thread ? ` thread ${a.thread}` : ""}`);
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
const usageOf = (name, cmd) => {
|
|
430
|
+
const flags = cmd.flags.map((f) => (OPTIONS[f].type === "boolean" ? `[--${f}]` : `[--${f} X]`));
|
|
431
|
+
return [`pier slack ${name}`, ...cmd.args.map((p) => (p.endsWith("?") ? `[<${p.slice(0, -1)}>]` : `<${p}>`)), ...flags].join(" ");
|
|
432
|
+
};
|
|
433
|
+
const USAGE = [
|
|
434
|
+
"usage: pier slack <subcommand> … — Slack from a shell (skills/pier-slack)",
|
|
435
|
+
...Object.entries(COMMANDS).map(([name, cmd]) => ` ${usageOf(name, cmd).slice("pier slack ".length)}\n ${cmd.help}`),
|
|
436
|
+
].join("\n");
|
|
437
|
+
const processIo = {
|
|
438
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
439
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
440
|
+
stdin: () => readFileSync(0, "utf8"),
|
|
441
|
+
};
|
|
442
|
+
/** The exit code: 0, 1 for a Slack or local failure, 2 for a malformed command
|
|
443
|
+
* line. `token` is asked for only once the command line parses: `--help`
|
|
444
|
+
* and a usage error must not touch the vault. */
|
|
445
|
+
export async function runSlackCli(argv, token, io = processIo) {
|
|
446
|
+
const usage = (message) => {
|
|
447
|
+
io.stderr(`slack: ${message}`);
|
|
448
|
+
return 2;
|
|
449
|
+
};
|
|
450
|
+
const [name, ...rest] = argv;
|
|
451
|
+
if (!name || name === "--help" || name === "-h") {
|
|
452
|
+
io.stdout(USAGE);
|
|
453
|
+
return name ? 0 : 2;
|
|
454
|
+
}
|
|
455
|
+
const cmd = COMMANDS[name];
|
|
456
|
+
if (!cmd)
|
|
457
|
+
return usage(`unknown subcommand "${name}"\n${USAGE}`);
|
|
458
|
+
let parsed;
|
|
459
|
+
try {
|
|
460
|
+
parsed = parseArgs({ args: rest, options: OPTIONS, allowPositionals: true, strict: true });
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
return usage(`${err instanceof Error ? err.message : String(err)}\n${usageOf(name, cmd)}`);
|
|
464
|
+
}
|
|
465
|
+
const { values, positionals } = parsed;
|
|
466
|
+
const stray = Object.keys(values).find((flag) => !cmd.flags.includes(flag));
|
|
467
|
+
if (stray)
|
|
468
|
+
return usage(`--${stray} is not an option of ${name}\n${usageOf(name, cmd)}`);
|
|
469
|
+
const required = cmd.args.filter((p) => !p.endsWith("?")).length;
|
|
470
|
+
if (positionals.length < required || positionals.length > cmd.args.length)
|
|
471
|
+
return usage(usageOf(name, cmd));
|
|
472
|
+
// The optional `ts` takes an argument only when there is one to spare: a
|
|
473
|
+
// link in `<channel>` supplies it, so `edit <link> "text"` has two positionals.
|
|
474
|
+
const given = [...positionals];
|
|
475
|
+
const a = { threads: false, json: false, render: {} };
|
|
476
|
+
cmd.args.forEach((spec, i) => {
|
|
477
|
+
if (spec.endsWith("?") && given.length < cmd.args.length - i)
|
|
478
|
+
return;
|
|
479
|
+
a[spec.replace("?", "")] = given.shift();
|
|
480
|
+
});
|
|
481
|
+
Object.assign(a, {
|
|
482
|
+
since: values.since,
|
|
483
|
+
until: values.until,
|
|
484
|
+
after: values.after,
|
|
485
|
+
thread: values.thread,
|
|
486
|
+
out: values.out,
|
|
487
|
+
dir: values.dir,
|
|
488
|
+
comment: values.comment,
|
|
489
|
+
threads: values.threads === true,
|
|
490
|
+
json: values.json === true,
|
|
491
|
+
render: { ts: values.ts === true, ids: values.ids === true },
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
await cmd.run(new Client(await token(), io), a);
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
// SlackApi says `slack <method>: <code>`; the line here starts `slack: `.
|
|
499
|
+
const text = err instanceof Error ? err.message.replace(/^slack /, "") : String(err);
|
|
500
|
+
io.stderr(`slack: ${text}`);
|
|
501
|
+
return 1;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// User display names and channel kind/name, cached for the process
|
|
2
|
-
//
|
|
1
|
+
// User display names and channel kind/name, cached for the process so a name
|
|
2
|
+
// is looked up once. Failures fall back to the id and
|
|
3
3
|
// are logged: without `users:read` every lookup fails, and the symptom is
|
|
4
4
|
// otherwise a scope problem wearing a product problem's clothes.
|
|
5
5
|
export class SlackDirectory {
|
|
@@ -36,7 +36,7 @@ export class SlackOutbound {
|
|
|
36
36
|
...(last && row ? [row] : []),
|
|
37
37
|
]);
|
|
38
38
|
}
|
|
39
|
-
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(channel, threadTs, file), this.log);
|
|
39
|
+
const lost = await sendAttachments(paths, async (file) => void await this.api.uploadFile(channel, threadTs, file), this.log);
|
|
40
40
|
if (lost)
|
|
41
41
|
await this.post(channel, threadTs, lost, []);
|
|
42
42
|
}
|
|
@@ -50,6 +50,16 @@ export class SlackOutbound {
|
|
|
50
50
|
}
|
|
51
51
|
return ts;
|
|
52
52
|
}
|
|
53
|
+
/** The one root Pier posts (no `thread_ts`): the thread a web session is
|
|
54
|
+
* continued in has no user message to hang from. Answers the root's `ts`. */
|
|
55
|
+
async open(channel, note) {
|
|
56
|
+
const link = note.url || "_(no public URL set — Settings → Instance)_";
|
|
57
|
+
const sent = await this.api.postMessage({
|
|
58
|
+
channel,
|
|
59
|
+
text: `Continued from web: *${escapeMrkdwn(note.title)}*\n${link}\n_Reply in this thread to continue._`,
|
|
60
|
+
});
|
|
61
|
+
return sent.ts;
|
|
62
|
+
}
|
|
53
63
|
budget() {
|
|
54
64
|
return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
|
|
55
65
|
}
|