@chloejs/core 0.2.0
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 +21 -0
- package/README.md +221 -0
- package/channels/api.ts +41 -0
- package/channels/shared.ts +250 -0
- package/channels/slack.ts +390 -0
- package/channels/telegram.ts +396 -0
- package/core/clock.ts +126 -0
- package/core/confine.ts +45 -0
- package/core/db.ts +117 -0
- package/core/markdown.ts +95 -0
- package/core/notes.ts +44 -0
- package/core/paths.ts +29 -0
- package/core/root.ts +26 -0
- package/core/settings.ts +124 -0
- package/core/steps.ts +896 -0
- package/core/turn.ts +314 -0
- package/do/email.ts +45 -0
- package/do/files.ts +96 -0
- package/do/mail.ts +155 -0
- package/do/run.ts +56 -0
- package/do/scripts.ts +49 -0
- package/do/web.ts +192 -0
- package/index.ts +52 -0
- package/load/job.ts +84 -0
- package/load/load.ts +478 -0
- package/model/ask.ts +84 -0
- package/model/claude.ts +261 -0
- package/model/memory.ts +68 -0
- package/model/model.ts +185 -0
- package/model/tool.ts +53 -0
- package/model/tools/files.ts +71 -0
- package/model/tools/gmail.ts +43 -0
- package/model/tools/index.ts +28 -0
- package/model/tools/memory.ts +23 -0
- package/model/tools/run_script.ts +44 -0
- package/model/tools/send_email.ts +29 -0
- package/model/tools/web.ts +23 -0
- package/model/tools/write_skill.ts +31 -0
- package/ops/account.ts +109 -0
- package/ops/agent.ts +290 -0
- package/ops/check.ts +37 -0
- package/ops/evals.ts +206 -0
- package/ops/install.sh +101 -0
- package/ops/test.ts +1976 -0
- package/package.json +65 -0
- package/scorers/calls.ts +50 -0
- package/scorers/expectations.ts +118 -0
- package/scorers/index.ts +5 -0
- package/serve/alerts.ts +79 -0
- package/serve/errors.ts +10 -0
- package/serve/files.ts +70 -0
- package/serve/http.ts +767 -0
- package/serve/login.ts +299 -0
- package/serve/memory.ts +372 -0
- package/serve/page.ts +142 -0
- package/serve/pass.ts +45 -0
- package/serve/recentWork.ts +69 -0
- package/serve/site.ts +409 -0
- package/serve/tokens.ts +132 -0
- package/server.ts +170 -0
- package/timer/cron.ts +92 -0
- package/timer/every.ts +153 -0
- package/timer/index.ts +4 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Talking to an agent from Slack. It is one entry in the agent's channels:
|
|
2
|
+
//
|
|
3
|
+
// // agents/<name>/agent.ts
|
|
4
|
+
// import { slackChannel } from "@chloejs/core/channels/slack";
|
|
5
|
+
// channels: [slackChannel({ allowFrom: ["U0123ABCD"] })],
|
|
6
|
+
//
|
|
7
|
+
// It needs a Slack app with Socket Mode on, which is chloe opening a
|
|
8
|
+
// connection out to Slack rather than Slack sending to chloe, so nothing is
|
|
9
|
+
// exposed past the login and nothing needs a public address. At
|
|
10
|
+
// api.slack.com/apps: create an app, turn on Socket Mode (it makes the app
|
|
11
|
+
// token, "xapp-..."), and under OAuth & Permissions add the bot scopes
|
|
12
|
+
// chat:write, im:history, channels:history, groups:history, mpim:history,
|
|
13
|
+
// users:read and files:read, plus reactions:write for the "working" mark.
|
|
14
|
+
// Under Event Subscriptions subscribe the bot to message.im, message.channels,
|
|
15
|
+
// message.groups and message.mpim, and under App Home allow messages from the
|
|
16
|
+
// Messages tab. Install it to the workspace, which makes the bot token
|
|
17
|
+
// ("xoxb-..."). The two tokens are SLACK_BOT_TOKEN and SLACK_APP_TOKEN in
|
|
18
|
+
// .env, or `credentials: { botToken, appToken }`. In a channel, invite the bot
|
|
19
|
+
// (/invite @name) before it can read anything there.
|
|
20
|
+
//
|
|
21
|
+
// allowFrom is who may talk to the agent, by Slack member id ("U0123ABCD", in
|
|
22
|
+
// a person's profile under "Copy member ID"). Leave it empty only the first
|
|
23
|
+
// time: until it has an entry, the bot answers a direct message with the
|
|
24
|
+
// sender's id. The first id is also who the agent's jobs ask when they name
|
|
25
|
+
// nobody, in a direct message.
|
|
26
|
+
//
|
|
27
|
+
// Slack answers "/something" itself unless the app declares it, so a job is
|
|
28
|
+
// run by a slash command only once it is added under Slash Commands in the
|
|
29
|
+
// app's settings, named like the job with "_" for "-" (commands() in
|
|
30
|
+
// shared.ts lists them). A job that `answers` plain messages needs nothing.
|
|
31
|
+
//
|
|
32
|
+
// A message in a Slack thread is answered in that thread, and each thread is
|
|
33
|
+
// its own conversation. Buttons for a job's question need Interactivity on,
|
|
34
|
+
// which Socket Mode covers with no address to fill in.
|
|
35
|
+
//
|
|
36
|
+
// What happens to a message once it is read is channels/shared.ts, the same
|
|
37
|
+
// for every channel. This file reads Slack, sends to it, and nothing else.
|
|
38
|
+
import { ownedBy, reachBy, unreach } from "#chloe/model/ask.ts";
|
|
39
|
+
import type { Agent, Channel, ChatHistory, Running } from "#chloe/load/load.ts";
|
|
40
|
+
import type { Attachment } from "#chloe/model/model.ts";
|
|
41
|
+
import { receive, type Incoming, type Rules } from "./shared.ts";
|
|
42
|
+
|
|
43
|
+
const MAX_MESSAGE = 4000; // Slack cuts a message's text at 40000, and advises under 4000.
|
|
44
|
+
const READS = new Set(["auth.test", "apps.connections.open", "users.info", "conversations.info"]);
|
|
45
|
+
|
|
46
|
+
/** How an agent is put on Slack: who may reach it, and how it behaves in a channel. */
|
|
47
|
+
export interface SlackOptions {
|
|
48
|
+
/**
|
|
49
|
+
* "slack" unless the agent is in two workspaces. It is what the log shows a
|
|
50
|
+
* run came in on, and the start of every address on this app, like "slack:U0123ABCD".
|
|
51
|
+
*/
|
|
52
|
+
name?: string;
|
|
53
|
+
/** Instead of SLACK_BOT_TOKEN and SLACK_APP_TOKEN. */
|
|
54
|
+
credentials?: { botToken?: string; appToken?: string };
|
|
55
|
+
/** Slack member ids that may reach the agent. */
|
|
56
|
+
allowFrom?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* In a channel, "when-addressed" (the default) answers only a slash
|
|
59
|
+
* command, a mention, or a reply in a thread the bot started. "always"
|
|
60
|
+
* answers every message from someone in allowFrom.
|
|
61
|
+
*/
|
|
62
|
+
inGroups?: "when-addressed" | "always";
|
|
63
|
+
/** How much of a conversation a turn is shown: `{ messages, days }`. */
|
|
64
|
+
chatHistory?: ChatHistory;
|
|
65
|
+
/** Send what the model writes on its way to an answer as it writes it, not only the answer. Off unless true. */
|
|
66
|
+
sendWhileWorking?: boolean;
|
|
67
|
+
/** Which files are taken, and how big. Anything else is named to the agent but not handed over. */
|
|
68
|
+
uploadPolicy?: { allowedMediaTypes?: string[]; maxBytes?: number };
|
|
69
|
+
/** Where Slack is. Only the tests change it. */
|
|
70
|
+
api?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Which agent has each app. Two readers of one app would each get some of the messages. */
|
|
74
|
+
const taken = new Map<string, string>();
|
|
75
|
+
|
|
76
|
+
interface SlackFile {
|
|
77
|
+
name?: string;
|
|
78
|
+
mimetype?: string;
|
|
79
|
+
size?: number;
|
|
80
|
+
url_private_download?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SlackMessage {
|
|
84
|
+
type: "message" | "app_mention";
|
|
85
|
+
subtype?: string;
|
|
86
|
+
channel: string;
|
|
87
|
+
channel_type?: "im" | "mpim" | "channel" | "group";
|
|
88
|
+
user?: string;
|
|
89
|
+
bot_id?: string;
|
|
90
|
+
text?: string;
|
|
91
|
+
ts: string;
|
|
92
|
+
thread_ts?: string;
|
|
93
|
+
parent_user_id?: string;
|
|
94
|
+
files?: SlackFile[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Envelope {
|
|
98
|
+
envelope_id?: string;
|
|
99
|
+
type: "hello" | "disconnect" | "events_api" | "interactive" | "slash_commands";
|
|
100
|
+
payload?: any;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** An agent on Slack, as a channel its own `agent.ts` names. */
|
|
104
|
+
export function slackChannel(options: SlackOptions = {}): Channel {
|
|
105
|
+
return {
|
|
106
|
+
name: options.name ?? "slack",
|
|
107
|
+
chatHistory: options.chatHistory,
|
|
108
|
+
start(agent) {
|
|
109
|
+
const name = agent()?.name ?? "";
|
|
110
|
+
const token = options.credentials?.botToken || process.env.SLACK_BOT_TOKEN || "";
|
|
111
|
+
const appToken = options.credentials?.appToken || process.env.SLACK_APP_TOKEN || "";
|
|
112
|
+
if (!token || !appToken) {
|
|
113
|
+
console.error(
|
|
114
|
+
`slack: ${name} has a Slack channel but no ${token ? "app token" : "bot token"}. Make an app at api.slack.com/apps ` +
|
|
115
|
+
"with Socket Mode on, and put its tokens in .env as SLACK_BOT_TOKEN (xoxb-...) and SLACK_APP_TOKEN (xapp-...). Then restart.",
|
|
116
|
+
);
|
|
117
|
+
return { stop: () => {} };
|
|
118
|
+
}
|
|
119
|
+
const holder = taken.get(appToken);
|
|
120
|
+
if (holder && holder !== name) {
|
|
121
|
+
console.error(`slack: ${holder} already answers this app, so ${name}'s channel does nothing. Give it its own app.`);
|
|
122
|
+
return { stop: () => {} };
|
|
123
|
+
}
|
|
124
|
+
taken.set(appToken, name);
|
|
125
|
+
const running = listen({ ...options, name, channel: options.name, token, appToken, agent });
|
|
126
|
+
return {
|
|
127
|
+
stop() {
|
|
128
|
+
running.stop();
|
|
129
|
+
taken.delete(appToken);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Reads messages until stopped. Separate from the channel so the tests can point it somewhere else. */
|
|
137
|
+
export function listen(
|
|
138
|
+
options: Omit<SlackOptions, "name"> & {
|
|
139
|
+
/** The agent's name. */
|
|
140
|
+
name: string;
|
|
141
|
+
/** The channel's name, "slack" when left out. */
|
|
142
|
+
channel?: string;
|
|
143
|
+
token: string;
|
|
144
|
+
appToken: string;
|
|
145
|
+
agent: () => Agent | undefined;
|
|
146
|
+
},
|
|
147
|
+
): Running {
|
|
148
|
+
const { name, token } = options;
|
|
149
|
+
const channel = options.channel ?? "slack";
|
|
150
|
+
const api = options.api ?? "https://slack.com/api";
|
|
151
|
+
const rules: Rules = { allowFrom: options.allowFrom ?? [], inGroups: options.inGroups, chatHistory: options.chatHistory, sendWhileWorking: options.sendWhileWorking };
|
|
152
|
+
const allowedTypes = options.uploadPolicy?.allowedMediaTypes ?? ["image/*", "application/pdf", "text/*"];
|
|
153
|
+
const maxBytes = options.uploadPolicy?.maxBytes ?? 10 * 1024 * 1024;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
let socket: WebSocket | undefined;
|
|
156
|
+
let me = "";
|
|
157
|
+
|
|
158
|
+
async function call<T = any>(method: string, body: object, as = token): Promise<T> {
|
|
159
|
+
// Slack reads JSON only on the methods that write, so the others are sent as a form.
|
|
160
|
+
const form = READS.has(method);
|
|
161
|
+
const response = await fetch(`${api}/${method}`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": form ? "application/x-www-form-urlencoded" : "application/json; charset=utf-8", Authorization: `Bearer ${as}` },
|
|
164
|
+
body: form ? new URLSearchParams(body as Record<string, string>).toString() : JSON.stringify(body),
|
|
165
|
+
signal: AbortSignal.timeout(30_000),
|
|
166
|
+
});
|
|
167
|
+
const reply = (await response.json()) as { ok: boolean; error?: string } & T;
|
|
168
|
+
if (!reply.ok) throw new Error(`${method}: ${reply.error ?? response.status}`);
|
|
169
|
+
return reply;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Sending is not stopped with the reading: a turn already under way still answers. */
|
|
173
|
+
async function send(chat: string, text: string, thread?: string, extra: object = {}): Promise<void> {
|
|
174
|
+
for (let i = 0; i < text.length; i += MAX_MESSAGE) {
|
|
175
|
+
const last = i + MAX_MESSAGE >= text.length;
|
|
176
|
+
await call("chat.postMessage", { channel: chat, text: text.slice(i, i + MAX_MESSAGE), ...(thread ? { thread_ts: thread } : {}), ...(last ? extra : {}) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// A job of this agent's that stops to ask somebody reaches them through this
|
|
181
|
+
// app. A member id as the channel is that person's direct message with the
|
|
182
|
+
// bot. Answers that can be listed become buttons, and anything else is
|
|
183
|
+
// answered by the next message in that chat.
|
|
184
|
+
reachBy(
|
|
185
|
+
channel,
|
|
186
|
+
(to, text, choices) =>
|
|
187
|
+
send(
|
|
188
|
+
to,
|
|
189
|
+
text,
|
|
190
|
+
undefined,
|
|
191
|
+
choices?.length
|
|
192
|
+
? {
|
|
193
|
+
blocks: [
|
|
194
|
+
{ type: "section", text: { type: "plain_text", text } },
|
|
195
|
+
{ type: "actions", elements: choices.map((choice, i) => ({ type: "button", action_id: `a:${i}`, text: { type: "plain_text", text: choice }, value: choice })) },
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
: {},
|
|
199
|
+
),
|
|
200
|
+
name,
|
|
201
|
+
);
|
|
202
|
+
if (options.allowFrom?.[0]) ownedBy(name, `${channel}:${options.allowFrom[0]}`);
|
|
203
|
+
|
|
204
|
+
/** Names by id, asked of Slack once each. An id stands in when Slack will not say. */
|
|
205
|
+
const names = new Map<string, Promise<string>>();
|
|
206
|
+
function named(kind: "user" | "channel", id: string): Promise<string> {
|
|
207
|
+
const key = `${kind}:${id}`;
|
|
208
|
+
if (!names.has(key)) {
|
|
209
|
+
names.set(
|
|
210
|
+
key,
|
|
211
|
+
kind === "user"
|
|
212
|
+
? call("users.info", { user: id }).then((r) => r.user?.profile?.display_name || r.user?.real_name || r.user?.name || id, () => id)
|
|
213
|
+
: call("conversations.info", { channel: id }).then((r) => (r.channel?.name ? `#${r.channel.name}` : ""), () => ""),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return names.get(key)!;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function wanted(mediaType: string): boolean {
|
|
220
|
+
return allowedTypes.some((one) => (one.endsWith("/*") ? mediaType.startsWith(one.slice(0, -1)) : one === mediaType));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The files on a message, fetched, and a line for each one that was not. */
|
|
224
|
+
async function filesOn(message: SlackMessage): Promise<{ attachments: Attachment[]; text?: string; notes: string[] }> {
|
|
225
|
+
const attachments: Attachment[] = [];
|
|
226
|
+
const texts: string[] = [];
|
|
227
|
+
const notes: string[] = [];
|
|
228
|
+
for (const file of message.files ?? []) {
|
|
229
|
+
const fileName = file.name ?? "file";
|
|
230
|
+
const mediaType = file.mimetype ?? "application/octet-stream";
|
|
231
|
+
if (!wanted(mediaType)) notes.push(`(They sent ${fileName}, a ${mediaType}, which this channel does not take.)`);
|
|
232
|
+
else if ((file.size ?? 0) > maxBytes) notes.push(`(They sent ${fileName}, which is over the ${Math.round(maxBytes / 1048576)}MB this channel takes.)`);
|
|
233
|
+
else if (file.url_private_download) {
|
|
234
|
+
const response = await fetch(file.url_private_download, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(60_000) });
|
|
235
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
236
|
+
if (mediaType.startsWith("text/")) texts.push(`<file name="${fileName}">\n${bytes.toString("utf8")}\n</file>`);
|
|
237
|
+
else {
|
|
238
|
+
attachments.push({ mediaType, data: bytes.toString("base64"), name: fileName });
|
|
239
|
+
notes.push(`(Attached: ${fileName})`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { attachments, text: texts.join("\n\n") || undefined, notes };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Slack shows no "typing..." for a bot, so an eyes mark on the message stands in, taken off when the work is over. */
|
|
247
|
+
function working(chat: string, ts: string): () => void {
|
|
248
|
+
const mark = { channel: chat, timestamp: ts, name: "eyes" };
|
|
249
|
+
void call("reactions.add", mark).catch(() => {});
|
|
250
|
+
return () => void call("reactions.remove", mark).catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A Slack message in the words every channel shares. In a direct message
|
|
255
|
+
* the chat is the person's member id, which is also where a job asks them
|
|
256
|
+
* something, so their answer comes back from the same address.
|
|
257
|
+
*/
|
|
258
|
+
async function incoming(message: SlackMessage, user: string, text: string): Promise<Incoming> {
|
|
259
|
+
const direct = message.channel_type === "im";
|
|
260
|
+
const chat = direct ? user : message.channel;
|
|
261
|
+
const mention = me ? `<@${me}>` : "";
|
|
262
|
+
const mentioned = !!mention && text.includes(mention);
|
|
263
|
+
const title = direct ? "" : await named("channel", message.channel);
|
|
264
|
+
return {
|
|
265
|
+
channel,
|
|
266
|
+
chat,
|
|
267
|
+
thread: `${name}/${channel}-${chat}${message.thread_ts ? `-${message.thread_ts}` : ""}`,
|
|
268
|
+
from: { id: user, name: await named("user", user) },
|
|
269
|
+
// The mention is how it was addressed, not part of what was said, and would hide a leading "/".
|
|
270
|
+
text: mentioned ? text.replaceAll(mention, "").trim() : text,
|
|
271
|
+
private: direct,
|
|
272
|
+
addressed: mentioned || (!!me && message.parent_user_id === me),
|
|
273
|
+
chatTitle: title,
|
|
274
|
+
context: {
|
|
275
|
+
chat_type: message.channel_type ?? "channel",
|
|
276
|
+
...(title ? { chat_title: title } : {}),
|
|
277
|
+
is_mentioned: String(mentioned),
|
|
278
|
+
},
|
|
279
|
+
files: () => filesOn(message),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Each message once: Slack sends a mention both as a message and as an app_mention, and sends again what was not acknowledged in time. */
|
|
284
|
+
const seen = new Set<string>();
|
|
285
|
+
function firstTime(key: string): boolean {
|
|
286
|
+
if (seen.has(key)) return false;
|
|
287
|
+
seen.add(key);
|
|
288
|
+
if (seen.size > 500) seen.delete(seen.values().next().value!);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function onMessage(message: SlackMessage): Promise<void> {
|
|
293
|
+
const agent = options.agent();
|
|
294
|
+
// Edits, deletions, joins and the like have a subtype; a message with a file is the one kept.
|
|
295
|
+
if (!agent || !message.user || message.bot_id || message.user === me) return;
|
|
296
|
+
if (message.subtype && message.subtype !== "file_share" && message.subtype !== "thread_broadcast") return;
|
|
297
|
+
if (!message.text && !message.files?.length) return;
|
|
298
|
+
if (!firstTime(`${message.channel}/${message.ts}`)) return;
|
|
299
|
+
const handled = await receive(agent, await incoming(message, message.user, message.text ?? ""), rules, {
|
|
300
|
+
working: () => working(message.channel, message.ts),
|
|
301
|
+
send: (words) => send(message.channel, words, message.thread_ts),
|
|
302
|
+
});
|
|
303
|
+
if (handled?.text) await send(message.channel, handled.text, message.thread_ts).catch((error) => console.error("slack:", error.message));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A slash command is its command and text, sent by whoever typed it, and always addressed to the agent. */
|
|
307
|
+
async function onCommand(command: { command: string; text?: string; user_id: string; channel_id: string; response_url?: string }): Promise<void> {
|
|
308
|
+
const agent = options.agent();
|
|
309
|
+
if (!agent) return;
|
|
310
|
+
const direct = command.channel_id.startsWith("D");
|
|
311
|
+
const message: SlackMessage = { type: "message", channel: command.channel_id, channel_type: direct ? "im" : "channel", user: command.user_id, ts: "" };
|
|
312
|
+
const text = `${command.command} ${command.text ?? ""}`.trim();
|
|
313
|
+
// The response address works in a channel the bot was never invited to, where posting does not.
|
|
314
|
+
const reply = (words: string) =>
|
|
315
|
+
command.response_url
|
|
316
|
+
? fetch(command.response_url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ response_type: "in_channel", text: words }) }).then(() => {})
|
|
317
|
+
: send(command.channel_id, words);
|
|
318
|
+
const handled = await receive(agent, { ...(await incoming(message, command.user_id, text)), addressed: true }, rules, { send: reply });
|
|
319
|
+
if (handled?.text) await reply(handled.text).catch((error) => console.error("slack:", error.message));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A pressed button is its text, sent by whoever pressed it, which is how it answers a waiting job. */
|
|
323
|
+
async function onButton(payload: any): Promise<void> {
|
|
324
|
+
const agent = options.agent();
|
|
325
|
+
const choice: string | undefined = payload.actions?.[0]?.value;
|
|
326
|
+
const chat: string | undefined = payload.channel?.id ?? payload.container?.channel_id;
|
|
327
|
+
if (!agent || !choice || !chat || !payload.user?.id) return;
|
|
328
|
+
const question: string = payload.message?.text ?? "";
|
|
329
|
+
const message: SlackMessage = { type: "message", channel: chat, channel_type: chat.startsWith("D") ? "im" : "channel", user: payload.user.id, ts: payload.message?.ts ?? "" };
|
|
330
|
+
const handled = await receive(agent, { ...(await incoming(message, payload.user.id, choice)), addressed: true }, rules);
|
|
331
|
+
if (!handled) return;
|
|
332
|
+
// Take the buttons away so the question cannot be answered twice, and say what was chosen.
|
|
333
|
+
if (message.ts) await call("chat.update", { channel: chat, ts: message.ts, text: `${question}\n\n→ ${choice}`, blocks: [] }).catch(() => {});
|
|
334
|
+
if (handled.text) await send(chat, handled.text).catch(() => {});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handle(envelope: Envelope): void {
|
|
338
|
+
const failed = (error: Error) => console.error("slack:", error);
|
|
339
|
+
const payload = envelope.payload;
|
|
340
|
+
if (envelope.type === "events_api") {
|
|
341
|
+
const event = payload?.event as SlackMessage | undefined;
|
|
342
|
+
if (event?.type === "message" || event?.type === "app_mention") void onMessage(event).catch(failed);
|
|
343
|
+
}
|
|
344
|
+
if (envelope.type === "slash_commands" && payload) void onCommand(payload).catch(failed);
|
|
345
|
+
if (envelope.type === "interactive" && payload?.type === "block_actions") void onButton(payload).catch(failed);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** One connection at a time, opened again whenever Slack closes it, which it does every few hours. */
|
|
349
|
+
async function connect(): Promise<void> {
|
|
350
|
+
while (!stopped) {
|
|
351
|
+
try {
|
|
352
|
+
if (!me) me = (await call<{ user_id: string }>("auth.test", {})).user_id;
|
|
353
|
+
const { url } = await call<{ url: string }>("apps.connections.open", {}, options.appToken);
|
|
354
|
+
await new Promise<void>((closed, failed) => {
|
|
355
|
+
const ws = new WebSocket(url);
|
|
356
|
+
socket = ws;
|
|
357
|
+
ws.onmessage = (event) => {
|
|
358
|
+
let envelope: Envelope;
|
|
359
|
+
try {
|
|
360
|
+
envelope = JSON.parse(String(event.data)) as Envelope;
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Acknowledged at once, or Slack sends it again three seconds later.
|
|
365
|
+
if (envelope.envelope_id) ws.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
366
|
+
if (envelope.type === "disconnect") ws.close();
|
|
367
|
+
else handle(envelope);
|
|
368
|
+
};
|
|
369
|
+
ws.onclose = () => closed();
|
|
370
|
+
ws.onerror = () => failed(new Error("the connection to Slack failed"));
|
|
371
|
+
});
|
|
372
|
+
// A moment before the next one, so a connection that keeps closing is not opened again in a tight loop.
|
|
373
|
+
if (!stopped) await new Promise((done) => setTimeout(done, 1000));
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (stopped) break;
|
|
376
|
+
console.error(`slack: ${name} could not reach Slack, trying again in 5s:`, (error as Error).message);
|
|
377
|
+
await new Promise((done) => setTimeout(done, 5000));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
void connect();
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
stop() {
|
|
385
|
+
stopped = true;
|
|
386
|
+
socket?.close();
|
|
387
|
+
unreach(channel, name);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|