@timqi/pier 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- package/skills/pier-tasks/SKILL.md +120 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// The agent-facing Slack tool: the model states an intent, Pier performs it.
|
|
2
|
+
//
|
|
3
|
+
// The token never leaves Pier. The agent has no Slack client, no scopes and no
|
|
4
|
+
// idea what a `ts` is; it names a channel and a time range, and gets a
|
|
5
|
+
// transcript back. That is the whole point of putting this behind a tool
|
|
6
|
+
// instead of documenting the Slack API in a skill.
|
|
7
|
+
//
|
|
8
|
+
// Every read goes to Slack. Slack is the source of truth and it is the only
|
|
9
|
+
// party that knows about an edit or a deletion, so a stored copy can only be a
|
|
10
|
+
// copy that is wrong later — and Pier is a workspace-internal app, where
|
|
11
|
+
// `conversations.history`/`replies` are Tier 3 (~50+ req/min) and a read costs
|
|
12
|
+
// one or two calls. If that assumption changes (a distributed non-Marketplace
|
|
13
|
+
// app is capped at 1 req/min), this is the decision to revisit.
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { MARKDOWN_MAX } from "./slack-render.js";
|
|
16
|
+
/** Hard cap on one read, so a wide range cannot blow up the model's context. */
|
|
17
|
+
const MAX_MESSAGES = 400;
|
|
18
|
+
/** Pages to walk before giving up on a very wide window. */
|
|
19
|
+
const MAX_PAGES = 10;
|
|
20
|
+
/**
|
|
21
|
+
* A Slack `ts` is `<epoch seconds>.<microseconds>` and sorts correctly as a
|
|
22
|
+
* number but *not* as a string once the integer part changes width. Ordering
|
|
23
|
+
* goes through this; the string itself is never rewritten, because it is the
|
|
24
|
+
* id a reply or a reaction has to match exactly.
|
|
25
|
+
*/
|
|
26
|
+
const tsToNumber = (ts) => Number(ts);
|
|
27
|
+
const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
|
|
28
|
+
/** Minute precision in the transcript: the exact time is in the `ts` beside it. */
|
|
29
|
+
const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
|
|
30
|
+
/** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
|
|
31
|
+
export function toTs(value) {
|
|
32
|
+
if (value === undefined || value === "")
|
|
33
|
+
return undefined;
|
|
34
|
+
if (typeof value === "number")
|
|
35
|
+
return String(value);
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
38
|
+
return trimmed;
|
|
39
|
+
const parsed = Date.parse(trimmed);
|
|
40
|
+
if (Number.isNaN(parsed))
|
|
41
|
+
throw new Error(`not a time: ${value}`);
|
|
42
|
+
return String(parsed / 1000);
|
|
43
|
+
}
|
|
44
|
+
export function slackToolSpec(execute) {
|
|
45
|
+
return {
|
|
46
|
+
name: "slack",
|
|
47
|
+
label: "Slack",
|
|
48
|
+
description: "Read and write Slack through Pier, which holds the bot token. State what you want; Pier does the paging and hands back a finished transcript. context says which Slack conversation you are in; read_channel returns a channel's transcript for a time range; read_thread returns one thread, or only what is new in it since a given message via after; read_message returns the single message at a ts (pass thread_ts when it is a reply inside a thread); post sends a message; channels lists what Pier can reach. When you were reached through Slack, omit channel (and thread_ts) to act on the conversation you are already in. since/until/after accept ISO 8601, epoch seconds or a ts from an earlier read. Every read fetches live from Slack, so nothing is kept between calls — write down what you need to keep. Message text is standard markdown, but @mentions, #channels and links need Slack's own syntax — read the pier-slack skill before posting.",
|
|
49
|
+
parameters: Type.Object({
|
|
50
|
+
// A JSON-Schema enum emits far fewer tokens than typebox's anyOf-of-consts.
|
|
51
|
+
operation: Type.Unsafe({
|
|
52
|
+
type: "string",
|
|
53
|
+
enum: ["context", "read_channel", "read_thread", "read_message", "post", "channels"],
|
|
54
|
+
}),
|
|
55
|
+
/**
|
|
56
|
+
* Channel id (`C…`/`D…`/`G…`) or the `#name` shown by `channels`. Omit to
|
|
57
|
+
* use the conversation this session is answering.
|
|
58
|
+
*/
|
|
59
|
+
channel: Type.Optional(Type.String()),
|
|
60
|
+
since: Type.Optional(Type.String()),
|
|
61
|
+
until: Type.Optional(Type.String()),
|
|
62
|
+
/** Strictly newer than this — "what changed since I last looked". */
|
|
63
|
+
after: Type.Optional(Type.String()),
|
|
64
|
+
/** The one message `read_message` is about. */
|
|
65
|
+
ts: Type.Optional(Type.String()),
|
|
66
|
+
limit: Type.Optional(Type.Number()),
|
|
67
|
+
thread_ts: Type.Optional(Type.String()),
|
|
68
|
+
text: Type.Optional(Type.String()),
|
|
69
|
+
}),
|
|
70
|
+
execute,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const required = (value, field) => {
|
|
74
|
+
if (typeof value !== "string" || !value.trim())
|
|
75
|
+
throw new Error(`${field} is required`);
|
|
76
|
+
return value.trim();
|
|
77
|
+
};
|
|
78
|
+
const record = (raw) => raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
79
|
+
export async function handleSlackTool(deps, raw, callerSessionId = "") {
|
|
80
|
+
const input = record(raw);
|
|
81
|
+
if (!input)
|
|
82
|
+
throw new Error("slack tool parameters required");
|
|
83
|
+
const config = deps.store.get("slack");
|
|
84
|
+
// Two switches, and the error says which one, because "it does nothing" is
|
|
85
|
+
// the most expensive kind of failure for a model to diagnose.
|
|
86
|
+
if (!config.enabled || !config.token)
|
|
87
|
+
throw new Error("the Slack channel is not configured in Pier");
|
|
88
|
+
if (!config.agentTool)
|
|
89
|
+
throw new Error("Slack agent access is switched off in Pier's Console");
|
|
90
|
+
const client = deps.client();
|
|
91
|
+
if (!client)
|
|
92
|
+
throw new Error("the Slack client is unavailable");
|
|
93
|
+
if (input.operation === "channels") {
|
|
94
|
+
// Only what Pier has actually seen: Slack has no reliable "list my
|
|
95
|
+
// channels", and a name the agent cannot address is worse than no name.
|
|
96
|
+
return config.chats.map((chat) => ({
|
|
97
|
+
id: chat.id,
|
|
98
|
+
name: chat.name,
|
|
99
|
+
kind: chat.kind,
|
|
100
|
+
respondsToMessages: chat.enabled,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
const at = deps.here(callerSessionId);
|
|
104
|
+
if (input.operation === "context") {
|
|
105
|
+
if (!at) {
|
|
106
|
+
return {
|
|
107
|
+
inSlack: false,
|
|
108
|
+
note: "This session was not reached through Slack, so there is no current conversation. Name a channel explicitly.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const chat = config.chats.find((c) => c.id === at.channel);
|
|
112
|
+
return {
|
|
113
|
+
inSlack: true,
|
|
114
|
+
channel: at.channel,
|
|
115
|
+
channelName: chat?.name ?? at.channel,
|
|
116
|
+
kind: chat?.kind ?? null,
|
|
117
|
+
threadTs: at.threadTs,
|
|
118
|
+
note: "Omit channel and thread_ts to read or post here. Speaker ids for mentions come from read_thread.",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// "Here" is the default target: an agent reached through a Slack thread
|
|
122
|
+
// should not have to be told which thread it is standing in.
|
|
123
|
+
const channel = input.channel === undefined || input.channel === ""
|
|
124
|
+
? at?.channel ??
|
|
125
|
+
(() => {
|
|
126
|
+
throw new Error("channel is required: this session was not reached through Slack, so there is no current conversation");
|
|
127
|
+
})()
|
|
128
|
+
: resolveChannel(deps, required(input.channel, "channel"));
|
|
129
|
+
// `after` is the "what is new since I last looked" form of `since`: Slack's
|
|
130
|
+
// own bounds are inclusive-ish, so the boundary message is dropped here
|
|
131
|
+
// rather than trusted to the API.
|
|
132
|
+
const after = toTs(input.after);
|
|
133
|
+
if (input.operation === "read_channel") {
|
|
134
|
+
const since = after ?? toTs(input.since);
|
|
135
|
+
const until = toTs(input.until);
|
|
136
|
+
const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
|
|
137
|
+
return readChannel(deps, client, channel, since, until, after, limit);
|
|
138
|
+
}
|
|
139
|
+
if (input.operation === "read_thread") {
|
|
140
|
+
const threadTs = typeof input.thread_ts === "string" && input.thread_ts.trim()
|
|
141
|
+
? input.thread_ts.trim()
|
|
142
|
+
: at?.threadTs;
|
|
143
|
+
if (!threadTs)
|
|
144
|
+
throw new Error("thread_ts is required outside a Slack thread");
|
|
145
|
+
return readThread(deps, client, channel, threadTs, after);
|
|
146
|
+
}
|
|
147
|
+
if (input.operation === "read_message") {
|
|
148
|
+
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
149
|
+
return readMessage(deps, client, channel, required(input.ts, "ts"), asked || undefined);
|
|
150
|
+
}
|
|
151
|
+
if (input.operation === "post") {
|
|
152
|
+
const text = required(input.text, "text");
|
|
153
|
+
if (text.length > MARKDOWN_MAX) {
|
|
154
|
+
throw new Error(`text is ${text.length} chars; Slack accepts ${MARKDOWN_MAX} per message`);
|
|
155
|
+
}
|
|
156
|
+
// Defaults to the thread we are in; `thread_ts: "none"` is the explicit
|
|
157
|
+
// way to start a new top-level message instead.
|
|
158
|
+
const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
|
|
159
|
+
const threadTs = asked === "none"
|
|
160
|
+
? undefined
|
|
161
|
+
: asked || (channel === at?.channel ? at?.threadTs : undefined);
|
|
162
|
+
const sent = await client.postMessage({
|
|
163
|
+
channel,
|
|
164
|
+
thread_ts: threadTs,
|
|
165
|
+
text,
|
|
166
|
+
// Slack's own markdown renderer: the agent writes markdown, not mrkdwn.
|
|
167
|
+
blocks: [{ type: "markdown", text }],
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
channel,
|
|
171
|
+
ts: sent.ts,
|
|
172
|
+
at: sent.ts ? tsToIso(sent.ts) : null,
|
|
173
|
+
// Returned so a follow-up can reply under what was just posted.
|
|
174
|
+
threadTs: threadTs ?? sent.ts,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`unknown slack operation: ${String(input.operation)}`);
|
|
178
|
+
}
|
|
179
|
+
/** Accept a `#name` or a bare name as well as an id — models prefer names. */
|
|
180
|
+
function resolveChannel(deps, given) {
|
|
181
|
+
if (/^[CDG][A-Z0-9]+$/.test(given))
|
|
182
|
+
return given;
|
|
183
|
+
const wanted = given.replace(/^#/, "").toLowerCase();
|
|
184
|
+
const chats = deps.store.get("slack").chats;
|
|
185
|
+
const hit = chats.find((chat) => chat.name.replace(/^#/, "").toLowerCase() === wanted);
|
|
186
|
+
if (hit)
|
|
187
|
+
return hit.id;
|
|
188
|
+
throw new Error(`unknown channel ${given}; use an id or one of: ${chats.map((c) => c.name).join(", ") || "(none discovered yet)"}`);
|
|
189
|
+
}
|
|
190
|
+
async function readChannel(deps, client, channel, since, until, after, limit) {
|
|
191
|
+
const fetched = await fetchPages(deps, (cursor) => client.history(channel, { oldest: since, latest: until, cursor }), `history for ${channel}`);
|
|
192
|
+
const all = newerThan(transcript(fetched.messages), after);
|
|
193
|
+
// Slack hands back the newest first, so a window wider than the caps is
|
|
194
|
+
// truncated at its newest end — the oldest `limit` messages are the ones
|
|
195
|
+
// that read as a transcript.
|
|
196
|
+
const window = all.slice(0, limit);
|
|
197
|
+
return {
|
|
198
|
+
channel,
|
|
199
|
+
range: `${since ? tsToMinute(since) : "start"} → ${until ? tsToMinute(until) : "now"}`,
|
|
200
|
+
count: window.length,
|
|
201
|
+
...(fetched.truncated || all.length > window.length ? { truncated: true } : {}),
|
|
202
|
+
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
203
|
+
format: LINE_FORMAT,
|
|
204
|
+
messages: await lines(deps, client, window),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async function readThread(deps, client, channel, threadTs, after) {
|
|
208
|
+
const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
|
|
209
|
+
const messages = newerThan(transcript(fetched.messages), after);
|
|
210
|
+
return {
|
|
211
|
+
channel,
|
|
212
|
+
// Hoisted: every line in a thread carries the same one.
|
|
213
|
+
threadTs,
|
|
214
|
+
count: messages.length,
|
|
215
|
+
...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
|
|
216
|
+
format: LINE_FORMAT,
|
|
217
|
+
messages: await lines(deps, client, messages),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* One message, because that is sometimes the whole question. A `ts` is unique
|
|
222
|
+
* only within its conversation, and `conversations.history` never returns what
|
|
223
|
+
* was posted inside a thread — so a reply has to be asked for through its
|
|
224
|
+
* thread, and saying which one is the caller's job.
|
|
225
|
+
*/
|
|
226
|
+
async function readMessage(deps, client, channel, ts, threadTs) {
|
|
227
|
+
const page = threadTs
|
|
228
|
+
? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
|
|
229
|
+
: await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
|
|
230
|
+
const found = page.messages.find((msg) => msg.ts === ts);
|
|
231
|
+
if (!found) {
|
|
232
|
+
throw new Error(threadTs
|
|
233
|
+
? `no message ${ts} in thread ${threadTs}`
|
|
234
|
+
: `no message ${ts} in that channel — a reply posted inside a thread needs thread_ts`);
|
|
235
|
+
}
|
|
236
|
+
const [line] = await lines(deps, client, [found]);
|
|
237
|
+
return {
|
|
238
|
+
channel,
|
|
239
|
+
...(found.thread_ts ? { threadTs: found.thread_ts } : {}),
|
|
240
|
+
format: LINE_FORMAT,
|
|
241
|
+
message: line,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Walk the cursor until it ends or the caps bite.
|
|
246
|
+
*
|
|
247
|
+
* A page that fails mid-walk does not throw away the pages before it: an
|
|
248
|
+
* agent that asked for a day of history and got an exception cannot tell a
|
|
249
|
+
* broken read from a quiet channel. It gets what there was, plus why the walk
|
|
250
|
+
* stopped, and decides for itself whether to retry or work with it.
|
|
251
|
+
*/
|
|
252
|
+
async function fetchPages(deps, page, what) {
|
|
253
|
+
const messages = [];
|
|
254
|
+
let cursor;
|
|
255
|
+
for (let i = 0; i < MAX_PAGES; i++) {
|
|
256
|
+
let batch;
|
|
257
|
+
try {
|
|
258
|
+
batch = await page(cursor);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (messages.length === 0)
|
|
262
|
+
throw new Error(explain(err));
|
|
263
|
+
deps.log(`${what} stopped after ${messages.length} messages: ${String(err)}`);
|
|
264
|
+
return { messages, truncated: true, incomplete: explain(err) };
|
|
265
|
+
}
|
|
266
|
+
messages.push(...batch.messages);
|
|
267
|
+
cursor = batch.nextCursor;
|
|
268
|
+
if (!cursor || messages.length >= MAX_MESSAGES)
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (cursor)
|
|
272
|
+
deps.log(`${what} truncated at ${messages.length} messages`);
|
|
273
|
+
return { messages, truncated: cursor !== undefined };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Slack's error codes are not instructions. Turn the ones an agent can act on
|
|
277
|
+
* into the action; anything else keeps its raw code, which is at least
|
|
278
|
+
* searchable.
|
|
279
|
+
*/
|
|
280
|
+
function explain(err) {
|
|
281
|
+
const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
|
|
282
|
+
return {
|
|
283
|
+
channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
|
|
284
|
+
not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
|
|
285
|
+
missing_scope: "Pier's Slack app lacks the scope for this read; the operator must reinstall it",
|
|
286
|
+
ratelimited: "Slack rate-limited this read; wait a minute or ask for a narrower range",
|
|
287
|
+
thread_not_found: "no thread with that ts in this channel",
|
|
288
|
+
}[code] ?? String(err);
|
|
289
|
+
}
|
|
290
|
+
/** Strictly newer, so `after: <last ts I saw>` never repeats that message. */
|
|
291
|
+
const newerThan = (messages, after) => after === undefined ? messages : messages.filter((m) => tsToNumber(m.ts) > tsToNumber(after));
|
|
292
|
+
/**
|
|
293
|
+
* Oldest first, one message per ts. Slack answers newest-first and its page
|
|
294
|
+
* bounds are inclusive-ish, so a paged read can repeat the message on the
|
|
295
|
+
* seam; the SQL `PRIMARY KEY` used to absorb that.
|
|
296
|
+
*/
|
|
297
|
+
function transcript(messages) {
|
|
298
|
+
const byTs = new Map();
|
|
299
|
+
for (const msg of messages)
|
|
300
|
+
if (msg.ts)
|
|
301
|
+
byTs.set(msg.ts, msg);
|
|
302
|
+
return [...byTs.values()].sort((a, b) => tsToNumber(a.ts) - tsToNumber(b.ts));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* One line per message instead of one object per message. Four hundred
|
|
306
|
+
* six-key objects spend most of their tokens on the key names; the same
|
|
307
|
+
* transcript as lines costs a fraction, and a model reads it more easily than
|
|
308
|
+
* it reads JSON. The shape is declared in the reply's `format` so nothing has
|
|
309
|
+
* to be guessed.
|
|
310
|
+
*
|
|
311
|
+
* The name makes it readable, the id is the only thing `<@…>` can be built
|
|
312
|
+
* from, and Slack's own `ts` string is passed through untouched — it is what
|
|
313
|
+
* a reply, a reaction or `after` has to match exactly.
|
|
314
|
+
*/
|
|
315
|
+
const LINE_FORMAT = "<ts> | <time, UTC> | <name>[<id>] | <text>";
|
|
316
|
+
const speaker = (msg) => msg.user ?? msg.bot_id ?? null;
|
|
317
|
+
async function lines(deps, client, messages) {
|
|
318
|
+
// Names come from the directory the adapter also uses, so re-reading a
|
|
319
|
+
// thread costs no lookups; the store is not consulted because a member need
|
|
320
|
+
// not be bound to have spoken.
|
|
321
|
+
const ids = messages.map(speaker).filter((id) => !!id);
|
|
322
|
+
const names = await deps.directory.names(client, ids);
|
|
323
|
+
return messages.map((msg) => {
|
|
324
|
+
const id = speaker(msg);
|
|
325
|
+
const known = id ? names.get(id) : undefined;
|
|
326
|
+
const who = id ? (known && known !== id ? `${known}[${id}]` : `[${id}]`) : "[unknown]";
|
|
327
|
+
// A parent's reply count, so the agent can decide whether the thread is
|
|
328
|
+
// worth opening instead of spending a read to find out.
|
|
329
|
+
const replies = msg.reply_count && (msg.thread_ts ?? msg.ts) === msg.ts
|
|
330
|
+
? ` [thread: ${msg.reply_count} replies]`
|
|
331
|
+
: "";
|
|
332
|
+
return `${msg.ts} | ${tsToMinute(msg.ts)} | ${who} | ${msg.text ?? ""}${replies}`;
|
|
333
|
+
});
|
|
334
|
+
}
|