@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
|
@@ -1,416 +0,0 @@
|
|
|
1
|
-
// The agent-facing Slack tool: the model states an intent, Pier performs it,
|
|
2
|
-
// and the token never leaves Pier. Every read goes to Slack — only it knows
|
|
3
|
-
// about an edit or a deletion. Affordable because a workspace-internal app gets
|
|
4
|
-
// Tier 3 (~50+ req/min) on history/replies; a distributed non-Marketplace app
|
|
5
|
-
// is capped at 1 req/min, and that is the decision to revisit.
|
|
6
|
-
import { Type } from "typebox";
|
|
7
|
-
import { saveInboundAll } from "../core/inbox.js";
|
|
8
|
-
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
9
|
-
import { MARKDOWN_MAX } from "./slack-render.js";
|
|
10
|
-
/** So a wide range cannot blow up the model's context. */
|
|
11
|
-
const MAX_MESSAGES = 400;
|
|
12
|
-
const MAX_PAGES = 10;
|
|
13
|
-
const INBOX_CHANNEL = "slack";
|
|
14
|
-
/** A `ts` is `<epoch seconds>.<microseconds>`: sorts as a number, not as a
|
|
15
|
-
* string. Never rewritten — it is the id a reply must match exactly. */
|
|
16
|
-
const tsToNumber = (ts) => Number(ts);
|
|
17
|
-
const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
|
|
18
|
-
const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
|
|
19
|
-
/** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
|
|
20
|
-
const MAX_SECONDS = 4_102_444_800;
|
|
21
|
-
/** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
|
|
22
|
-
export function toTs(value) {
|
|
23
|
-
if (value === undefined || value === "")
|
|
24
|
-
return undefined;
|
|
25
|
-
const raw = typeof value === "number" ? String(value) : value.trim();
|
|
26
|
-
const numeric = /^\d+(\.\d+)?$/.test(raw);
|
|
27
|
-
const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
|
|
28
|
-
if (Number.isNaN(seconds))
|
|
29
|
-
throw new Error(`not a time: ${value}`);
|
|
30
|
-
// Milliseconds: Slack takes the window without complaint and answers an empty
|
|
31
|
-
// read indistinguishable from a channel where nobody spoke.
|
|
32
|
-
if (seconds > MAX_SECONDS) {
|
|
33
|
-
throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
|
|
34
|
-
}
|
|
35
|
-
return numeric ? raw : String(seconds);
|
|
36
|
-
}
|
|
37
|
-
/** Asked before the description is paid for. `handleSlackTool` keeps its own
|
|
38
|
-
* checks: a session opened while Slack was configured outlives the switch. */
|
|
39
|
-
export function slackToolAvailable(store) {
|
|
40
|
-
const config = store.get("slack");
|
|
41
|
-
return config.enabled && !!config.token && config.agentTool;
|
|
42
|
-
}
|
|
43
|
-
export function slackToolSpec(execute, available) {
|
|
44
|
-
return {
|
|
45
|
-
name: "slack",
|
|
46
|
-
label: "Slack",
|
|
47
|
-
description: "Read and write Slack through Pier, which holds the bot token. Operations: context (which Slack conversation this session is in), read_channel (transcript for a time range), read_thread (one thread; only what is new since a message via after), read_message (the one at ts), fetch_file (save a file a transcript names, by its F… id), post, edit/delete (Pier's own messages only), channels (what Pier can reach). Omit channel and thread_ts to act on the conversation you are in. since/until/after accept ISO 8601, epoch seconds or a ts. Every read fetches live; nothing is kept between calls. @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
48
|
-
parameters: Type.Object({
|
|
49
|
-
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
50
|
-
operation: Type.Unsafe({
|
|
51
|
-
type: "string",
|
|
52
|
-
enum: [
|
|
53
|
-
"context",
|
|
54
|
-
"read_channel",
|
|
55
|
-
"read_thread",
|
|
56
|
-
"read_message",
|
|
57
|
-
"fetch_file",
|
|
58
|
-
"post",
|
|
59
|
-
"edit",
|
|
60
|
-
"delete",
|
|
61
|
-
"channels",
|
|
62
|
-
],
|
|
63
|
-
}),
|
|
64
|
-
channel: Type.Optional(Type.String()),
|
|
65
|
-
since: Type.Optional(Type.String()),
|
|
66
|
-
until: Type.Optional(Type.String()),
|
|
67
|
-
after: Type.Optional(Type.String()),
|
|
68
|
-
ts: Type.Optional(Type.String()),
|
|
69
|
-
file: Type.Optional(Type.String()),
|
|
70
|
-
limit: Type.Optional(Type.Number()),
|
|
71
|
-
thread_ts: Type.Optional(Type.String()),
|
|
72
|
-
text: Type.Optional(Type.String()),
|
|
73
|
-
}),
|
|
74
|
-
available,
|
|
75
|
-
skill: "pier-slack",
|
|
76
|
-
execute,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
const required = (value, field) => {
|
|
80
|
-
if (typeof value !== "string" || !value.trim())
|
|
81
|
-
throw new Error(`${field} is required`);
|
|
82
|
-
return value.trim();
|
|
83
|
-
};
|
|
84
|
-
/** Slack rejects an oversized message outright; a refusal the agent can act
|
|
85
|
-
* on beats a post that silently never happened. */
|
|
86
|
-
const messageText = (raw) => {
|
|
87
|
-
const text = required(raw, "text");
|
|
88
|
-
if (text.length > MARKDOWN_MAX) {
|
|
89
|
-
throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
|
|
90
|
-
}
|
|
91
|
-
return text;
|
|
92
|
-
};
|
|
93
|
-
const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
94
|
-
export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
95
|
-
const input = record(raw);
|
|
96
|
-
if (!input)
|
|
97
|
-
throw new Error("slack tool parameters required");
|
|
98
|
-
const config = deps.store.get("slack");
|
|
99
|
-
// The error says which switch: "it does nothing" is the most expensive
|
|
100
|
-
// failure for a model to diagnose.
|
|
101
|
-
if (!config.enabled || !config.token)
|
|
102
|
-
throw new Error("the Slack channel is not configured in Pier");
|
|
103
|
-
if (!config.agentTool)
|
|
104
|
-
throw new Error("Slack agent access is switched off in Pier's Console");
|
|
105
|
-
const client = deps.client();
|
|
106
|
-
if (!client)
|
|
107
|
-
throw new Error("the Slack client is unavailable");
|
|
108
|
-
if (input.operation === "channels") {
|
|
109
|
-
// Only what Pier has seen: Slack has no reliable "list my channels".
|
|
110
|
-
return config.chats.map((chat) => ({
|
|
111
|
-
id: chat.id,
|
|
112
|
-
name: chat.name,
|
|
113
|
-
kind: chat.kind,
|
|
114
|
-
respondsToMessages: chat.enabled,
|
|
115
|
-
}));
|
|
116
|
-
}
|
|
117
|
-
const at = deps.here(callerSessionId);
|
|
118
|
-
if (input.operation === "context") {
|
|
119
|
-
if (!at) {
|
|
120
|
-
return {
|
|
121
|
-
inSlack: false,
|
|
122
|
-
note: "This session was not reached through Slack, so there is no current conversation. Name a channel explicitly.",
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const chat = config.chats.find((c) => c.id === at.channel);
|
|
126
|
-
return {
|
|
127
|
-
inSlack: true,
|
|
128
|
-
channel: at.channel,
|
|
129
|
-
channelName: chat?.name ?? at.channel,
|
|
130
|
-
kind: chat?.kind ?? null,
|
|
131
|
-
threadTs: at.threadTs,
|
|
132
|
-
note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
// Before the channel default: a file id is unique workspace-wide.
|
|
136
|
-
if (input.operation === "fetch_file") {
|
|
137
|
-
return fetchFile(deps, client, required(input.file, "file"));
|
|
138
|
-
}
|
|
139
|
-
const channel = input.channel === undefined || input.channel === ""
|
|
140
|
-
? at?.channel ??
|
|
141
|
-
(() => {
|
|
142
|
-
throw new Error("channel is required: this session was not reached through Slack, so there is no current conversation");
|
|
143
|
-
})()
|
|
144
|
-
: resolveChannel(deps, required(input.channel, "channel"));
|
|
145
|
-
// Slack's bounds are inclusive-ish, so the boundary message is dropped here.
|
|
146
|
-
const after = toTs(input.after);
|
|
147
|
-
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
148
|
-
if (input.operation === "read_channel") {
|
|
149
|
-
const since = after ?? toTs(input.since);
|
|
150
|
-
const until = toTs(input.until);
|
|
151
|
-
return readChannel(deps, client, channel, since, until, after, limit);
|
|
152
|
-
}
|
|
153
|
-
if (input.operation === "read_thread") {
|
|
154
|
-
const threadTs = typeof input.thread_ts === "string" && input.thread_ts.trim()
|
|
155
|
-
? input.thread_ts.trim()
|
|
156
|
-
: at?.threadTs;
|
|
157
|
-
if (!threadTs)
|
|
158
|
-
throw new Error("thread_ts is required outside a Slack thread");
|
|
159
|
-
return readThread(deps, client, channel, threadTs, after, limit);
|
|
160
|
-
}
|
|
161
|
-
if (input.operation === "read_message") {
|
|
162
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
163
|
-
return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
|
|
164
|
-
}
|
|
165
|
-
if (input.operation === "post") {
|
|
166
|
-
const text = messageText(input.text);
|
|
167
|
-
// `thread_ts: "none"` is the explicit way to start a top-level message.
|
|
168
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
169
|
-
const threadTs = asked === "none"
|
|
170
|
-
? undefined
|
|
171
|
-
: asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
172
|
-
const sent = await client.postMessage({
|
|
173
|
-
channel,
|
|
174
|
-
thread_ts: threadTs,
|
|
175
|
-
text,
|
|
176
|
-
// Slack's own markdown renderer: the agent writes markdown, not mrkdwn.
|
|
177
|
-
blocks: [{ type: "markdown", text }],
|
|
178
|
-
});
|
|
179
|
-
return {
|
|
180
|
-
channel,
|
|
181
|
-
ts: sent.ts,
|
|
182
|
-
at: sent.ts ? tsToIso(sent.ts) : null,
|
|
183
|
-
threadTs: threadTs ?? sent.ts,
|
|
184
|
-
...inertMention(text),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (input.operation === "edit") {
|
|
188
|
-
const ts = required(input.ts, "ts");
|
|
189
|
-
const text = messageText(input.text);
|
|
190
|
-
// `conversations.history` cannot see inside a thread, so the read below
|
|
191
|
-
// needs the thread `post` would default to.
|
|
192
|
-
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
193
|
-
const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
194
|
-
// Slack keeps no version, so this log is the only record of what was replaced.
|
|
195
|
-
const was = await previousText(client, channel, ts, inThread);
|
|
196
|
-
try {
|
|
197
|
-
await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
throw new Error(explain(err));
|
|
201
|
-
}
|
|
202
|
-
deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
|
|
203
|
-
return { channel, ts, edited: true, ...inertMention(text) };
|
|
204
|
-
}
|
|
205
|
-
if (input.operation === "delete") {
|
|
206
|
-
// Never defaulted from `here`: an implied target is the one mistake with no undo.
|
|
207
|
-
const ts = required(input.ts, "ts");
|
|
208
|
-
try {
|
|
209
|
-
await client.deleteMessage(channel, ts);
|
|
210
|
-
}
|
|
211
|
-
catch (err) {
|
|
212
|
-
throw new Error(explain(err));
|
|
213
|
-
}
|
|
214
|
-
deps.log(`slack tool deleted ${ts} in ${channel}`);
|
|
215
|
-
return { channel, ts, deleted: true };
|
|
216
|
-
}
|
|
217
|
-
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
218
|
-
}
|
|
219
|
-
/** `conversations.history` never returns what was posted inside a thread, so
|
|
220
|
-
* a reply has to be asked for through its thread. */
|
|
221
|
-
async function oneMessage(client, channel, ts, threadTs) {
|
|
222
|
-
const page = threadTs
|
|
223
|
-
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
224
|
-
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
225
|
-
return page.messages.find((msg) => msg.ts === ts);
|
|
226
|
-
}
|
|
227
|
-
async function previousText(client, channel, ts, threadTs) {
|
|
228
|
-
try {
|
|
229
|
-
const text = (await oneMessage(client, channel, ts, threadTs))?.text;
|
|
230
|
-
return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
|
|
231
|
-
}
|
|
232
|
-
catch {
|
|
233
|
-
// The caller logs "not captured"; refusing the edit over it would be worse.
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
/** A plain `@alice` looks like it worked and notifies nobody. Reported, not
|
|
238
|
-
* refused: a name in prose is legitimate. */
|
|
239
|
-
function inertMention(text) {
|
|
240
|
-
const prose = text
|
|
241
|
-
.replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
|
|
242
|
-
.replace(/<[^>]*>/g, ""); // already Slack syntax
|
|
243
|
-
const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
|
|
244
|
-
if (!hit)
|
|
245
|
-
return {};
|
|
246
|
-
const as = hit.startsWith("@")
|
|
247
|
-
? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
|
|
248
|
-
: "<#C…>";
|
|
249
|
-
return {
|
|
250
|
-
hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
|
|
251
|
-
`Edit this ts if it was meant to reach someone.`,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
function resolveChannel(deps, given) {
|
|
255
|
-
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
256
|
-
return given;
|
|
257
|
-
const wanted = given.replace(/^#/, "").toLowerCase();
|
|
258
|
-
const chats = deps.store.get("slack").chats;
|
|
259
|
-
const hit = chats.find((chat) => chat.name.replace(/^#/, "").toLowerCase() === wanted);
|
|
260
|
-
if (hit)
|
|
261
|
-
return hit.id;
|
|
262
|
-
throw new Error(`unknown channel ${given}; use an id or one of: ${chats.map((c) => c.name).join(", ") || "(none discovered yet)"}`);
|
|
263
|
-
}
|
|
264
|
-
async function readChannel(deps, client, channel, since, until, after, limit) {
|
|
265
|
-
const fetched = await fetchPages(deps, (cursor) => client.history(channel, { oldest: since, latest: until, cursor }), `history for ${channel}`);
|
|
266
|
-
const all = newerThan(transcript(fetched.messages), after);
|
|
267
|
-
// Truncated at the newest end: the oldest `limit` messages read as a transcript.
|
|
268
|
-
const window = all.slice(0, limit);
|
|
269
|
-
return {
|
|
270
|
-
channel,
|
|
271
|
-
range: `${since ? tsToMinute(since) : "start"} → ${until ? tsToMinute(until) : "now"}`,
|
|
272
|
-
count: window.length,
|
|
273
|
-
...(fetched.truncated || all.length > window.length ? { truncated: true } : {}),
|
|
274
|
-
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
275
|
-
format: LINE_FORMAT,
|
|
276
|
-
messages: await lines(deps, client, window),
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
/** Exported for the adapter, which inlines a small shared thread. */
|
|
280
|
-
export async function readThread(deps, client, channel, threadTs, after, limit) {
|
|
281
|
-
const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
|
|
282
|
-
const all = newerThan(transcript(fetched.messages), after);
|
|
283
|
-
const messages = all.slice(0, limit);
|
|
284
|
-
return {
|
|
285
|
-
channel,
|
|
286
|
-
threadTs,
|
|
287
|
-
count: messages.length,
|
|
288
|
-
...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
|
|
289
|
-
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
290
|
-
format: LINE_FORMAT,
|
|
291
|
-
messages: await lines(deps, client, messages),
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
async function readMessage(deps, client, channel, ts, threadTs) {
|
|
295
|
-
const found = await oneMessage(client, channel, ts, threadTs);
|
|
296
|
-
if (!found) {
|
|
297
|
-
throw new Error(threadTs
|
|
298
|
-
? `no message ${ts} in thread ${threadTs}`
|
|
299
|
-
: `no message ${ts} in that channel — a reply posted inside a thread needs thread_ts`);
|
|
300
|
-
}
|
|
301
|
-
const [line] = await lines(deps, client, [found]);
|
|
302
|
-
return {
|
|
303
|
-
channel,
|
|
304
|
-
...(found.thread_ts ? { threadTs: found.thread_ts } : {}),
|
|
305
|
-
format: LINE_FORMAT,
|
|
306
|
-
message: line,
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
/** Explicit, not automatic: one read can name a hundred uploads, and the agent
|
|
310
|
-
* knows which one the question is about. `files.info` every call: the signed
|
|
311
|
-
* url expires. */
|
|
312
|
-
async function fetchFile(deps, client, id) {
|
|
313
|
-
let file;
|
|
314
|
-
try {
|
|
315
|
-
file = await client.filesInfo(id);
|
|
316
|
-
}
|
|
317
|
-
catch (err) {
|
|
318
|
-
// Manifest scopes apply only at app creation, so an older install refuses
|
|
319
|
-
// for a reason no agent can guess.
|
|
320
|
-
if (/missing_scope/.test(String(err))) {
|
|
321
|
-
throw new Error("Pier's Slack app cannot read files — add the files:read scope to the Slack app " +
|
|
322
|
-
"under OAuth & Permissions and reinstall it");
|
|
323
|
-
}
|
|
324
|
-
throw new Error(explain(err));
|
|
325
|
-
}
|
|
326
|
-
const [marker] = await saveInboundAll(INBOX_CHANNEL, [{
|
|
327
|
-
label: file.name ?? id,
|
|
328
|
-
name: file.name,
|
|
329
|
-
mimeType: file.mimetype ?? "application/octet-stream",
|
|
330
|
-
size: file.size,
|
|
331
|
-
// The response's content-type wins: Slack's metadata is a guess.
|
|
332
|
-
fetch: () => client.downloadFile(file, MAX_INBOUND_BYTES),
|
|
333
|
-
}], deps.log);
|
|
334
|
-
return marker;
|
|
335
|
-
}
|
|
336
|
-
/** A page that fails mid-walk keeps the pages before it, plus why it stopped:
|
|
337
|
-
* an exception alone cannot be told from a quiet channel. */
|
|
338
|
-
async function fetchPages(deps, page, what) {
|
|
339
|
-
const messages = [];
|
|
340
|
-
let cursor;
|
|
341
|
-
for (let i = 0; i < MAX_PAGES; i++) {
|
|
342
|
-
let batch;
|
|
343
|
-
try {
|
|
344
|
-
batch = await page(cursor);
|
|
345
|
-
}
|
|
346
|
-
catch (err) {
|
|
347
|
-
if (messages.length === 0)
|
|
348
|
-
throw new Error(explain(err));
|
|
349
|
-
deps.log(`${what} stopped after ${messages.length} messages: ${String(err)}`);
|
|
350
|
-
return { messages, truncated: true, incomplete: explain(err) };
|
|
351
|
-
}
|
|
352
|
-
messages.push(...batch.messages);
|
|
353
|
-
cursor = batch.nextCursor;
|
|
354
|
-
if (!cursor || messages.length >= MAX_MESSAGES)
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
if (cursor)
|
|
358
|
-
deps.log(`${what} truncated at ${messages.length} messages`);
|
|
359
|
-
return { messages, truncated: cursor !== undefined };
|
|
360
|
-
}
|
|
361
|
-
/** Error codes an agent can act on become the action; the rest keep their
|
|
362
|
-
* searchable raw code. */
|
|
363
|
-
function explain(err) {
|
|
364
|
-
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
365
|
-
return {
|
|
366
|
-
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
367
|
-
not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
|
|
368
|
-
missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
|
|
369
|
-
ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
|
|
370
|
-
thread_not_found: "no thread with that ts in this channel",
|
|
371
|
-
cant_delete_message: "Slack only lets Pier delete what its own bot posted; a person's message has to be deleted by them",
|
|
372
|
-
cant_update_message: "Slack only lets Pier edit what its own bot posted; anyone else's message can only be replied to",
|
|
373
|
-
edit_window_closed: "Slack's edit window for that message has closed; post a correction instead of rewriting it",
|
|
374
|
-
message_not_found: "no message with that ts in this channel — a ts only means anything in the conversation it came from",
|
|
375
|
-
file_not_found: "no file with that id, or Pier's bot cannot see it — the F… id comes from a transcript line",
|
|
376
|
-
}[code] ?? String(err);
|
|
377
|
-
}
|
|
378
|
-
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|
|
379
|
-
const newerThan = (messages, after) => after === undefined ? messages : messages.filter((m) => tsToNumber(m.ts) > tsToNumber(after));
|
|
380
|
-
/** Oldest first, one per ts: page bounds are inclusive-ish, so a paged read
|
|
381
|
-
* can repeat the message on the seam. */
|
|
382
|
-
function transcript(messages) {
|
|
383
|
-
const byTs = new Map();
|
|
384
|
-
for (const msg of messages)
|
|
385
|
-
if (msg.ts)
|
|
386
|
-
byTs.set(msg.ts, msg);
|
|
387
|
-
return [...byTs.values()].sort((a, b) => tsToNumber(a.ts) - tsToNumber(b.ts));
|
|
388
|
-
}
|
|
389
|
-
/** Lines, not objects: four hundred six-key objects spend most of their tokens
|
|
390
|
-
* on key names. The id is the only thing `<@…>` can be built from. Terse: the
|
|
391
|
-
* adapter puts this string in a prompt, so every word is paid for per read. */
|
|
392
|
-
const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>, then — when there are any —"
|
|
393
|
-
+ " [thread: <n> replies] and one [file: <name> <F… id> <size>] per upload";
|
|
394
|
-
/** Enough to judge a fetch against the 32 MB cap without arithmetic. */
|
|
395
|
-
const sizeLabel = (bytes) => bytes < 1024
|
|
396
|
-
? `${bytes}B`
|
|
397
|
-
: bytes < 1024 * 1024
|
|
398
|
-
? `${Math.round(bytes / 1024)}KB`
|
|
399
|
-
: `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
400
|
-
/** Without this a PDF somebody posted reads as an empty message. */
|
|
401
|
-
const uploads = (files) => (files ?? []).map((file) => ` [file: ${file.name ?? file.mimetype ?? "file"} ${file.id}${file.size === undefined ? "" : ` ${sizeLabel(file.size)}`}]`).join("");
|
|
402
|
-
const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
|
|
403
|
-
async function lines(deps, client, messages) {
|
|
404
|
-
// The store is not consulted: a member need not be bound to have spoken.
|
|
405
|
-
const ids = messages.map(speaker).filter((id) => !!id);
|
|
406
|
-
const names = await deps.directory.names(client, ids);
|
|
407
|
-
return messages.map((msg) => {
|
|
408
|
-
const id = speaker(msg);
|
|
409
|
-
const known = id ? names.get(id) : undefined;
|
|
410
|
-
const who = id ? (known && known !== id ? `${known}[${id}]` : `[${id}]`) : "[unknown]";
|
|
411
|
-
const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
|
|
412
|
-
? ` [thread: ${msg.reply_count} replies]`
|
|
413
|
-
: "";
|
|
414
|
-
return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}${uploads(msg.files)}`;
|
|
415
|
-
});
|
|
416
|
-
}
|