@gitterm/slack-bot 0.1.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/README.md +274 -0
- package/dist/adapter.d.ts +19 -0
- package/dist/blocks.d.ts +58 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +602 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +573 -0
- package/dist/manifest.d.ts +37 -0
- package/dist/manifest.js +43 -0
- package/package.json +56 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { BOT_ENV_HELP, cliOptions, hasRepository } from "@gitterm/bot";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
import { createBot, withSavedConfig as loadSaved } from "@gitterm/bot";
|
|
8
|
+
|
|
9
|
+
// src/adapter.ts
|
|
10
|
+
import { downloadImage } from "@gitterm/bot";
|
|
11
|
+
import {
|
|
12
|
+
App,
|
|
13
|
+
LogLevel,
|
|
14
|
+
webApi
|
|
15
|
+
} from "@slack/bolt";
|
|
16
|
+
|
|
17
|
+
// src/blocks.ts
|
|
18
|
+
import { splitMessage, tablesToCode } from "@gitterm/bot";
|
|
19
|
+
var ACTION = {
|
|
20
|
+
option: /^gitterm_option_\d+$/,
|
|
21
|
+
toggle: "gitterm_toggle",
|
|
22
|
+
submit: "gitterm_submit",
|
|
23
|
+
custom: "gitterm_custom",
|
|
24
|
+
permission: /^gitterm_permission_(once|always|reject)$/
|
|
25
|
+
};
|
|
26
|
+
var CUSTOM_MODAL = "gitterm_custom_modal";
|
|
27
|
+
var CUSTOM_INPUT = "gitterm_custom_text";
|
|
28
|
+
var BLOCK_PREFIX = "gitterm";
|
|
29
|
+
var BUTTONS_PER_ROW = 5;
|
|
30
|
+
var CHECKBOX_LIMIT = 10;
|
|
31
|
+
var LABEL_LIMIT = 75;
|
|
32
|
+
var SECTION_LIMIT = 2900;
|
|
33
|
+
var SECTIONS_PER_MESSAGE = 10;
|
|
34
|
+
var OPEN_COLOR = "#E8A33D";
|
|
35
|
+
var SETTLED_COLOR = "#B8BFC7";
|
|
36
|
+
var truncate = (value, limit) => value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
|
|
37
|
+
var plain = (text, limit = LABEL_LIMIT) => ({
|
|
38
|
+
type: "plain_text",
|
|
39
|
+
text: truncate(text, limit)
|
|
40
|
+
});
|
|
41
|
+
var context = (text) => ({
|
|
42
|
+
type: "context",
|
|
43
|
+
elements: [{ type: "mrkdwn", text }]
|
|
44
|
+
});
|
|
45
|
+
var section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
|
|
46
|
+
var blockId = (promptId, row = 0) => `${BLOCK_PREFIX}|${promptId}|${row}`;
|
|
47
|
+
var promptIdOf = (id) => {
|
|
48
|
+
const [prefix, promptId] = id.split("|");
|
|
49
|
+
return prefix === BLOCK_PREFIX && promptId ? promptId : null;
|
|
50
|
+
};
|
|
51
|
+
function toMrkdwn(markdown) {
|
|
52
|
+
let inCode = false;
|
|
53
|
+
return markdown.split(`
|
|
54
|
+
`).map((line) => {
|
|
55
|
+
if (line.trimStart().startsWith("```")) {
|
|
56
|
+
inCode = !inCode;
|
|
57
|
+
return line;
|
|
58
|
+
}
|
|
59
|
+
if (inCode)
|
|
60
|
+
return line;
|
|
61
|
+
return line.replace(/^#{1,6}\s+(.+)$/, "*$1*").replace(/\*\*(.+?)\*\*/g, "*$1*").replace(/~~(.+?)~~/g, "~$1~").replace(/\[([^\]]+)]\((https?:\/\/[^)]+)\)/g, "<$2|$1>");
|
|
62
|
+
}).join(`
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
function answerMessages(markdown, footer) {
|
|
66
|
+
const quiet = markdown.replace(/<!(channel|here|everyone)[^>]*>/g, "@$1");
|
|
67
|
+
const sections = splitMessage(toMrkdwn(tablesToCode(quiet)), SECTION_LIMIT);
|
|
68
|
+
const messages = [];
|
|
69
|
+
for (let index = 0;index < sections.length; index += SECTIONS_PER_MESSAGE) {
|
|
70
|
+
const group = sections.slice(index, index + SECTIONS_PER_MESSAGE);
|
|
71
|
+
const last = index + SECTIONS_PER_MESSAGE >= sections.length;
|
|
72
|
+
messages.push({
|
|
73
|
+
text: group.join(`
|
|
74
|
+
|
|
75
|
+
`),
|
|
76
|
+
blocks: [...group.map(section), ...last ? [{ type: "divider" }, context(footer)] : []]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return messages;
|
|
80
|
+
}
|
|
81
|
+
function leadIn(prompt, mention) {
|
|
82
|
+
const who = mention ? ` for <@${prompt.requester.id}>` : "";
|
|
83
|
+
if (prompt.kind === "permission")
|
|
84
|
+
return context(`*Approval needed${who}*`);
|
|
85
|
+
const counter = prompt.total > 1 ? `Question ${prompt.index + 1} of ${prompt.total}` : "Question";
|
|
86
|
+
return context(`*${counter}${who}* · ${prompt.question.header}`);
|
|
87
|
+
}
|
|
88
|
+
function body(prompt) {
|
|
89
|
+
if (prompt.kind === "permission") {
|
|
90
|
+
const { request } = prompt;
|
|
91
|
+
const patterns = request.patterns.filter(Boolean);
|
|
92
|
+
return [
|
|
93
|
+
section(`The agent wants to run *${request.title}*`),
|
|
94
|
+
...patterns.length ? [section(`\`\`\`${patterns.join(`
|
|
95
|
+
`).slice(0, 2800)}\`\`\``)] : []
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
const { question } = prompt;
|
|
99
|
+
const options = question.options.map((option, i) => `${i + 1}. *${option.label}*${option.description ? ` — ${option.description}` : ""}`).join(`
|
|
100
|
+
`);
|
|
101
|
+
return [section(`*${question.question}*`), ...options ? [section(options)] : []];
|
|
102
|
+
}
|
|
103
|
+
var customButton = {
|
|
104
|
+
type: "button",
|
|
105
|
+
action_id: ACTION.custom,
|
|
106
|
+
text: plain("Write your own answer"),
|
|
107
|
+
value: "custom"
|
|
108
|
+
};
|
|
109
|
+
function controls(prompt) {
|
|
110
|
+
if (prompt.kind === "permission") {
|
|
111
|
+
return [
|
|
112
|
+
{
|
|
113
|
+
type: "actions",
|
|
114
|
+
block_id: blockId(prompt.id),
|
|
115
|
+
elements: [
|
|
116
|
+
{ label: "Allow once", value: "once", style: "primary" },
|
|
117
|
+
{ label: "Always allow", value: "always" },
|
|
118
|
+
{ label: "Deny", value: "reject", style: "danger" }
|
|
119
|
+
].map((button) => ({
|
|
120
|
+
type: "button",
|
|
121
|
+
action_id: `gitterm_permission_${button.value}`,
|
|
122
|
+
text: plain(button.label),
|
|
123
|
+
value: button.value,
|
|
124
|
+
...button.style ? { style: button.style } : {}
|
|
125
|
+
}))
|
|
126
|
+
},
|
|
127
|
+
context("Or reply *yes*, *always*, or *no* in this thread.")
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
const { question } = prompt;
|
|
131
|
+
const rows = [];
|
|
132
|
+
if (question.multiple && question.options.length > 0) {
|
|
133
|
+
rows.push({
|
|
134
|
+
type: "actions",
|
|
135
|
+
block_id: blockId(prompt.id),
|
|
136
|
+
elements: [
|
|
137
|
+
{
|
|
138
|
+
type: "checkboxes",
|
|
139
|
+
action_id: ACTION.toggle,
|
|
140
|
+
options: question.options.slice(0, CHECKBOX_LIMIT).map((option, i) => ({
|
|
141
|
+
text: plain(option.label),
|
|
142
|
+
...option.description ? { description: plain(option.description) } : {},
|
|
143
|
+
value: String(i)
|
|
144
|
+
}))
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: "button",
|
|
148
|
+
action_id: ACTION.submit,
|
|
149
|
+
style: "primary",
|
|
150
|
+
text: plain("Submit"),
|
|
151
|
+
value: "submit"
|
|
152
|
+
},
|
|
153
|
+
...question.custom ? [customButton] : []
|
|
154
|
+
]
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
for (let start = 0;start < question.options.length; start += BUTTONS_PER_ROW) {
|
|
158
|
+
rows.push({
|
|
159
|
+
type: "actions",
|
|
160
|
+
block_id: blockId(prompt.id, rows.length),
|
|
161
|
+
elements: question.options.slice(start, start + BUTTONS_PER_ROW).map((option, i) => ({
|
|
162
|
+
type: "button",
|
|
163
|
+
action_id: `gitterm_option_${start + i}`,
|
|
164
|
+
text: plain(option.label),
|
|
165
|
+
value: String(start + i)
|
|
166
|
+
}))
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (question.custom) {
|
|
170
|
+
rows.push({
|
|
171
|
+
type: "actions",
|
|
172
|
+
block_id: blockId(prompt.id, rows.length),
|
|
173
|
+
elements: [customButton]
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const hint = question.options.length > 0 ? question.multiple ? "Tick options and press *Submit*, or reply with their numbers (e.g. `1, 3`)." : "Press a button or reply with its number." : "Reply in this thread with your answer.";
|
|
178
|
+
return [...rows, context(hint)];
|
|
179
|
+
}
|
|
180
|
+
function promptAttachment(prompt) {
|
|
181
|
+
const fallback = prompt.kind === "permission" ? `<@${prompt.requester.id}> the agent needs approval: ${prompt.request.title}` : `<@${prompt.requester.id}> the agent has a question: ${prompt.question.header}`;
|
|
182
|
+
return {
|
|
183
|
+
color: OPEN_COLOR,
|
|
184
|
+
fallback,
|
|
185
|
+
blocks: [leadIn(prompt, true), ...body(prompt), ...controls(prompt)]
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function settledAttachment(prompt, outcome) {
|
|
189
|
+
return {
|
|
190
|
+
color: SETTLED_COLOR,
|
|
191
|
+
fallback: outcome,
|
|
192
|
+
blocks: [leadIn(prompt, false), ...body(prompt), context(outcome)]
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function customAnswerView(prompt) {
|
|
196
|
+
return {
|
|
197
|
+
type: "modal",
|
|
198
|
+
callback_id: CUSTOM_MODAL,
|
|
199
|
+
private_metadata: prompt.id,
|
|
200
|
+
title: plain(prompt.question.header || "Your answer", 24),
|
|
201
|
+
submit: plain("Send to the agent"),
|
|
202
|
+
close: plain("Cancel"),
|
|
203
|
+
blocks: [
|
|
204
|
+
section(prompt.question.question),
|
|
205
|
+
{
|
|
206
|
+
type: "input",
|
|
207
|
+
block_id: "answer",
|
|
208
|
+
label: plain("Your answer"),
|
|
209
|
+
element: { type: "plain_text_input", action_id: CUSTOM_INPUT, multiline: true }
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/adapter.ts
|
|
216
|
+
var HISTORY_LIMIT = 200;
|
|
217
|
+
function required(value, name) {
|
|
218
|
+
if (!value?.trim())
|
|
219
|
+
throw new Error(`${name} is required`);
|
|
220
|
+
return value.trim();
|
|
221
|
+
}
|
|
222
|
+
function createSlackAdapter(options = {}) {
|
|
223
|
+
const botToken = required(options.botToken ?? options.app?.client.token ?? process.env.SLACK_BOT_TOKEN, "SLACK_BOT_TOKEN");
|
|
224
|
+
const owned = !options.app;
|
|
225
|
+
const app = options.app ?? new App({
|
|
226
|
+
token: botToken,
|
|
227
|
+
appToken: required(options.appToken ?? process.env.SLACK_APP_TOKEN, "SLACK_APP_TOKEN"),
|
|
228
|
+
socketMode: true,
|
|
229
|
+
logLevel: LogLevel.WARN
|
|
230
|
+
});
|
|
231
|
+
const client = owned ? app.client : new webApi.WebClient(botToken);
|
|
232
|
+
const users = new Map;
|
|
233
|
+
const ticked = new Map;
|
|
234
|
+
let indicatorAvailable = true;
|
|
235
|
+
let teamId = "";
|
|
236
|
+
let botUserId = "";
|
|
237
|
+
const stripMention = (text) => text.replaceAll(`<@${botUserId}>`, "").trim();
|
|
238
|
+
const user = async (id) => {
|
|
239
|
+
if (!id)
|
|
240
|
+
return { id: "unknown", name: "someone", guest: true };
|
|
241
|
+
const cached = users.get(id);
|
|
242
|
+
if (cached && Date.now() - cached.at < 60000)
|
|
243
|
+
return cached.user;
|
|
244
|
+
const known = await client.users.info({ user: id }).then(({ user: profile }) => ({
|
|
245
|
+
id,
|
|
246
|
+
name: profile?.profile?.display_name || profile?.real_name || profile?.name || id,
|
|
247
|
+
guest: Boolean(!profile || profile.deleted || profile?.is_restricted || profile?.is_ultra_restricted || profile?.team_id && teamId && profile.team_id !== teamId)
|
|
248
|
+
})).catch(() => ({ id, name: id, guest: true }));
|
|
249
|
+
users.delete(id);
|
|
250
|
+
users.set(id, { user: known, at: Date.now() });
|
|
251
|
+
if (users.size > 2000)
|
|
252
|
+
users.delete(users.keys().next().value);
|
|
253
|
+
return known;
|
|
254
|
+
};
|
|
255
|
+
const file = (entry) => ({
|
|
256
|
+
id: entry.id,
|
|
257
|
+
name: entry.name ?? entry.id,
|
|
258
|
+
mime: entry.mimetype ?? "application/octet-stream",
|
|
259
|
+
size: entry.size ?? -1,
|
|
260
|
+
async download(signal) {
|
|
261
|
+
return downloadImage(entry.url_private_download ?? "", {
|
|
262
|
+
hosts: ["files.slack.com", "files-pri.slack.com"],
|
|
263
|
+
signal,
|
|
264
|
+
headers: { Authorization: `Bearer ${botToken}` }
|
|
265
|
+
}).catch((error) => {
|
|
266
|
+
throw new Error(`Could not download “${entry.name ?? entry.id}” from Slack; the app needs the files:read scope (${error instanceof Error ? error.message : error})`);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
const fromBot = (message) => Boolean(message.bot_id) || message.subtype === "bot_message" || message.user === botUserId;
|
|
271
|
+
return {
|
|
272
|
+
platform: "slack",
|
|
273
|
+
displayName: "Slack",
|
|
274
|
+
async start(events) {
|
|
275
|
+
const sameTeam = (id) => !id || id === teamId;
|
|
276
|
+
app.event("app_mention", async ({ event, context }) => {
|
|
277
|
+
if (!sameTeam(context.teamId) || event.bot_id || !event.user)
|
|
278
|
+
return;
|
|
279
|
+
if (event.channel.startsWith("D"))
|
|
280
|
+
return;
|
|
281
|
+
events.message({
|
|
282
|
+
id: event.ts,
|
|
283
|
+
thread: { channel: event.channel, thread: event.thread_ts ?? event.ts },
|
|
284
|
+
author: await user(event.user),
|
|
285
|
+
text: stripMention(event.text),
|
|
286
|
+
files: (event.files ?? []).map(file),
|
|
287
|
+
mentioned: true,
|
|
288
|
+
inThread: Boolean(event.thread_ts)
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
app.message(async ({ message, context }) => {
|
|
292
|
+
const reply = message;
|
|
293
|
+
if (!sameTeam(context.teamId) || fromBot(reply) || !reply.user || !reply.ts)
|
|
294
|
+
return;
|
|
295
|
+
if (reply.subtype && reply.subtype !== "file_share")
|
|
296
|
+
return;
|
|
297
|
+
if (reply.channel_type === "im") {
|
|
298
|
+
events.message({
|
|
299
|
+
id: reply.ts,
|
|
300
|
+
thread: { channel: reply.channel, thread: reply.thread_ts ?? reply.ts },
|
|
301
|
+
author: await user(reply.user),
|
|
302
|
+
text: stripMention(reply.text ?? ""),
|
|
303
|
+
files: (reply.files ?? []).map(file),
|
|
304
|
+
mentioned: true,
|
|
305
|
+
inThread: Boolean(reply.thread_ts),
|
|
306
|
+
direct: true
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!reply.thread_ts || reply.thread_ts === reply.ts)
|
|
311
|
+
return;
|
|
312
|
+
if (reply.text?.includes(`<@${botUserId}>`))
|
|
313
|
+
return;
|
|
314
|
+
events.message({
|
|
315
|
+
id: reply.ts,
|
|
316
|
+
thread: { channel: reply.channel, thread: reply.thread_ts },
|
|
317
|
+
author: await user(reply.user),
|
|
318
|
+
text: reply.text?.trim() ?? "",
|
|
319
|
+
files: (reply.files ?? []).map(file),
|
|
320
|
+
mentioned: false,
|
|
321
|
+
inThread: true
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
const answer = async (body, promptId, value) => promptId === null ? "stale" : events.answer(promptId, value, await user(body.user.id));
|
|
325
|
+
const notice = (text) => ({
|
|
326
|
+
response_type: "ephemeral",
|
|
327
|
+
replace_original: false,
|
|
328
|
+
text
|
|
329
|
+
});
|
|
330
|
+
const stale = notice("This was already answered.");
|
|
331
|
+
const outcomeNotice = (outcome) => outcome === "forbidden" ? notice("Only people allowed to use this bot can answer.") : outcome === "stale" ? stale : undefined;
|
|
332
|
+
app.action(ACTION.option, async ({ ack, body, action, respond }) => {
|
|
333
|
+
await ack();
|
|
334
|
+
const promptId = promptIdOf(action.block_id);
|
|
335
|
+
const prompt = promptId ? events.prompt(promptId) : undefined;
|
|
336
|
+
const label = prompt?.kind === "question" ? prompt.question.options[Number(action.value)]?.label : undefined;
|
|
337
|
+
const reply = label ? outcomeNotice(await answer(body, promptId, { kind: "question", labels: [label] })) : stale;
|
|
338
|
+
if (reply)
|
|
339
|
+
await respond(reply);
|
|
340
|
+
});
|
|
341
|
+
app.action(ACTION.toggle, async ({ ack, action, body }) => {
|
|
342
|
+
await ack();
|
|
343
|
+
const promptId = promptIdOf(action.block_id);
|
|
344
|
+
if (promptId && events.canAnswer && !events.canAnswer(promptId, await user(body.user.id)))
|
|
345
|
+
return;
|
|
346
|
+
if (promptId)
|
|
347
|
+
ticked.set(promptId, action.selected_options.map((option) => Number(option.value)));
|
|
348
|
+
});
|
|
349
|
+
app.action(ACTION.submit, async ({ ack, body, action, respond }) => {
|
|
350
|
+
await ack();
|
|
351
|
+
const promptId = promptIdOf(action.block_id);
|
|
352
|
+
const prompt = promptId ? events.prompt(promptId) : undefined;
|
|
353
|
+
if (prompt?.kind !== "question" || !promptId) {
|
|
354
|
+
await respond(stale);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const labels = (ticked.get(promptId) ?? []).map((index) => prompt.question.options[index]?.label).filter((label) => Boolean(label));
|
|
358
|
+
if (labels.length === 0) {
|
|
359
|
+
await respond({ ...stale, text: "Tick at least one option before pressing Submit." });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const reply = outcomeNotice(await answer(body, promptId, { kind: "question", labels }));
|
|
363
|
+
if (reply)
|
|
364
|
+
await respond(reply);
|
|
365
|
+
});
|
|
366
|
+
app.action(ACTION.permission, async ({ ack, body, action, respond }) => {
|
|
367
|
+
await ack();
|
|
368
|
+
const response = action.value;
|
|
369
|
+
const reply = outcomeNotice(await answer(body, promptIdOf(action.block_id), { kind: "permission", response }));
|
|
370
|
+
if (reply)
|
|
371
|
+
await respond(reply);
|
|
372
|
+
});
|
|
373
|
+
app.action(ACTION.custom, async ({ ack, body, action, client: web, respond }) => {
|
|
374
|
+
await ack();
|
|
375
|
+
const promptId = promptIdOf(action.block_id);
|
|
376
|
+
const prompt = promptId ? events.prompt(promptId) : undefined;
|
|
377
|
+
if (prompt?.kind !== "question") {
|
|
378
|
+
await respond(stale);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
await web.views.open({ trigger_id: body.trigger_id, view: customAnswerView(prompt) });
|
|
382
|
+
});
|
|
383
|
+
app.view(CUSTOM_MODAL, async ({ ack, body, view }) => {
|
|
384
|
+
const promptId = view.private_metadata;
|
|
385
|
+
const prompt = events.prompt(promptId);
|
|
386
|
+
const text = view.state.values.answer?.[CUSTOM_INPUT]?.value?.trim() ?? "";
|
|
387
|
+
const labels = prompt?.kind === "question" && prompt.question.multiple ? (ticked.get(promptId) ?? []).map((index) => prompt.question.options[index]?.label ?? "") : [];
|
|
388
|
+
const outcome = text ? events.answer(promptId, { kind: "question", labels: [...new Set([...labels.filter(Boolean), text])] }, await user(body.user.id)) : "empty";
|
|
389
|
+
if (outcome === "answered")
|
|
390
|
+
return ack();
|
|
391
|
+
await ack({
|
|
392
|
+
response_action: "errors",
|
|
393
|
+
errors: {
|
|
394
|
+
answer: outcome === "empty" ? "Write an answer first." : outcome === "forbidden" ? "Only people allowed to use this bot can answer." : "This question was already answered."
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
if (owned) {
|
|
399
|
+
app.error(async (error) => {
|
|
400
|
+
console.error("Slack error", error);
|
|
401
|
+
});
|
|
402
|
+
await app.start();
|
|
403
|
+
}
|
|
404
|
+
const identity = await client.auth.test();
|
|
405
|
+
teamId = identity.team_id ?? "";
|
|
406
|
+
botUserId = identity.user_id ?? "";
|
|
407
|
+
return { scope: teamId };
|
|
408
|
+
},
|
|
409
|
+
async stop() {
|
|
410
|
+
if (owned)
|
|
411
|
+
await app.stop();
|
|
412
|
+
},
|
|
413
|
+
async history(thread, after) {
|
|
414
|
+
const messages = [];
|
|
415
|
+
let cursor;
|
|
416
|
+
for (let page = 0;page < 5; page++) {
|
|
417
|
+
const replies = await client.conversations.replies({
|
|
418
|
+
channel: thread.channel,
|
|
419
|
+
ts: thread.thread,
|
|
420
|
+
limit: HISTORY_LIMIT,
|
|
421
|
+
...after ? { oldest: after, inclusive: false } : {},
|
|
422
|
+
...cursor ? { cursor } : {}
|
|
423
|
+
});
|
|
424
|
+
messages.push(...replies.messages ?? []);
|
|
425
|
+
cursor = replies.response_metadata?.next_cursor?.trim();
|
|
426
|
+
if (!cursor)
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
const result = [];
|
|
430
|
+
for (const message of messages) {
|
|
431
|
+
if (!message.ts || after && Number(message.ts) <= Number(after))
|
|
432
|
+
continue;
|
|
433
|
+
const bot = fromBot(message);
|
|
434
|
+
result.push({
|
|
435
|
+
id: message.ts,
|
|
436
|
+
author: bot ? { id: message.user ?? "bot", name: "assistant" } : await user(message.user),
|
|
437
|
+
fromBot: bot,
|
|
438
|
+
text: stripMention(message.text ?? ""),
|
|
439
|
+
files: (message.files ?? []).map(file)
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
},
|
|
444
|
+
async post(thread, text) {
|
|
445
|
+
const posted = await client.chat.postMessage({
|
|
446
|
+
channel: thread.channel,
|
|
447
|
+
thread_ts: thread.thread,
|
|
448
|
+
text
|
|
449
|
+
});
|
|
450
|
+
if (!posted.ts)
|
|
451
|
+
throw new Error("Slack did not return a message timestamp");
|
|
452
|
+
return posted.ts;
|
|
453
|
+
},
|
|
454
|
+
async edit(thread, messageId, text) {
|
|
455
|
+
await client.chat.update({ channel: thread.channel, ts: messageId, text });
|
|
456
|
+
},
|
|
457
|
+
async remove(thread, messageId) {
|
|
458
|
+
await client.chat.delete({ channel: thread.channel, ts: messageId });
|
|
459
|
+
},
|
|
460
|
+
async reply(thread, markdown, footer, delivery) {
|
|
461
|
+
const messages = answerMessages(markdown, footer);
|
|
462
|
+
for (let index = delivery?.sent ?? 0;index < messages.length; index++) {
|
|
463
|
+
await client.chat.postMessage({
|
|
464
|
+
channel: thread.channel,
|
|
465
|
+
thread_ts: thread.thread,
|
|
466
|
+
...messages[index]
|
|
467
|
+
});
|
|
468
|
+
await delivery?.recordSent(index + 1);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
async ask(thread, prompt) {
|
|
472
|
+
const posted = await client.chat.postMessage({
|
|
473
|
+
channel: thread.channel,
|
|
474
|
+
thread_ts: thread.thread,
|
|
475
|
+
attachments: [promptAttachment(prompt)]
|
|
476
|
+
});
|
|
477
|
+
if (!posted.ts)
|
|
478
|
+
throw new Error("Slack did not return a message timestamp");
|
|
479
|
+
return posted.ts;
|
|
480
|
+
},
|
|
481
|
+
async settle(thread, messageId, prompt, outcome) {
|
|
482
|
+
ticked.delete(prompt.id);
|
|
483
|
+
await client.chat.update({
|
|
484
|
+
channel: thread.channel,
|
|
485
|
+
ts: messageId,
|
|
486
|
+
text: "",
|
|
487
|
+
attachments: [settledAttachment(prompt, outcome)]
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
async indicate(thread, status) {
|
|
491
|
+
if (!indicatorAvailable)
|
|
492
|
+
return false;
|
|
493
|
+
try {
|
|
494
|
+
await client.assistant.threads.setStatus({
|
|
495
|
+
channel_id: thread.channel,
|
|
496
|
+
thread_ts: thread.thread,
|
|
497
|
+
status
|
|
498
|
+
});
|
|
499
|
+
return true;
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (error.code === "slack_webapi_platform_error") {
|
|
502
|
+
indicatorAvailable = false;
|
|
503
|
+
console.warn("Slack refused the native status indicator; using status messages instead", error);
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
async mark(message, state) {
|
|
509
|
+
await client.reactions.add({
|
|
510
|
+
channel: message.thread.channel,
|
|
511
|
+
timestamp: message.id,
|
|
512
|
+
name: { seen: "eyes", done: "white_check_mark", failed: "x" }[state]
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// src/manifest.ts
|
|
518
|
+
function slackManifest(name = "GitTerm Agent") {
|
|
519
|
+
return {
|
|
520
|
+
display_information: {
|
|
521
|
+
name,
|
|
522
|
+
description: "A coding agent for your repository, running in a GitTerm sandbox"
|
|
523
|
+
},
|
|
524
|
+
features: {
|
|
525
|
+
bot_user: { display_name: name, always_online: true },
|
|
526
|
+
app_home: {
|
|
527
|
+
home_tab_enabled: false,
|
|
528
|
+
messages_tab_enabled: true,
|
|
529
|
+
messages_tab_read_only_enabled: false
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
oauth_config: {
|
|
533
|
+
scopes: {
|
|
534
|
+
bot: [
|
|
535
|
+
"app_mentions:read",
|
|
536
|
+
"channels:history",
|
|
537
|
+
"groups:history",
|
|
538
|
+
"im:history",
|
|
539
|
+
"chat:write",
|
|
540
|
+
"files:read",
|
|
541
|
+
"reactions:write",
|
|
542
|
+
"users:read"
|
|
543
|
+
]
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
settings: {
|
|
547
|
+
event_subscriptions: {
|
|
548
|
+
bot_events: ["app_mention", "message.channels", "message.groups", "message.im"]
|
|
549
|
+
},
|
|
550
|
+
interactivity: { is_enabled: true },
|
|
551
|
+
org_deploy_enabled: false,
|
|
552
|
+
socket_mode_enabled: true,
|
|
553
|
+
token_rotation_enabled: false
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/index.ts
|
|
559
|
+
function createSlackBot(options) {
|
|
560
|
+
const { botToken, appToken, app, ...bot } = options;
|
|
561
|
+
return createBot({
|
|
562
|
+
...bot,
|
|
563
|
+
adapter: createSlackAdapter({
|
|
564
|
+
...botToken ? { botToken } : {},
|
|
565
|
+
...appToken ? { appToken } : {},
|
|
566
|
+
...app ? { app } : {}
|
|
567
|
+
})
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async function withSavedConfig(options = {}) {
|
|
571
|
+
return { ...options, ...await loadSaved(options, "slack", options.logger) };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/cli.ts
|
|
575
|
+
var USAGE = `Usage: gitterm-slack-bot [-h] [--repo <url>[#branch]] [--model <provider/model>]
|
|
576
|
+
[--instructions-file <path>]
|
|
577
|
+
gitterm-slack-bot manifest [name]
|
|
578
|
+
|
|
579
|
+
Environment (a .env file in the working directory is loaded):
|
|
580
|
+
SLACK_BOT_TOKEN Bot token (xoxb-…)
|
|
581
|
+
SLACK_APP_TOKEN App-level token with connections:write (xapp-…)
|
|
582
|
+
${BOT_ENV_HELP}`;
|
|
583
|
+
var { command, options: local } = cliOptions();
|
|
584
|
+
if (command === "manifest") {
|
|
585
|
+
console.log(JSON.stringify(slackManifest(process.argv[3]), null, 2));
|
|
586
|
+
} else if (command === "help" || !hasRepository(local) && !process.env.GITTERM_API_TOKEN) {
|
|
587
|
+
console.log(USAGE);
|
|
588
|
+
process.exitCode = command === "help" ? 0 : 1;
|
|
589
|
+
} else {
|
|
590
|
+
const options = await withSavedConfig(local);
|
|
591
|
+
if (!hasRepository(options)) {
|
|
592
|
+
console.log(USAGE);
|
|
593
|
+
console.error(`
|
|
594
|
+
No repository: set GITTERM_BOT_REPO, or save this bot under Bots in GitTerm.`);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
const bot = createSlackBot(options);
|
|
598
|
+
await bot.start();
|
|
599
|
+
const shutdown = () => void bot.stop().finally(() => process.exit(0));
|
|
600
|
+
process.once("SIGINT", shutdown);
|
|
601
|
+
process.once("SIGTERM", shutdown);
|
|
602
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Bot, type BotOptions } from "@gitterm/bot";
|
|
2
|
+
import { type SlackAdapterOptions } from "./adapter.js";
|
|
3
|
+
export { createSlackAdapter, type SlackAdapterOptions } from "./adapter.js";
|
|
4
|
+
export { slackManifest } from "./manifest.js";
|
|
5
|
+
export type { Bot, RepoTarget, WorkspaceOverrides } from "@gitterm/bot";
|
|
6
|
+
export type SlackBotOptions = Omit<BotOptions, "adapter"> & SlackAdapterOptions;
|
|
7
|
+
/**
|
|
8
|
+
* A Slack bot that runs a coding agent on your repository in a GitTerm sandbox.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* await createSlackBot({ repo: "https://github.com/acme/app" }).start();
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Tokens default to `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `GITTERM_API_TOKEN`; pass `app` to
|
|
15
|
+
* add the agent to a Bolt app you already run.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createSlackBot(options: SlackBotOptions): Bot;
|
|
18
|
+
/**
|
|
19
|
+
* Adds the settings saved for this bot under Bots in the GitTerm dashboard, found by its API
|
|
20
|
+
* token. Anything you pass wins: `createSlackBot(await withSavedConfig({ ... }))`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withSavedConfig(options?: SlackBotOptions): Promise<SlackBotOptions>;
|