allaboard 0.0.0-stage → 0.5.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 +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files the owner attaches to a message: checked, downloaded, and handed to Claude.
|
|
3
|
+
*
|
|
4
|
+
* A message with files arrives as `subtype: file_share` with a `files` array (measured 2026-09-25,
|
|
5
|
+
* although Slack's reference page for the `file_share` message subtype, read 2026-09-25, calls it
|
|
6
|
+
* legacy and no longer served). An image Claude can read goes into the prompt as an image part, as
|
|
7
|
+
* an image pasted into the terminal does (Agent SDK "Streaming Input", read 2026-09-25); any other
|
|
8
|
+
* file is saved to a private folder and its path joins the prompt, as a file dropped into the
|
|
9
|
+
* terminal does. A file past a limit, or one that fails to download, stops the whole message: a
|
|
10
|
+
* prompt without its attachment would mislead Claude.
|
|
11
|
+
*/
|
|
12
|
+
import { lstatSync } from "node:fs";
|
|
13
|
+
import { lstat, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { named, texts } from "../../core/port.js";
|
|
16
|
+
import { getLogger } from "../../log.js";
|
|
17
|
+
import { errorName } from "../../values.js";
|
|
18
|
+
import { take } from "./reply/chars.js";
|
|
19
|
+
import { mrkdwnEscape } from "./reply/escape.js";
|
|
20
|
+
import * as slackTexts from "./texts.js";
|
|
21
|
+
// Claude's vision limits (platform.claude.com, "Vision", read 2026-09-25): JPEG, PNG, GIF and
|
|
22
|
+
// WebP, 10 MB base64-encoded per image through the API, 8000x8000 px.
|
|
23
|
+
export const IMAGE_TYPES = new Set([
|
|
24
|
+
"image/jpeg",
|
|
25
|
+
"image/png",
|
|
26
|
+
"image/gif",
|
|
27
|
+
"image/webp",
|
|
28
|
+
]);
|
|
29
|
+
export const IMAGE_LIMIT = 7_500_000; // bytes, whose base64 form is 10,000,000 characters
|
|
30
|
+
export const IMAGE_SIDE = 8000;
|
|
31
|
+
// Per message (the maintainer, 2026-09-25): images stay in the conversation and are sent again
|
|
32
|
+
// at every turn, and a request is capped at 32 MB (Vision, read 2026-09-25).
|
|
33
|
+
export const IMAGES_PER_MESSAGE = 5;
|
|
34
|
+
export const IMAGES_LIMIT = 15 * 1024 * 1024;
|
|
35
|
+
export const FILE_LIMIT = 100 * 1024 * 1024;
|
|
36
|
+
// The files that are not images Claude receives (the maintainer, 2026-09-25: the most common
|
|
37
|
+
// ones, those its Read tool opens): any text/* type, which covers source code, plus these.
|
|
38
|
+
export const FILE_TYPES = new Set([
|
|
39
|
+
"application/pdf",
|
|
40
|
+
"application/json",
|
|
41
|
+
"application/xml",
|
|
42
|
+
"application/yaml",
|
|
43
|
+
"application/x-yaml",
|
|
44
|
+
"application/javascript",
|
|
45
|
+
"application/x-sh",
|
|
46
|
+
"application/sql",
|
|
47
|
+
"application/toml",
|
|
48
|
+
"application/x-ipynb+json",
|
|
49
|
+
]);
|
|
50
|
+
// Saved files outlive a restart, since the resumed conversation names them (the maintainer,
|
|
51
|
+
// 2026-09-25).
|
|
52
|
+
export const KEEP_SECONDS = 3 * 24 * 3600;
|
|
53
|
+
// A file's URL receives the bot token: only Slack's own file host may have it.
|
|
54
|
+
export const FILE_HOST = "files.slack.com";
|
|
55
|
+
const ORIGIN_PROTOCOL = "https:";
|
|
56
|
+
export const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
57
|
+
export const logger = getLogger("allaboard.chat.slack.attachments");
|
|
58
|
+
/** A file could not be downloaded or saved; the message says why, for the owner. */
|
|
59
|
+
export class DownloadFailed extends Error {
|
|
60
|
+
constructor(message, options) {
|
|
61
|
+
super(message, options);
|
|
62
|
+
this.name = "DownloadFailed";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function field(file, key) {
|
|
66
|
+
return file[key];
|
|
67
|
+
}
|
|
68
|
+
/** A field that is a number or a numeric string, as a whole number; absent or false reads 0. */
|
|
69
|
+
function integer(value) {
|
|
70
|
+
const number = Number(value || 0);
|
|
71
|
+
return Number.isFinite(number) ? Math.trunc(number) : 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A declared size in bytes: a finite non-negative integer, as a number or as ASCII digits, and an
|
|
75
|
+
* absent or false one is 0. Null for anything else: `"999_999_999"`, non-ASCII digits, a sign or
|
|
76
|
+
* padding are not what Slack writes, and a file of that kind is refused.
|
|
77
|
+
*/
|
|
78
|
+
function declaredSize(value) {
|
|
79
|
+
if (value === undefined || value === null || value === false || value === 0 || value === "") {
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === "number")
|
|
83
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
84
|
+
if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
85
|
+
return null;
|
|
86
|
+
const size = Number(value);
|
|
87
|
+
return Number.isFinite(size) ? size : null;
|
|
88
|
+
}
|
|
89
|
+
/** Python's `f"{size / (1024 * 1024):.1f}MB"`: a tie rounds to even, which `toFixed` does not. */
|
|
90
|
+
function megabytes(size) {
|
|
91
|
+
const value = size / (1024 * 1024);
|
|
92
|
+
// `toFixed` switches to an exponent from 1e21; Python prints every digit of the double.
|
|
93
|
+
if (value >= 1e21)
|
|
94
|
+
return `${BigInt(value)}.0MB`;
|
|
95
|
+
// Only a quarter of a megabyte is both exact in binary and a tie at one decimal.
|
|
96
|
+
const quarters = value * 4;
|
|
97
|
+
if (Number.isInteger(quarters) && quarters % 2 !== 0) {
|
|
98
|
+
const down = Math.floor(value * 10);
|
|
99
|
+
const tenths = down % 2 === 0 ? down : down + 1;
|
|
100
|
+
return `${(tenths / 10).toFixed(1)}MB`;
|
|
101
|
+
}
|
|
102
|
+
return `${value.toFixed(1)}MB`;
|
|
103
|
+
}
|
|
104
|
+
/** The parts of an `https://files.slack.com/...` URL; null for any other host or scheme. */
|
|
105
|
+
function slackFileUrl(raw) {
|
|
106
|
+
let url;
|
|
107
|
+
try {
|
|
108
|
+
url = new URL(raw);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return url.protocol === ORIGIN_PROTOCOL && url.hostname === FILE_HOST ? url : null;
|
|
114
|
+
}
|
|
115
|
+
/** Why `file` cannot reach Claude, as the end of `UPLOAD_FAILED`; null when it can. */
|
|
116
|
+
export function refusal(file, names) {
|
|
117
|
+
const access = field(file, "file_access");
|
|
118
|
+
if ((access === undefined ? "visible" : access) !== "visible")
|
|
119
|
+
return slackTexts.UPLOAD_NOT_SHARED;
|
|
120
|
+
if (slackFileUrl(String(field(file, "url_private_download") || "")) === null) {
|
|
121
|
+
return slackTexts.UPLOAD_NOT_SHARED;
|
|
122
|
+
}
|
|
123
|
+
const mimetype = String(field(file, "mimetype") || "");
|
|
124
|
+
const size = declaredSize(field(file, "size"));
|
|
125
|
+
if (size === null)
|
|
126
|
+
return slackTexts.UPLOAD_NOT_SHARED;
|
|
127
|
+
if (mimetype.startsWith("image/")) {
|
|
128
|
+
if (!IMAGE_TYPES.has(mimetype))
|
|
129
|
+
return texts.fill(named(slackTexts.UPLOAD_IMAGE_TYPE, names), { mimetype });
|
|
130
|
+
if (size > IMAGE_LIMIT) {
|
|
131
|
+
return texts.fill(named(slackTexts.UPLOAD_IMAGE_SIZE, names), {
|
|
132
|
+
size: megabytes(size),
|
|
133
|
+
limit: megabytes(IMAGE_LIMIT),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const width = field(file, "original_w");
|
|
137
|
+
const height = field(file, "original_h");
|
|
138
|
+
if (integer(width) > IMAGE_SIDE || integer(height) > IMAGE_SIDE) {
|
|
139
|
+
return texts.fill(named(slackTexts.UPLOAD_IMAGE_SIDE, names), {
|
|
140
|
+
width: String(width ?? "None"),
|
|
141
|
+
height: String(height ?? "None"),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (!(mimetype.startsWith("text/") || FILE_TYPES.has(mimetype))) {
|
|
146
|
+
return texts.fill(slackTexts.UPLOAD_FILE_TYPE, { mimetype: mimetype || "unknown" });
|
|
147
|
+
}
|
|
148
|
+
else if (size > FILE_LIMIT) {
|
|
149
|
+
return texts.fill(slackTexts.UPLOAD_FILE_SIZE, {
|
|
150
|
+
size: megabytes(size),
|
|
151
|
+
limit: megabytes(FILE_LIMIT),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** Why the images of one message, together, cannot reach Claude; null when they can. */
|
|
157
|
+
export function imagesRefusal(files, names) {
|
|
158
|
+
const images = files.filter(isImage);
|
|
159
|
+
if (images.length > IMAGES_PER_MESSAGE) {
|
|
160
|
+
return texts.fill(named(slackTexts.UPLOAD_TOO_MANY, names), {
|
|
161
|
+
count: images.length,
|
|
162
|
+
limit: IMAGES_PER_MESSAGE,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
let total = 0;
|
|
166
|
+
for (const file of images) {
|
|
167
|
+
const size = declaredSize(field(file, "size"));
|
|
168
|
+
// `refusal` speaks for one file; the images together are told as a whole sentence.
|
|
169
|
+
if (size === null) {
|
|
170
|
+
const name = field(file, "name") || field(file, "id");
|
|
171
|
+
return texts.fill(named(slackTexts.UPLOAD_FAILED, names), {
|
|
172
|
+
name: mrkdwnEscape(String(name ?? "None")),
|
|
173
|
+
reason: slackTexts.UPLOAD_NOT_SHARED,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
total += size;
|
|
177
|
+
}
|
|
178
|
+
if (total > IMAGES_LIMIT) {
|
|
179
|
+
return texts.fill(named(slackTexts.UPLOAD_TOO_HEAVY, names), {
|
|
180
|
+
size: megabytes(total),
|
|
181
|
+
limit: megabytes(IMAGES_LIMIT),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/** Whether Claude reads this file as an image. */
|
|
187
|
+
export function isImage(file) {
|
|
188
|
+
const mimetype = field(file, "mimetype");
|
|
189
|
+
return typeof mimetype === "string" && IMAGE_TYPES.has(mimetype);
|
|
190
|
+
}
|
|
191
|
+
/** The most bytes `download` takes of this file. */
|
|
192
|
+
export function limitFor(file) {
|
|
193
|
+
return isImage(file) ? IMAGE_LIMIT : FILE_LIMIT;
|
|
194
|
+
}
|
|
195
|
+
/** `Name: message (cause)`, at most 200 characters, so the owner sees why and not a class alone. */
|
|
196
|
+
function failureDetail(error) {
|
|
197
|
+
if (!(error instanceof Error))
|
|
198
|
+
return take(String(error), 200);
|
|
199
|
+
// A timeout carries a sentence of the runtime's own; the name says it.
|
|
200
|
+
if (error.name === "TimeoutError")
|
|
201
|
+
return "TimeoutError";
|
|
202
|
+
let detail = error.message ? `${error.name}: ${error.message}` : error.name;
|
|
203
|
+
if (error.cause instanceof Error && error.cause.message)
|
|
204
|
+
detail += ` (${error.cause.message})`;
|
|
205
|
+
return take(detail, 200);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The file at `url`, fetched with the bot token (Slack's file object reference, read 2026-09-25);
|
|
209
|
+
* throws `DownloadFailed` past `limit` bytes or on any failure.
|
|
210
|
+
*/
|
|
211
|
+
export async function download(url, token, mimetype, limit, options = {}) {
|
|
212
|
+
const target = slackFileUrl(url);
|
|
213
|
+
if (target === null) {
|
|
214
|
+
// The token goes to Slack's file host alone, whoever calls this.
|
|
215
|
+
throw new DownloadFailed(`not a ${FILE_HOST} URL`);
|
|
216
|
+
}
|
|
217
|
+
const call = options.fetch ?? fetch;
|
|
218
|
+
const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
|
|
219
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
220
|
+
try {
|
|
221
|
+
const response = await call(target.href, {
|
|
222
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
223
|
+
// A redirect could carry the token to another host: none is followed.
|
|
224
|
+
redirect: "manual",
|
|
225
|
+
signal,
|
|
226
|
+
});
|
|
227
|
+
if (response.status === 302) {
|
|
228
|
+
// Measured 2026-09-25: Slack's answer when the token lacks the scope.
|
|
229
|
+
throw new DownloadFailed("HTTP 302: Slack redirects instead of sending the file when the app lacks the " +
|
|
230
|
+
"`files:read` scope");
|
|
231
|
+
}
|
|
232
|
+
if (response.status !== 200)
|
|
233
|
+
throw new DownloadFailed(`HTTP ${response.status}`);
|
|
234
|
+
const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim();
|
|
235
|
+
if (contentType?.toLowerCase() === "text/html" && mimetype !== "text/html") {
|
|
236
|
+
throw new DownloadFailed("Slack sent a web page instead of the file: the app may lack the `files:read` scope");
|
|
237
|
+
}
|
|
238
|
+
return await readBody(response, limit);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error instanceof DownloadFailed || options.signal?.aborted)
|
|
242
|
+
throw error;
|
|
243
|
+
throw new DownloadFailed(failureDetail(error), { cause: error });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** The body of `response`, cut off with a `DownloadFailed` as soon as it passes `limit`. */
|
|
247
|
+
async function readBody(response, limit) {
|
|
248
|
+
if (response.body === null)
|
|
249
|
+
return new Uint8Array(0);
|
|
250
|
+
const reader = response.body.getReader();
|
|
251
|
+
const chunks = [];
|
|
252
|
+
let length = 0;
|
|
253
|
+
for (;;) {
|
|
254
|
+
const { done, value } = await reader.read();
|
|
255
|
+
if (done)
|
|
256
|
+
break;
|
|
257
|
+
chunks.push(value);
|
|
258
|
+
length += value.length;
|
|
259
|
+
if (length > limit) {
|
|
260
|
+
await reader.cancel().catch(() => undefined);
|
|
261
|
+
throw new DownloadFailed(`larger than ${megabytes(limit)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return Buffer.concat(chunks);
|
|
265
|
+
}
|
|
266
|
+
/** `.name` of a Python `PurePath`: the last part, with `.` parts and empty parts dropped. */
|
|
267
|
+
function lastPart(name) {
|
|
268
|
+
// Python's Path splits on `\` only on Windows, and so does this.
|
|
269
|
+
const parts = name.split(process.platform === "win32" ? /[\\/]/ : "/");
|
|
270
|
+
return parts.filter((part) => part !== "" && part !== ".").at(-1) ?? "";
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The file's name in the uploads folder: its Slack id first, so names never collide, and only the
|
|
274
|
+
* last part of the name, so it cannot point elsewhere.
|
|
275
|
+
*/
|
|
276
|
+
export function savedName(file) {
|
|
277
|
+
const id = field(file, "id");
|
|
278
|
+
if (id === undefined || id === null)
|
|
279
|
+
throw new TypeError("a Slack file has an id");
|
|
280
|
+
const name = lastPart(String(field(file, "name") || ""));
|
|
281
|
+
return name !== "" && name !== "." && name !== ".." ? `${id}-${name}` : String(id);
|
|
282
|
+
}
|
|
283
|
+
/** The folder is a real directory of this user that no one else can open. */
|
|
284
|
+
function isPrivate(folder) {
|
|
285
|
+
let info;
|
|
286
|
+
try {
|
|
287
|
+
info = lstatSync(folder);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
if (!info.isDirectory())
|
|
293
|
+
return false;
|
|
294
|
+
// Windows has no owner id and no mode bits to read.
|
|
295
|
+
if (process.platform === "win32")
|
|
296
|
+
return true;
|
|
297
|
+
return info.uid === process.getuid?.() && (info.mode & 0o077) === 0;
|
|
298
|
+
}
|
|
299
|
+
/** Remove the saved files older than `KEEP_SECONDS`, from a folder only the owner can read. */
|
|
300
|
+
export async function prepareUploads(folder, now = Date.now) {
|
|
301
|
+
await mkdir(folder, { mode: 0o700, recursive: true });
|
|
302
|
+
if (!isPrivate(folder)) {
|
|
303
|
+
// Never touched: `save` refuses to write there, and old files are not removed.
|
|
304
|
+
logger.warning(`the uploads folder ${folder} is not private: it is left untouched`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const cutoff = now() - KEEP_SECONDS * 1000;
|
|
308
|
+
for (const name of await readdir(folder)) {
|
|
309
|
+
const path = join(folder, name);
|
|
310
|
+
try {
|
|
311
|
+
if ((await lstat(path)).mtimeMs < cutoff)
|
|
312
|
+
await unlink(path);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// A file that vanished or cannot be removed stays for the next start.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Write `data` as the file's saved copy and return its path; the folder must be private. */
|
|
320
|
+
export async function save(folder, file, data) {
|
|
321
|
+
// macOS may clean the temporary folder while the daemon runs.
|
|
322
|
+
await mkdir(folder, { mode: 0o700, recursive: true });
|
|
323
|
+
if (!isPrivate(folder)) {
|
|
324
|
+
throw new DownloadFailed(`could not be saved (the uploads folder ${folder} is not private)`);
|
|
325
|
+
}
|
|
326
|
+
const path = join(folder, savedName(file));
|
|
327
|
+
try {
|
|
328
|
+
await writeFile(path, data);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
throw new DownloadFailed(`could not be saved (${errorName(error)})`, { cause: error });
|
|
332
|
+
}
|
|
333
|
+
return path;
|
|
334
|
+
}
|
|
335
|
+
/** The owner's text with the saved files' paths; with images, one message of content parts. */
|
|
336
|
+
export function promptFor(text, images, paths) {
|
|
337
|
+
if (paths.length > 0) {
|
|
338
|
+
const listed = paths.map((path) => `- ${path}`).join("\n");
|
|
339
|
+
text = text ? `${text}\n\nAttached files:\n${listed}` : `Attached files:\n${listed}`;
|
|
340
|
+
}
|
|
341
|
+
if (images.length === 0)
|
|
342
|
+
return text;
|
|
343
|
+
const parts = text ? [{ type: "text", text }] : [];
|
|
344
|
+
for (const { mediaType, data } of images) {
|
|
345
|
+
parts.push({ type: "image", mediaType, data: Buffer.from(data).toString("base64") });
|
|
346
|
+
}
|
|
347
|
+
return parts;
|
|
348
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!bind` alone, as Slack shows it: one row per folder `src/core/folders.ts` found, a Bind button
|
|
3
|
+
* each.
|
|
4
|
+
*/
|
|
5
|
+
import { relative, sep } from "node:path";
|
|
6
|
+
import { FOLDER_ROWS, fill, named, texts } from "../../core/port.js";
|
|
7
|
+
import { compareCodePoints } from "../../text.js";
|
|
8
|
+
import { contextBlock } from "./reply/blocks.js";
|
|
9
|
+
import { shownAsWritten } from "./reply/escape.js";
|
|
10
|
+
import * as slackTexts from "./texts.js";
|
|
11
|
+
export const BIND_ACTION = "folder_bind";
|
|
12
|
+
/** A path's components as `PurePosixPath` compares them: no empty or `.` part. */
|
|
13
|
+
function parts(path) {
|
|
14
|
+
return path.split("/").filter((part) => part !== "" && part !== ".");
|
|
15
|
+
}
|
|
16
|
+
function samePath(a, b) {
|
|
17
|
+
const left = parts(a);
|
|
18
|
+
const right = parts(b);
|
|
19
|
+
return left.length === right.length && left.every((part, i) => part === right[i]);
|
|
20
|
+
}
|
|
21
|
+
/** Paths sort by components as Python sorts a `Path`, so `a/b` comes before `a-b`. */
|
|
22
|
+
function comparePaths(a, b) {
|
|
23
|
+
const left = parts(a);
|
|
24
|
+
const right = parts(b);
|
|
25
|
+
for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
|
|
26
|
+
const order = compareCodePoints(left[i], right[i]);
|
|
27
|
+
if (order !== 0)
|
|
28
|
+
return order;
|
|
29
|
+
}
|
|
30
|
+
return left.length - right.length;
|
|
31
|
+
}
|
|
32
|
+
function row(root, index, folder, current) {
|
|
33
|
+
const shown = relative(root, folder).split(sep).join("/") || ".";
|
|
34
|
+
const block = {
|
|
35
|
+
type: "section",
|
|
36
|
+
// An index, not the path: a block_id is capped at 255 characters.
|
|
37
|
+
block_id: `folder-${index}`,
|
|
38
|
+
text: {
|
|
39
|
+
type: "mrkdwn",
|
|
40
|
+
text: `\`${shownAsWritten(shown)}\`${current ? slackTexts.BIND_CURRENT : ""}`,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
if (!current) {
|
|
44
|
+
block.accessory = {
|
|
45
|
+
type: "button",
|
|
46
|
+
action_id: BIND_ACTION,
|
|
47
|
+
value: shown,
|
|
48
|
+
text: { type: "plain_text", text: slackTexts.BIND_BUTTON },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return block;
|
|
52
|
+
}
|
|
53
|
+
/** The list: the first FOLDER_ROWS folders in path order, the channel's own marked. */
|
|
54
|
+
export function bindBlocks(root, folders, current, names) {
|
|
55
|
+
const shown = folders.slice(0, FOLDER_ROWS).toSorted(comparePaths);
|
|
56
|
+
// The list's own lines are the daemon's notices, small and grey; the rows keep their button.
|
|
57
|
+
if (folders.length === 0) {
|
|
58
|
+
return [contextBlock(fill(named(texts.BIND_EMPTY, names), { root: shownAsWritten(root) }))];
|
|
59
|
+
}
|
|
60
|
+
return [
|
|
61
|
+
contextBlock(fill(named(texts.BIND_LIST, names), { root: shownAsWritten(root) })),
|
|
62
|
+
...shown.map((folder, i) => row(root, i, folder, current !== null && samePath(folder, current))),
|
|
63
|
+
...(folders.length > FOLDER_ROWS
|
|
64
|
+
? [contextBlock(fill(slackTexts.BIND_MORE, { rows: FOLDER_ROWS }))]
|
|
65
|
+
: []),
|
|
66
|
+
];
|
|
67
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getLogger } from "../../log.js";
|
|
2
|
+
import { bindBlocks } from "./bind.js";
|
|
3
|
+
import { crossing, deliver, deliverNotice, rewriteAsNotice } from "./notice.js";
|
|
4
|
+
import { split } from "./reply/blocks.js";
|
|
5
|
+
import { describe } from "./reply/errors.js";
|
|
6
|
+
import { resumeBlocks } from "./resume.js";
|
|
7
|
+
import { updateBlocks } from "./update.js";
|
|
8
|
+
export const logger = getLogger("allaboard.chat.slack.channel");
|
|
9
|
+
export class SlackChannel {
|
|
10
|
+
#channel;
|
|
11
|
+
#parts;
|
|
12
|
+
constructor(channel, parts) {
|
|
13
|
+
this.#channel = channel;
|
|
14
|
+
this.#parts = parts;
|
|
15
|
+
}
|
|
16
|
+
async notice(text) {
|
|
17
|
+
if (this.#parts.stopped.aborted)
|
|
18
|
+
return null;
|
|
19
|
+
const { slack, ownerUserId } = this.#parts;
|
|
20
|
+
return crossing(() => deliverNotice(slack, ownerUserId, { channel: this.#channel, threadTs: null }, text, {
|
|
21
|
+
ephemeral: false,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
async say(markdown) {
|
|
25
|
+
const { slack, ownerUserId, stopped } = this.#parts;
|
|
26
|
+
for (const chunk of split(markdown)) {
|
|
27
|
+
if (stopped.aborted)
|
|
28
|
+
return;
|
|
29
|
+
await crossing(() => deliver(slack, ownerUserId, { channel: this.#channel, threadTs: null }, chunk, [{ type: "markdown", text: chunk }], { ephemeral: false }));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async offer(list, title) {
|
|
33
|
+
const { slack, ownerUserId, clock, stopped, names } = this.#parts;
|
|
34
|
+
if (stopped.aborted)
|
|
35
|
+
return;
|
|
36
|
+
const blocks = list.type === "folders"
|
|
37
|
+
? bindBlocks(list.root, [...list.folders], list.bound, names())
|
|
38
|
+
: list.type === "update"
|
|
39
|
+
? updateBlocks(list, title)
|
|
40
|
+
: resumeBlocks(list.folder, list.sessions, list.held, new Date(clock.time() * 1000), list.thread.thread);
|
|
41
|
+
await crossing(() => deliver(slack, ownerUserId, { channel: this.#channel, threadTs: null }, title, blocks, {
|
|
42
|
+
ephemeral: false,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
async withdraw(list) {
|
|
46
|
+
try {
|
|
47
|
+
await this.#parts.slack.chat.delete({ channel: this.#channel, ts: list });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
logger.warning(`could not delete a list in ${this.#channel}: ${describe(error)}`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async rewrite(list, text) {
|
|
56
|
+
await rewriteAsNotice(this.#parts.slack, this.#parts.limiter, this.#channel, list, text);
|
|
57
|
+
}
|
|
58
|
+
/** Slack's "Only visible to you": an ephemeral message in the channel, for the owner. */
|
|
59
|
+
async tell(text) {
|
|
60
|
+
if (this.#parts.stopped.aborted)
|
|
61
|
+
return;
|
|
62
|
+
const { slack, ownerUserId } = this.#parts;
|
|
63
|
+
await crossing(() => deliverNotice(slack, ownerUserId, { channel: this.#channel, threadTs: null }, text, {
|
|
64
|
+
ephemeral: true,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What the daemon asks Slack about the channels it is bound to, and the threads in them. */
|
|
2
|
+
import { ChatError } from "../seam.js";
|
|
3
|
+
import { THREAD_NOT_FOUND } from "./delete.js";
|
|
4
|
+
import { describe } from "./reply/errors.js";
|
|
5
|
+
const CHANNEL_NOT_FOUND = "channel_not_found";
|
|
6
|
+
/**
|
|
7
|
+
* What the cleanup asks about a bound channel: `conversations.info`. Only `channel_not_found` is
|
|
8
|
+
* an answer that the channel is gone; any other failure crosses as a `ChatError` named by
|
|
9
|
+
* Slack's code, which the cleanup logs as no answer.
|
|
10
|
+
*/
|
|
11
|
+
export function channelLookup(slack) {
|
|
12
|
+
return async (channelId) => {
|
|
13
|
+
try {
|
|
14
|
+
await slack.conversations.info({ channel: channelId });
|
|
15
|
+
return "there";
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const code = describe(error);
|
|
19
|
+
if (code === CHANNEL_NOT_FOUND)
|
|
20
|
+
return "gone";
|
|
21
|
+
throw new ChatError(code, { cause: error });
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What the cleanup asks about a thread: `conversations.replies` on its root with `limit=1`, which
|
|
27
|
+
* a deleted root answers with `thread_not_found` (measured 2026-10-01, see `home.ts`). Only that
|
|
28
|
+
* code says the thread is gone; any other failure crosses as a `ChatError` named by Slack's code.
|
|
29
|
+
*/
|
|
30
|
+
export function threadLookup(slack) {
|
|
31
|
+
return async (ref) => {
|
|
32
|
+
try {
|
|
33
|
+
await slack.conversations.replies({ channel: ref.channel, ts: ref.thread, limit: 1 });
|
|
34
|
+
return "there";
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const code = describe(error);
|
|
38
|
+
if (code === THREAD_NOT_FOUND)
|
|
39
|
+
return "gone";
|
|
40
|
+
throw new ChatError(code, { cause: error });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|