allaboard 0.0.0-stage → 0.4.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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's two Slack clients on one token, with the daemon's retry policy:
|
|
3
|
+
*
|
|
4
|
+
* - every call that Slack rate limited (HTTP 429) is sent again after the `Retry-After` Slack
|
|
5
|
+
* gave, up to 3 times and while its pauses add up to no more than `RATE_LIMIT_WAIT_SECONDS`
|
|
6
|
+
* (60): a pause that would pass them is not taken, and the call is thrown as rate limited.
|
|
7
|
+
* That is safe for every method: a rate limited call never ran. Once the
|
|
8
|
+
* daemon stops (`ClientOptions.stopped`) such a call is thrown as it is and the pause of one
|
|
9
|
+
* that waits ends: Slack names the length of that pause, and a stop does not wait for it.
|
|
10
|
+
* Each rate limit is logged with its method, its `Retry-After` and the send it answered.
|
|
11
|
+
* - a call that failed on the connection (a reset, a refused or unreachable host: a
|
|
12
|
+
* `WebAPIRequestError` that is not a timeout) is sent again once, after a backoff pause.
|
|
13
|
+
* `sharedClient` does so for every method, which is safe for a post that is an approval, a
|
|
14
|
+
* question or a notice. `repliesClient`, the one a reply is written with, does so for every
|
|
15
|
+
* method but the four of `CREATING_METHODS`: a start, an append, a stop and a post are not
|
|
16
|
+
* idempotent, a connection can reset after Slack applied the call, and a second send would
|
|
17
|
+
* duplicate a message or its text, or stop a stream twice. The sink reads the thread back
|
|
18
|
+
* instead, and adopts what landed.
|
|
19
|
+
* - a call Slack answered with HTTP 500 or 503 (`SERVER_ERROR_STATUSES`) is sent again as one
|
|
20
|
+
* that failed on the connection is: once, after the same pause, by the same client for the
|
|
21
|
+
* same methods. The statuses and the one retry are those of slack-sdk's
|
|
22
|
+
* `ServerErrorRetryHandler` (`slack_sdk/http_retry/builtin_handlers.py`, read 2026-10-11).
|
|
23
|
+
* The sink reads an HTTP status as a call that did not run (`unknownOutcome` in `errors.ts`),
|
|
24
|
+
* which would let the four creating calls be sent again too; they are left out, since whether
|
|
25
|
+
* Slack can answer 500 or 503 after it applied a call was not measured.
|
|
26
|
+
* - nothing else is sent again: Slack answering with an error (`WebAPIPlatformError`), any other
|
|
27
|
+
* HTTP status, a timeout (a timed out call may have run).
|
|
28
|
+
*
|
|
29
|
+
* `@slack/web-api` 8.2.0 retries every failure or none: `WebClient.makeRequest` ends in
|
|
30
|
+
* `pRetry(task, this.retryConfig)`, with no option per method and none per error. So both clients
|
|
31
|
+
* are built with the two documented options that switch its retries off (`SENT_ONCE`), which
|
|
32
|
+
* makes the library report each failure once, as a typed error, and `PolicyClient.apiCall` applies
|
|
33
|
+
* the policy around `WebClient.apiCall`. A retry counts toward one budget per call, as slack-sdk's
|
|
34
|
+
* `RetryState.current_attempt` did: a connection or server error retry is allowed while no retry
|
|
35
|
+
* was made, a rate limit retry while fewer than 3 were.
|
|
36
|
+
*
|
|
37
|
+
* Differences from slack-sdk: a 429 with no valid `Retry-After` is thrown as the library's plain
|
|
38
|
+
* error at once (slack-sdk waited 1 to 2 seconds and retried; Slack always sends the header); and
|
|
39
|
+
* the pause before a retry is taken from the injected `Clock`, so a test crosses it without
|
|
40
|
+
* waiting. `files.uploadV2` is passed through untouched: it is not a Slack method but the
|
|
41
|
+
* library's three step upload, whose `files.getUploadURLExternal` and
|
|
42
|
+
* `files.completeUploadExternal` come back through `apiCall` and are retried there, one by one.
|
|
43
|
+
*/
|
|
44
|
+
import { WebAPIHTTPError, WebAPIRateLimitedError, WebAPIRequestError, WebClient, } from "@slack/web-api";
|
|
45
|
+
import { systemClock } from "../../../clock.js";
|
|
46
|
+
import { getLogger } from "../../../log.js";
|
|
47
|
+
import { silentLogger } from "../quiet-logger.js";
|
|
48
|
+
export const logger = getLogger("allaboard.chat.slack.reply.clients");
|
|
49
|
+
/**
|
|
50
|
+
* The four calls that create or grow a message: the replies client never sends one again after a
|
|
51
|
+
* failed connection or a server error.
|
|
52
|
+
*/
|
|
53
|
+
export const CREATING_METHODS = [
|
|
54
|
+
"chat.startStream",
|
|
55
|
+
"chat.appendStream",
|
|
56
|
+
"chat.stopStream",
|
|
57
|
+
"chat.postMessage",
|
|
58
|
+
];
|
|
59
|
+
// What slack-sdk's client waited for an answer by default (`AsyncWebClient(timeout=30)`). The
|
|
60
|
+
// library's default is no timeout at all (`WebClientOptions.timeout`, 0), and a pass of the sink
|
|
61
|
+
// waits for its call to answer.
|
|
62
|
+
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
63
|
+
/** The two options that make the library report each failure once, whatever else it is built with. */
|
|
64
|
+
export const SENT_ONCE = {
|
|
65
|
+
retryConfig: { retries: 0 },
|
|
66
|
+
rejectRateLimitedCalls: true,
|
|
67
|
+
};
|
|
68
|
+
// `AsyncRateLimitErrorRetryHandler(max_retry_count=3)`, and the default of slack-sdk's connection
|
|
69
|
+
// handler and of its server error one: the one retry of a call covers both kinds of failure.
|
|
70
|
+
export const RATE_LIMIT_RETRIES = 3;
|
|
71
|
+
// The most of Slack's `Retry-After` pauses one call waits out in all, the jitter left out (issue
|
|
72
|
+
// #236): the sink sends a reply's calls holding the reply's lock, which a `!stop` waits for. The
|
|
73
|
+
// one value measured, on 2026-10-12 and 2026-10-13, was 10 seconds.
|
|
74
|
+
export const RATE_LIMIT_WAIT_SECONDS = 60;
|
|
75
|
+
export const CONNECTION_RETRIES = 1;
|
|
76
|
+
/** The HTTP statuses a call is sent again after, as after a failed connection. */
|
|
77
|
+
export const SERVER_ERROR_STATUSES = [500, 503];
|
|
78
|
+
// `BackoffRetryIntervalCalculator(backoff_factor=0.5)`: 0.5 * 2^attempt seconds, plus a jitter.
|
|
79
|
+
const BACKOFF_FACTOR = 0.5;
|
|
80
|
+
/** A request that failed before an answer, and not by the client's own timeout. */
|
|
81
|
+
function failedOnConnection(error) {
|
|
82
|
+
return (error instanceof WebAPIRequestError &&
|
|
83
|
+
error.original.name !== "TimeoutError" &&
|
|
84
|
+
error.original.name !== "AbortError");
|
|
85
|
+
}
|
|
86
|
+
/** Slack answered with a status that says its side failed, with no word on the call itself. */
|
|
87
|
+
function failedOnServer(error) {
|
|
88
|
+
return error instanceof WebAPIHTTPError && SERVER_ERROR_STATUSES.includes(error.statusCode);
|
|
89
|
+
}
|
|
90
|
+
/** A `WebClient` that sends each call by the policy at the top of this file. */
|
|
91
|
+
class PolicyClient extends WebClient {
|
|
92
|
+
#notRetried;
|
|
93
|
+
#clock;
|
|
94
|
+
#random;
|
|
95
|
+
#stopped;
|
|
96
|
+
constructor(token, notRetried, options) {
|
|
97
|
+
const { clock, random, stopped, ...rest } = options;
|
|
98
|
+
super(token, { timeout: REQUEST_TIMEOUT_MS, ...rest, ...SENT_ONCE, logger: silentLogger() });
|
|
99
|
+
this.#notRetried = new Set(notRetried);
|
|
100
|
+
this.#clock = clock ?? systemClock;
|
|
101
|
+
this.#random = random ?? Math.random;
|
|
102
|
+
this.#stopped = stopped;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Seconds to wait before sending the call again, or null when it is not sent again. `waited` is
|
|
106
|
+
* the sum of the `Retry-After` pauses the call already took.
|
|
107
|
+
*/
|
|
108
|
+
#pause(method, error, retries, waited) {
|
|
109
|
+
if (error instanceof WebAPIRateLimitedError) {
|
|
110
|
+
return retries < RATE_LIMIT_RETRIES && waited + error.retryAfter <= RATE_LIMIT_WAIT_SECONDS
|
|
111
|
+
? error.retryAfter + this.#random()
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
if ((failedOnConnection(error) || failedOnServer(error)) &&
|
|
115
|
+
retries < CONNECTION_RETRIES &&
|
|
116
|
+
!this.#notRetried.has(method)) {
|
|
117
|
+
return BACKOFF_FACTOR * 2 ** retries + this.#random();
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
async apiCall(method, options = {}) {
|
|
122
|
+
if (method === "files.uploadV2")
|
|
123
|
+
return super.apiCall(method, options);
|
|
124
|
+
let waited = 0;
|
|
125
|
+
for (let retries = 0;; retries += 1) {
|
|
126
|
+
try {
|
|
127
|
+
return await super.apiCall(method, options);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof WebAPIRateLimitedError) {
|
|
131
|
+
// The method and Slack's own number, never the call's arguments: which methods are
|
|
132
|
+
// rate limited, and for how long, is what issue #236 waits for.
|
|
133
|
+
logger.info(`Slack rate limited ${method}: Retry-After ${error.retryAfter} s, ` +
|
|
134
|
+
`send ${retries + 1} of ${RATE_LIMIT_RETRIES + 1}`);
|
|
135
|
+
}
|
|
136
|
+
const pause = this.#pause(method, error, retries, waited);
|
|
137
|
+
if (pause === null)
|
|
138
|
+
throw error;
|
|
139
|
+
if (error instanceof WebAPIRateLimitedError) {
|
|
140
|
+
// The one pause whose length Slack names, and the one a stop may leave: see
|
|
141
|
+
// `ClientOptions.stopped`.
|
|
142
|
+
if (this.#stopped?.aborted)
|
|
143
|
+
throw error;
|
|
144
|
+
waited += error.retryAfter;
|
|
145
|
+
try {
|
|
146
|
+
await this.#clock.sleep(pause, this.#stopped);
|
|
147
|
+
}
|
|
148
|
+
catch (stop) {
|
|
149
|
+
if (this.#stopped?.aborted)
|
|
150
|
+
throw error;
|
|
151
|
+
throw stop;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await this.#clock.sleep(pause);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** The shared client: connection, server error and rate limit retries for every call. */
|
|
162
|
+
export function sharedClient(token, options = {}) {
|
|
163
|
+
return new PolicyClient(token, [], options);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The replies client: the shared client's policy, except that `CREATING_METHODS` are not resent
|
|
167
|
+
* on a connection failure or a server error.
|
|
168
|
+
*/
|
|
169
|
+
export function repliesClient(token, options = {}) {
|
|
170
|
+
return new PolicyClient(token, CREATING_METHODS, options);
|
|
171
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** What a failed Slack call says, in words that are safe to log and to decide on. */
|
|
2
|
+
import { WebAPIHTTPError, WebAPIPlatformError, WebAPIRateLimitedError, WebAPIRequestError, } from "@slack/web-api";
|
|
3
|
+
import { getLogger } from "../../../log.js";
|
|
4
|
+
import { errorName } from "../../../values.js";
|
|
5
|
+
// The sink's lines all go under one logger name. `sinks.ts` imports this logger and, through
|
|
6
|
+
// `export *`, its callers get the `Logger` type from here.
|
|
7
|
+
export const logger = getLogger("allaboard.chat.slack.reply.sinks");
|
|
8
|
+
// chat.update errors that refuse the content itself (reference, read 2026-09-25): a plain retry
|
|
9
|
+
// can pass where the blocks did not. A transient error such as `ratelimited` is not one.
|
|
10
|
+
export const REFUSED_CONTENT = new Set([
|
|
11
|
+
"invalid_blocks",
|
|
12
|
+
"invalid_blocks_format",
|
|
13
|
+
"msg_too_long",
|
|
14
|
+
"invalid_arguments",
|
|
15
|
+
]);
|
|
16
|
+
// Slack's answers about a stream's state (measured 2026-09-28): it is over, or still open.
|
|
17
|
+
export const NOT_STREAMING = "message_not_in_streaming_state";
|
|
18
|
+
// The one refusal of an append's content measured on `chat.appendStream` (2026-10-01, slack-sdk
|
|
19
|
+
// 3.44.1), and the one a `chat.update` of the same message was seen to pass.
|
|
20
|
+
export const TOO_LONG = "msg_too_long";
|
|
21
|
+
export const STILL_STREAMING = "streaming_state_conflict";
|
|
22
|
+
/** The system's code for a request that never got an answer (`ECONNRESET`), else the error's name. */
|
|
23
|
+
function requestFailure(original) {
|
|
24
|
+
let cause = original;
|
|
25
|
+
for (let depth = 0; depth < 4 && cause instanceof Error; depth += 1) {
|
|
26
|
+
const code = cause.code;
|
|
27
|
+
if (typeof code === "string" && code)
|
|
28
|
+
return code;
|
|
29
|
+
cause = cause.cause;
|
|
30
|
+
}
|
|
31
|
+
return original.name;
|
|
32
|
+
}
|
|
33
|
+
/** Slack's error code, or the error's type: never the request or its content. */
|
|
34
|
+
export function describe(error) {
|
|
35
|
+
if (error instanceof WebAPIPlatformError)
|
|
36
|
+
return String(error.data.error);
|
|
37
|
+
// The code Slack gives a call past its rate limit (HTTP 429).
|
|
38
|
+
if (error instanceof WebAPIRateLimitedError)
|
|
39
|
+
return "ratelimited";
|
|
40
|
+
if (error instanceof WebAPIHTTPError)
|
|
41
|
+
return `http_${error.statusCode}`;
|
|
42
|
+
if (error instanceof WebAPIRequestError)
|
|
43
|
+
return requestFailure(error.original);
|
|
44
|
+
return errorName(error);
|
|
45
|
+
}
|
|
46
|
+
// What Slack says of a message whose blocks, once it has translated the markdown ones, pass 50
|
|
47
|
+
// (measured 2026-10-08 on `chat.update` and `chat.postMessage`, slack-sdk 3.45.0).
|
|
48
|
+
export const TOO_MANY_BLOCKS = "no more than 50 items allowed";
|
|
49
|
+
export const JSON_POINTER = /\[json-pointer:([^\]]*)\]/g;
|
|
50
|
+
/** The sentences Slack adds to a refused write, which say what in the payload it refused. */
|
|
51
|
+
export function refusalNotes(error) {
|
|
52
|
+
if (!(error instanceof WebAPIPlatformError))
|
|
53
|
+
return [];
|
|
54
|
+
const notes = error.data.response_metadata?.messages ?? [];
|
|
55
|
+
return notes.map((note) => String(note));
|
|
56
|
+
}
|
|
57
|
+
export function tooManyBlocks(error) {
|
|
58
|
+
return refusalNotes(error).some((note) => note.includes(TOO_MANY_BLOCKS));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* `describe`, with where in the payload Slack pointed and whether it counted too many blocks:
|
|
62
|
+
* paths and a fixed phrase, never Slack's own sentence, which can quote a value.
|
|
63
|
+
*/
|
|
64
|
+
export function describeRefusal(error) {
|
|
65
|
+
let words = describe(error);
|
|
66
|
+
const where = new Set();
|
|
67
|
+
for (const note of refusalNotes(error)) {
|
|
68
|
+
for (const match of note.matchAll(JSON_POINTER))
|
|
69
|
+
where.add(match[1] ?? "");
|
|
70
|
+
}
|
|
71
|
+
if (where.size > 0)
|
|
72
|
+
words += ` at ${[...where].sort().join(", ")}`;
|
|
73
|
+
if (tooManyBlocks(error))
|
|
74
|
+
words += ", over 50 blocks once translated";
|
|
75
|
+
return words;
|
|
76
|
+
}
|
|
77
|
+
// What Slack answers about a channel or a message it no longer has: the only refusals taken as
|
|
78
|
+
// final by the Home tab and by the crash repair. Any other failure (a rate limit, a server
|
|
79
|
+
// error, the network) is no answer.
|
|
80
|
+
export const GONE = new Set([
|
|
81
|
+
"channel_not_found",
|
|
82
|
+
"message_not_found",
|
|
83
|
+
"thread_not_found",
|
|
84
|
+
]);
|
|
85
|
+
/** Whether Slack answered that the channel or the message is not there any more. */
|
|
86
|
+
export function gone(error) {
|
|
87
|
+
return error instanceof WebAPIPlatformError && GONE.has(describe(error));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether a failed call may have been applied: Slack answering with an error says it was not
|
|
91
|
+
* (an error code, a rate limit, an HTTP status), anything else (a reset, a timeout) says nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function unknownOutcome(error) {
|
|
94
|
+
return !(error instanceof WebAPIPlatformError ||
|
|
95
|
+
error instanceof WebAPIRateLimitedError ||
|
|
96
|
+
error instanceof WebAPIHTTPError);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Delete a request (an approval, a question, a hold) once it is decided or stale:
|
|
100
|
+
* `message_not_found` counts as done, as everywhere else this project deletes one. Shared by the
|
|
101
|
+
* session, the handlers and the crash repair, so the one behaviour lives in one place. False
|
|
102
|
+
* when Slack did not delete it and did not say it is gone.
|
|
103
|
+
*/
|
|
104
|
+
export async function deleteRequest(slack, request, options = {}) {
|
|
105
|
+
const log = options.logger ?? logger;
|
|
106
|
+
const { channel, ts } = request;
|
|
107
|
+
try {
|
|
108
|
+
await slack.chat.delete({ channel, ts });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (describe(error) !== "message_not_found") {
|
|
112
|
+
log.warning(`could not remove a request in ${channel}: ${describe(error)}`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Issue #71: a message ts is its post time in epoch seconds, so the age of a request when it
|
|
117
|
+
// goes is read from the log with no timer kept for it. Ids and a duration only.
|
|
118
|
+
const now = (options.now ?? (() => Date.now() / 1000))();
|
|
119
|
+
const posted = Number(ts);
|
|
120
|
+
const age = ts.trim() === "" || Number.isNaN(posted)
|
|
121
|
+
? "age unknown"
|
|
122
|
+
: `${(now - posted).toFixed(1)}s after it was posted`;
|
|
123
|
+
log.info(`removed a request in ${channel}: message ${ts}, ${age}`);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Third-party text shown as written in Slack's two markup languages. A leaf module, so the
|
|
3
|
+
* footer, the sinks, the approvals and the lists can all use it without an import cycle.
|
|
4
|
+
*/
|
|
5
|
+
// The characters that start inline formatting in a markdown block, each escapable with a backslash
|
|
6
|
+
// (Slack's markdown block reference, read 2026-09-25). `#`, `+`, `-`, `.` and `!` act only at the
|
|
7
|
+
// start of a line. `~` is not in the reference's list, but `~~` renders as strikethrough (measured
|
|
8
|
+
// live 2026-09-25); `<` stays text there.
|
|
9
|
+
const MARKDOWN_INLINE = /([\\`*_{}[\]()&~])/g;
|
|
10
|
+
/** Slack's mrkdwn reads `&`, `<` and `>` as markup; a tool's title is shown as written. */
|
|
11
|
+
export function mrkdwnEscape(text) {
|
|
12
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
13
|
+
}
|
|
14
|
+
/** Text for inside a markdown block's own formatting, shown as written. */
|
|
15
|
+
export function markdownEscape(text) {
|
|
16
|
+
return text.replace(MARKDOWN_INLINE, "\\$1");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Model-written text for mrkdwn, shown as written: `&`, `<` and `>` escaped, so no
|
|
20
|
+
* `<url|label>` can hide what it links, and a zero-width space after each backtick, so no
|
|
21
|
+
* run of three can close a code block.
|
|
22
|
+
*/
|
|
23
|
+
export function shownAsWritten(text) {
|
|
24
|
+
return mrkdwnEscape(text).replaceAll("`", "`");
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** The budget of writes every reply in the process shares. */
|
|
2
|
+
import { monotonicClock } from "../../../clock.js";
|
|
3
|
+
import { Mutex } from "../../../core/port.js";
|
|
4
|
+
import { getLogger } from "../../../log.js";
|
|
5
|
+
export const logger = getLogger("allaboard.chat.slack.reply.limiter");
|
|
6
|
+
// chat.update is Tier 3, "50+ per minute" per app (chat.update reference, read 2026-09-28): 40
|
|
7
|
+
// per 60 s plus a burst of 5, worst case 45 in one window, a real margin under the documented
|
|
8
|
+
// floor. Paced evenly (a token bucket, not a sliding window) past the burst, so a busy minute is
|
|
9
|
+
// a steady trickle rather than every reply racing through the budget together and then freezing
|
|
10
|
+
// until it resets.
|
|
11
|
+
export const UPDATE_LIMIT = 40;
|
|
12
|
+
export const UPDATE_WINDOW_SECONDS = 60.0;
|
|
13
|
+
// How many writes the budget lets through at once before pacing kicks in: enough for a reply
|
|
14
|
+
// that just started to show its first few lines without waiting on threads that were already busy.
|
|
15
|
+
export const UPDATE_BURST = 5;
|
|
16
|
+
// A write that waited this long for its turn is logged (issue #203): one token takes
|
|
17
|
+
// `UPDATE_WINDOW_SECONDS / UPDATE_LIMIT` (1.5 s) to come back, so a wait this long is a line of
|
|
18
|
+
// writes ahead, which is when the budget delays what the owner sees. The number is the
|
|
19
|
+
// project's own.
|
|
20
|
+
export const UPDATE_WAIT_LOGGED_SECONDS = 5.0;
|
|
21
|
+
/**
|
|
22
|
+
* One instance shared by every `ReplySink` in the process, so their chat.update writes stay
|
|
23
|
+
* under Slack's app-wide budget: a token bucket refilling at `limit` tokens per `window` seconds
|
|
24
|
+
* (evenly, one token every `window / limit`), holding at most `burst` at once. A caller that has
|
|
25
|
+
* to wait keeps its place in line, since it holds the lock for as long as it waits, so the next
|
|
26
|
+
* caller queues up behind it. A retry the Slack client makes under the hood, inside one
|
|
27
|
+
* `chat.update` call, spends no extra token here: the limiter only gates the call itself, not
|
|
28
|
+
* what the client does while it is in flight.
|
|
29
|
+
*/
|
|
30
|
+
export class UpdateLimiter {
|
|
31
|
+
/** Tokens regained per second. */
|
|
32
|
+
rate;
|
|
33
|
+
burst;
|
|
34
|
+
clock;
|
|
35
|
+
log;
|
|
36
|
+
tokens;
|
|
37
|
+
checked;
|
|
38
|
+
lock = new Mutex();
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
const limit = options.limit ?? UPDATE_LIMIT;
|
|
41
|
+
const window = options.window ?? UPDATE_WINDOW_SECONDS;
|
|
42
|
+
this.rate = limit / window;
|
|
43
|
+
this.burst = options.burst ?? UPDATE_BURST;
|
|
44
|
+
this.clock = options.clock ?? monotonicClock;
|
|
45
|
+
this.log = options.logger ?? logger;
|
|
46
|
+
this.tokens = this.burst;
|
|
47
|
+
this.checked = this.clock.time();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Waits until a token is available, then spends it. A signal that aborts gives the place up.
|
|
51
|
+
* A wait of `UPDATE_WAIT_LOGGED_SECONDS` or more, the line of callers ahead included, is logged.
|
|
52
|
+
*/
|
|
53
|
+
async acquire(signal) {
|
|
54
|
+
const asked = this.clock.time();
|
|
55
|
+
const release = await this.lock.acquire(signal);
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const now = this.clock.time();
|
|
59
|
+
this.tokens = Math.min(this.burst, this.tokens + (now - this.checked) * this.rate);
|
|
60
|
+
this.checked = now;
|
|
61
|
+
if (this.tokens >= 1) {
|
|
62
|
+
this.tokens -= 1;
|
|
63
|
+
const waited = now - asked;
|
|
64
|
+
if (waited >= UPDATE_WAIT_LOGGED_SECONDS) {
|
|
65
|
+
this.log.info(`a write waited ${waited.toFixed(1)} s for the shared budget of updates`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await this.clock.sleep((1 - this.tokens) / this.rate, signal);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
release();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Give back a token `acquire` spent on a write that, once inside the caller's own lock, turned
|
|
78
|
+
* out not to be needed after all (the reply caught up to what it now shows while this one
|
|
79
|
+
* waited its turn): capped at `burst`, as a token earned by waiting would be. Never takes the
|
|
80
|
+
* lock: a concurrent `acquire` can hold it for as long as its own wait takes, and this must
|
|
81
|
+
* land at once regardless; `acquire` always rereads the tokens fresh on its own next pass.
|
|
82
|
+
*/
|
|
83
|
+
async refund() {
|
|
84
|
+
this.tokens = Math.min(this.burst, this.tokens + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude's markdown as Slack reads it: how many blocks it makes of a text and where to cut one,
|
|
3
|
+
* and the text with its markers gone, for a notification's banner.
|
|
4
|
+
*
|
|
5
|
+
* The patterns follow Python's regular expression rules where the two engines differ: Python's
|
|
6
|
+
* `\s` and `\w` are Unicode (`PY_SPACE`, `WORD`), its `.` stops at `\n` only, and its `^` under
|
|
7
|
+
* `re.M` starts a line after `\n` only (JavaScript's also after `\r`, U+2028 and U+2029).
|
|
8
|
+
*/
|
|
9
|
+
import { blank, PY_SPACE, strip } from "../../../text.js";
|
|
10
|
+
import { len } from "./chars.js";
|
|
11
|
+
import { mrkdwnEscape } from "./escape.js";
|
|
12
|
+
const S = `[${PY_SPACE}]`;
|
|
13
|
+
// Python's `\w` on text: a letter or a digit of any script, or `_`.
|
|
14
|
+
const WORD = "[\\p{L}\\p{N}_]";
|
|
15
|
+
// Slack stores a `markdown` block, and a stream's `markdown_text`, as several blocks: a header
|
|
16
|
+
// per heading, a table per table, a divider per rule, and rich text for each run of anything
|
|
17
|
+
// else between them. A write by `chat.update` or `chat.postMessage` is refused when the message
|
|
18
|
+
// passes 50 of them; a stream is not, and the update that follows its stop is (measured
|
|
19
|
+
// 2026-10-08, slack-sdk 3.45.0). Quotes, lists, images, bold lines and code blocks stay in the
|
|
20
|
+
// rich text around them, and a `#` inside a code block is not a heading.
|
|
21
|
+
// Read as CommonMark and GFM define them where Slack was not measured (a heading underlined
|
|
22
|
+
// with `=`, a fence longer than three marks, a rule with spaces in it): a shape counted that
|
|
23
|
+
// Slack keeps in its rich text only ends a message early.
|
|
24
|
+
// Sticky, so that it reads a heading at an offset of a whole text as well as at a line's start.
|
|
25
|
+
const HEADING = new RegExp(` {0,3}#{1,6}(?:${S}|$)`, "y");
|
|
26
|
+
const UNDERLINE = new RegExp(`^ {0,3}=+${S}*$`);
|
|
27
|
+
const RULE = new RegExp(`^ {0,3}([-*_])(?:${S}*\\1){2,}${S}*$`);
|
|
28
|
+
const TABLE_CELL = /^:?-+:?$/;
|
|
29
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/;
|
|
30
|
+
/** Whether a heading starts at `offset` of `text`. */
|
|
31
|
+
export function headingAt(text, offset) {
|
|
32
|
+
HEADING.lastIndex = offset;
|
|
33
|
+
return HEADING.test(text);
|
|
34
|
+
}
|
|
35
|
+
/** Python's `line.rstrip("\r")`. */
|
|
36
|
+
function rstripReturns(line) {
|
|
37
|
+
let end = line.length;
|
|
38
|
+
while (end > 0 && line.charCodeAt(end - 1) === 0x0d)
|
|
39
|
+
end -= 1;
|
|
40
|
+
return line.slice(0, end);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether `line` is a table's delimiter row: cells of dashes, each with an optional colon at
|
|
44
|
+
* either end, between pipes, the first and the last pipe optional.
|
|
45
|
+
*
|
|
46
|
+
* Read cell by cell, in time linear in the line. The agent writes the text, and one pattern for
|
|
47
|
+
* the whole row backtracks in cubic time on a long run of spaces (measured 2026-10-11, Node
|
|
48
|
+
* 26.5.1), on the event loop every thread shares.
|
|
49
|
+
*/
|
|
50
|
+
function tableRule(line) {
|
|
51
|
+
let row = strip(line);
|
|
52
|
+
if (row.startsWith("|"))
|
|
53
|
+
row = row.slice(1);
|
|
54
|
+
if (row.endsWith("|"))
|
|
55
|
+
row = row.slice(0, -1);
|
|
56
|
+
return row.split("|").every((cell) => TABLE_CELL.test(strip(cell)));
|
|
57
|
+
}
|
|
58
|
+
/** Where each block starts that Slack makes of a markdown text, as offsets into it. */
|
|
59
|
+
export function markdownStarts(text) {
|
|
60
|
+
const starts = [];
|
|
61
|
+
const lines = text.split("\n");
|
|
62
|
+
let offset = 0;
|
|
63
|
+
let fence = "";
|
|
64
|
+
let running = false;
|
|
65
|
+
let table = false;
|
|
66
|
+
for (const [number, raw] of lines.entries()) {
|
|
67
|
+
const here = offset;
|
|
68
|
+
offset += raw.length + 1;
|
|
69
|
+
const line = rstripReturns(raw);
|
|
70
|
+
const mark = FENCE.exec(line);
|
|
71
|
+
const run = mark?.[1] ?? "";
|
|
72
|
+
const info = mark?.[2] ?? "";
|
|
73
|
+
if (fence) {
|
|
74
|
+
// Closed by a run of the same mark, at least as long, with nothing after it.
|
|
75
|
+
if (mark !== null && run.startsWith(fence) && blank(info))
|
|
76
|
+
fence = "";
|
|
77
|
+
}
|
|
78
|
+
else if (mark !== null && !info.includes("`")) {
|
|
79
|
+
fence = run;
|
|
80
|
+
table = false;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
if (blank(line)) {
|
|
84
|
+
table = false;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (table && line.includes("|"))
|
|
88
|
+
continue;
|
|
89
|
+
table = false;
|
|
90
|
+
const following = rstripReturns(lines[number + 1] ?? "");
|
|
91
|
+
if (line.includes("|") && following.includes("|") && tableRule(following)) {
|
|
92
|
+
starts.push(here);
|
|
93
|
+
table = true;
|
|
94
|
+
running = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (headingAt(line, 0) || RULE.test(line) || (running && UNDERLINE.test(line))) {
|
|
98
|
+
starts.push(here);
|
|
99
|
+
running = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!running) {
|
|
104
|
+
starts.push(here);
|
|
105
|
+
running = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return starts;
|
|
109
|
+
}
|
|
110
|
+
/** How many blocks a markdown text with words in it counts in a message. */
|
|
111
|
+
export function markdownBlocks(text) {
|
|
112
|
+
return Math.max(1, markdownStarts(text).length);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Where to cut a markdown text so that it makes at most `room` blocks, at a block's start no
|
|
116
|
+
* earlier than `floor` (what a stream was already sent); null when nothing is to cut. A heading
|
|
117
|
+
* is never the last block before the cut, nor the last of a text that fills the room: it opens
|
|
118
|
+
* the next message, with the text it heads.
|
|
119
|
+
*/
|
|
120
|
+
export function markdownCut(text, room, floor = 0) {
|
|
121
|
+
const starts = markdownStarts(text);
|
|
122
|
+
let index = starts.length === room ? starts.length : null;
|
|
123
|
+
if (starts.length > room) {
|
|
124
|
+
index = null;
|
|
125
|
+
for (let i = room; i < starts.length; i += 1) {
|
|
126
|
+
if ((starts.at(i) ?? 0) >= floor) {
|
|
127
|
+
index = i;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (index === null)
|
|
133
|
+
return null;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const before = starts[index - 1];
|
|
136
|
+
if (index <= 1 || before === undefined || before < floor || !headingAt(text, before))
|
|
137
|
+
break;
|
|
138
|
+
index -= 1;
|
|
139
|
+
}
|
|
140
|
+
return starts[index] ?? null;
|
|
141
|
+
}
|
|
142
|
+
// A pattern that reads ahead and then fails is tried again from every later start, which is
|
|
143
|
+
// quadratic on a long run of `[`, of blank lines or of `_` inside a word. So the three that read
|
|
144
|
+
// ahead also match what they read where no marker is (a `[` no link closes, space no marker
|
|
145
|
+
// follows, `_` between two letters), and the replacement puts that back as it was.
|
|
146
|
+
const LINK = /\[([^\]]*)(?:\](?:\([^)]*(\))?)?)?/g;
|
|
147
|
+
// `(?<![^\n])` is Python's `^` under `re.M`; `\p{Nd}` its `\d`.
|
|
148
|
+
const LINE_MARKER = new RegExp(`(?<![^\\n])${S}*((?:#+|>|[-+*]|\\p{Nd}+\\.)${S}+)?`, "gu");
|
|
149
|
+
const PAIRED = /\*\*|__|~~|`+/g;
|
|
150
|
+
const EMPHASIS = new RegExp(`(?<!${WORD})[*_]+|[*_]+(?!${WORD})|(_+)`, "gu");
|
|
151
|
+
/**
|
|
152
|
+
* The markdown markers of `text` removed, nothing escaped, in a time linear in the text: a
|
|
153
|
+
* banner is cut from a paragraph as long as Claude wrote it.
|
|
154
|
+
*/
|
|
155
|
+
export function stripMarkdown(text) {
|
|
156
|
+
let plain = text.replace(LINK, (all, label, closed) => closed ? label : all);
|
|
157
|
+
plain = plain.replace(LINE_MARKER, (all, marker) => marker ? "" : all);
|
|
158
|
+
plain = plain.replace(PAIRED, "");
|
|
159
|
+
plain = plain.replace(EMPHASIS, (_all, kept) => kept ?? "");
|
|
160
|
+
return strip(plain);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A paragraph of Claude's markdown as the plain `text` of a notification: the markers gone
|
|
164
|
+
* (headings, quotes, list bullets, emphasis, code ticks, link targets) and `&`, `<`, `>`
|
|
165
|
+
* escaped, since Slack reads them as markup there too. With `limit` it is cut before it is
|
|
166
|
+
* escaped, so the result holds at most that many characters and no half an entity.
|
|
167
|
+
*/
|
|
168
|
+
export function bannerText(paragraph, options = {}) {
|
|
169
|
+
const plain = stripMarkdown(paragraph);
|
|
170
|
+
const limit = options.limit;
|
|
171
|
+
if (limit === undefined)
|
|
172
|
+
return mrkdwnEscape(plain);
|
|
173
|
+
const out = [];
|
|
174
|
+
let size = 0;
|
|
175
|
+
// By code point: an emoji is one character of the limit.
|
|
176
|
+
for (const char of plain) {
|
|
177
|
+
const piece = mrkdwnEscape(char);
|
|
178
|
+
const cost = len(piece);
|
|
179
|
+
if (size + cost > limit)
|
|
180
|
+
break;
|
|
181
|
+
out.push(piece);
|
|
182
|
+
size += cost;
|
|
183
|
+
}
|
|
184
|
+
return out.join("");
|
|
185
|
+
}
|
|
186
|
+
// A `<` with what follows it up to the next `|` or `>`, and the label when a `|...>` comes
|
|
187
|
+
// next. The stretch with no label is matched too, so that the search goes on after it: no `<`
|
|
188
|
+
// inside it opens a link either, since it would stop at the same `|` or `>`.
|
|
189
|
+
const LABELLED = /<[^|>]*(?:\|([^>]*)>)?/g;
|
|
190
|
+
const WORDS = new RegExp(`${WORD}+`, "gu");
|
|
191
|
+
/**
|
|
192
|
+
* `text` with each `<target|label>` replaced by its label: what `/<[^|>]*\|([^>]*)>/g` and
|
|
193
|
+
* `"$1"` give, in a time linear in the text (issue #241: that pattern starts again at every `<`
|
|
194
|
+
* of a run, and reads to the end each time). A link ends at a `>`, so the text after the last
|
|
195
|
+
* one is left unread, and every `|` in what is read has a `>` after it.
|
|
196
|
+
*/
|
|
197
|
+
function labelsOnly(text) {
|
|
198
|
+
const end = text.lastIndexOf(">") + 1;
|
|
199
|
+
const read = text.slice(0, end).replace(LABELLED, (whole, label) => label ?? whole);
|
|
200
|
+
return read + text.slice(end);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The words of a text with the markup, the punctuation and the spacing gone: what two
|
|
204
|
+
* renderings of one text (Claude's markdown and Slack's converted read-back of it, with `*b*`
|
|
205
|
+
* for `**b**`, `<url|label>` for a link, `•` for a bullet) have in common.
|
|
206
|
+
*/
|
|
207
|
+
export function plainWords(text) {
|
|
208
|
+
const words = stripMarkdown(labelsOnly(text)).match(WORDS) ?? [];
|
|
209
|
+
return words.join(" ").toLowerCase();
|
|
210
|
+
}
|