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,110 @@
|
|
|
1
|
+
/** Claude Code's `/usage` text read into the 5-hour and weekly limits. */
|
|
2
|
+
import { strip } from "../../text.js";
|
|
3
|
+
// `/usage` answers in seconds; past this the probe gives up, so the footer never stops refreshing.
|
|
4
|
+
export const USAGE_TIMEOUT = 60_000;
|
|
5
|
+
// Wording measured on Claude Code 2.1.280 (2026-09-23). A change hides the field, nothing more.
|
|
6
|
+
const SESSION_LINE = /^Current session: (\d+)% used(?: · resets (.+))?$/m;
|
|
7
|
+
const WEEK_LINE = /^Current week \(all models\): (\d+)% used(?: · resets (.+))?$/m;
|
|
8
|
+
const RESET = /^(?<mon>[A-Z][a-z]{2}) (?<day>\d{1,2}) at (?<hour>\d{1,2})(?::(?<min>\d{2}))?(?<ampm>am|pm) \((?<tz>[^)]+)\)$/;
|
|
9
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
const formatters = new Map();
|
|
12
|
+
/** The zone's formatter, null for a zone the runtime does not know. */
|
|
13
|
+
function formatterFor(zone) {
|
|
14
|
+
// `zoneinfo` knows no zone named by an offset; `Intl` reads "+02:00" as one.
|
|
15
|
+
if (/^[+-]/.test(zone)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
let formatter = formatters.get(zone);
|
|
19
|
+
if (formatter === undefined) {
|
|
20
|
+
try {
|
|
21
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
22
|
+
timeZone: zone,
|
|
23
|
+
hourCycle: "h23",
|
|
24
|
+
year: "numeric",
|
|
25
|
+
month: "numeric",
|
|
26
|
+
day: "numeric",
|
|
27
|
+
hour: "numeric",
|
|
28
|
+
minute: "numeric",
|
|
29
|
+
second: "numeric",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (!(error instanceof RangeError)) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
formatter = null;
|
|
37
|
+
}
|
|
38
|
+
formatters.set(zone, formatter);
|
|
39
|
+
}
|
|
40
|
+
return formatter;
|
|
41
|
+
}
|
|
42
|
+
function wallClock(at, formatter) {
|
|
43
|
+
const parts = {};
|
|
44
|
+
for (const part of formatter.formatToParts(at)) {
|
|
45
|
+
if (part.type !== "literal") {
|
|
46
|
+
parts[part.type] = Number(part.value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return parts;
|
|
50
|
+
}
|
|
51
|
+
/** The zone's offset from UTC at an instant, in milliseconds. */
|
|
52
|
+
function offsetAt(at, formatter) {
|
|
53
|
+
const w = wallClock(at, formatter);
|
|
54
|
+
const local = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
|
|
55
|
+
return local - Math.floor(at / 1000) * 1000;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The instant a wall-clock time has in a zone, as `zoneinfo` reads it with `fold=0`: a time the
|
|
59
|
+
* zone repeats is its first occurrence, and one it skips takes the offset from before the jump.
|
|
60
|
+
*/
|
|
61
|
+
function instantOf(wall, formatter) {
|
|
62
|
+
const before = offsetAt(wall - DAY_MS, formatter);
|
|
63
|
+
const after = offsetAt(wall + DAY_MS, formatter);
|
|
64
|
+
const valid = [before, after]
|
|
65
|
+
.map((offset) => wall - offset)
|
|
66
|
+
.filter((instant) => offsetAt(instant, formatter) === wall - instant);
|
|
67
|
+
return valid.length > 0 ? Math.min(...valid) : wall - before;
|
|
68
|
+
}
|
|
69
|
+
/** The instant in `text` ("Sep 24 at 1:10am (Europe/Berlin)"), null when it cannot be read. */
|
|
70
|
+
export function parseReset(text, now) {
|
|
71
|
+
const found = RESET.exec(strip(text))?.groups;
|
|
72
|
+
if (found === undefined) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const month = MONTHS.indexOf(found.mon);
|
|
76
|
+
const formatter = formatterFor(found.tz);
|
|
77
|
+
if (month < 0 || formatter === null) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const hour = (Number(found.hour) % 12) + (found.ampm === "pm" ? 12 : 0);
|
|
81
|
+
const day = Number(found.day);
|
|
82
|
+
const minute = Number(found.min ?? 0);
|
|
83
|
+
const at = (year) => {
|
|
84
|
+
const wall = new Date(Date.UTC(year, month, day, hour, minute));
|
|
85
|
+
// A day or a minute out of range is a date that does not exist, not the next month's.
|
|
86
|
+
if (minute > 59 || wall.getUTCMonth() !== month || wall.getUTCDate() !== day) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return instantOf(wall.getTime(), formatter);
|
|
90
|
+
};
|
|
91
|
+
// The year is the one `now` has in the reset's own zone.
|
|
92
|
+
const year = wallClock(now, formatter).year;
|
|
93
|
+
const instant = at(year);
|
|
94
|
+
if (instant === null || instant >= now - DAY_MS) {
|
|
95
|
+
return instant;
|
|
96
|
+
}
|
|
97
|
+
return at(year + 1);
|
|
98
|
+
}
|
|
99
|
+
function parseLimit(pattern, text, now) {
|
|
100
|
+
const match = pattern.exec(text);
|
|
101
|
+
if (match === null) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const reset = match[2];
|
|
105
|
+
return { percent: Number(match[1]), resetsAt: reset ? parseReset(reset, now) : null };
|
|
106
|
+
}
|
|
107
|
+
/** The 5-hour and weekly figures from `/usage`; a field it cannot read is null. */
|
|
108
|
+
export function parseUsage(text, now) {
|
|
109
|
+
return { session: parseLimit(SESSION_LINE, text, now), week: parseLimit(WEEK_LINE, text, now) };
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what Claude Code sends as `unknown`. The SDK's types describe less than the wire
|
|
3
|
+
* carries (of 24 recorded kinds, 9 hold fields the types do not declare and one is absent from
|
|
4
|
+
* the union, measured 2026-10-10 on SDK 0.3.296), so nothing here trusts a declared shape: each
|
|
5
|
+
* value is narrowed where it is read.
|
|
6
|
+
*/
|
|
7
|
+
/** `value` when it is a string, else null. */
|
|
8
|
+
export function string(value) {
|
|
9
|
+
return typeof value === "string" ? value : null;
|
|
10
|
+
}
|
|
11
|
+
/** `value` when it is a string that says something, else null. */
|
|
12
|
+
export function words(value) {
|
|
13
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
14
|
+
}
|
|
15
|
+
/** `value` when it is a whole number, else null. */
|
|
16
|
+
export function integer(value) {
|
|
17
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
/** `value` when it is a number, else null. */
|
|
20
|
+
export function number(value) {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent refused to resume the session a start named (its transcript is gone): the thread
|
|
3
|
+
* that held it cannot go on. Any other failure of a start rejects with the failure itself, and
|
|
4
|
+
* the thread's next message tries again.
|
|
5
|
+
*/
|
|
6
|
+
export class ResumeRefused extends Error {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super("the agent could not resume the session", options);
|
|
9
|
+
this.name = "ResumeRefused";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A setting the owner gave the agent in the daemon's file cannot be used; the message names the
|
|
14
|
+
* variable and says what to fix. The daemon logs it as it does its own configuration errors.
|
|
15
|
+
*/
|
|
16
|
+
export class AgentConfigError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "AgentConfigError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A call the chat refused or could not take. `name` is the provider's code for the failure
|
|
3
|
+
* (`channel_not_found`), or the error's own name: all a log line may say of it, never content.
|
|
4
|
+
*/
|
|
5
|
+
export class ChatError extends Error {
|
|
6
|
+
constructor(code, options) {
|
|
7
|
+
super(code, options);
|
|
8
|
+
this.name = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every listener shares: the checks each one runs on its own, where the daemon's own answers
|
|
3
|
+
* go, and how a failure after the checks reaches the owner.
|
|
4
|
+
*
|
|
5
|
+
* The work handed to `replyOnFailure` is a function, called inside its guard.
|
|
6
|
+
*/
|
|
7
|
+
import { Cancelled, DirectoryUnavailable, fill, named, SessionClosed, SessionGone, texts, } from "../../../core/port.js";
|
|
8
|
+
import { getLogger } from "../../../log.js";
|
|
9
|
+
import { deliver } from "../notice.js";
|
|
10
|
+
import { contextBlock, noticeText } from "../reply/blocks.js";
|
|
11
|
+
import { describe } from "../reply/errors.js";
|
|
12
|
+
import * as slackTexts from "../texts.js";
|
|
13
|
+
import { isOwner } from "./guards.js";
|
|
14
|
+
export const logger = getLogger("allaboard.chat.slack.app");
|
|
15
|
+
/** Whether a wait was cancelled: a task's signal aborted. */
|
|
16
|
+
export function isCancelled(error) {
|
|
17
|
+
return error instanceof Cancelled;
|
|
18
|
+
}
|
|
19
|
+
export class Answers {
|
|
20
|
+
parts;
|
|
21
|
+
constructor(parts) {
|
|
22
|
+
this.parts = parts;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the daemon has stopped. A listener that finds it so ends silently: Node's listeners
|
|
26
|
+
* go on at their next await after the stop.
|
|
27
|
+
*/
|
|
28
|
+
get stopped() {
|
|
29
|
+
return this.parts.stopped.aborted;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One message to the channel: a normal post (in `threadTs`'s thread, or top-level when it is
|
|
33
|
+
* null) or, with `ephemeral`, one only the owner sees, under `threadTs`. A normal post pushes
|
|
34
|
+
* only inside a thread the owner started; an ephemeral message never does. Returns the `ts`
|
|
35
|
+
* of a normal post, null for an ephemeral message and once the daemon has stopped.
|
|
36
|
+
*/
|
|
37
|
+
async deliver(channel, threadTs, text, blocks, options) {
|
|
38
|
+
const { slack, identity, sessions } = this.parts;
|
|
39
|
+
if (this.stopped)
|
|
40
|
+
return null;
|
|
41
|
+
const posted = await deliver(slack, identity.ownerUserId, { channel, threadTs }, text, blocks, options);
|
|
42
|
+
if (threadTs !== null) {
|
|
43
|
+
// Slack clears a thread's status line when the app replies: the session's is set
|
|
44
|
+
// again, so `Working…` does not go for a minute after the answer to a word.
|
|
45
|
+
sessions.wrote(channel, threadTs);
|
|
46
|
+
}
|
|
47
|
+
return posted;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One of the daemon's own notices (a bind, a resume, a restart), small and grey as the footer,
|
|
51
|
+
* so it reads apart from Claude's replies. `text` is mrkdwn.
|
|
52
|
+
*/
|
|
53
|
+
async notice(channel, threadTs, text, options = {}) {
|
|
54
|
+
await this.deliver(channel, threadTs, text, [contextBlock(noticeText(text))], {
|
|
55
|
+
ephemeral: options.ephemeral ?? false,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A line for the owner alone, under their message in `threadTs`'s thread: a refusal that
|
|
60
|
+
* answers what they just did. With `stays` it is a post in the thread instead, which a reload
|
|
61
|
+
* keeps and which can notify. Never throws: nobody is left to tell about a failure.
|
|
62
|
+
*/
|
|
63
|
+
async tellOwner(channel, threadTs, text, options = {}) {
|
|
64
|
+
await this.thread(channel, threadTs).tell(text, options);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `tellOwner` about the message `ts`. At the top level (`threadTs` is `ts`) the line goes to
|
|
68
|
+
* the channel: the message has no reply yet, and Slack shows an ephemeral message in a thread
|
|
69
|
+
* only when the thread is already active (`chat.postEphemeral` reference), accepting the call
|
|
70
|
+
* and showing nothing. Never throws.
|
|
71
|
+
*/
|
|
72
|
+
async tellOwnerAbout(channel, threadTs, ts, text) {
|
|
73
|
+
if (threadTs !== ts) {
|
|
74
|
+
await this.tellOwner(channel, threadTs, text);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await this.notice(channel, null, text, { ephemeral: true });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
logger.warning(`could not reach the owner in ${channel}: ${describe(error)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A notice about the message `ts` that the daemon can take back (`withdraw`): a normal post,
|
|
86
|
+
* since Slack lets nobody delete an ephemeral message. It goes to the message's thread, or to
|
|
87
|
+
* the channel for a message at the top level, which has no thread yet. Returns the post's
|
|
88
|
+
* `ts`, null when nothing was posted. Never throws.
|
|
89
|
+
*/
|
|
90
|
+
async tellAbout(channel, threadTs, ts, text) {
|
|
91
|
+
try {
|
|
92
|
+
return await this.deliver(channel, threadTs === ts ? null : threadTs, text, [contextBlock(noticeText(text))], { ephemeral: false });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
logger.warning(`could not reach the owner in ${channel}: ${describe(error)}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Takes back a notice `tellAbout` posted; one already gone counts as done. Never throws. */
|
|
100
|
+
async withdraw(channel, ts) {
|
|
101
|
+
if (this.stopped)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
await this.parts.slack.chat.delete({ channel, ts });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (describe(error) !== "message_not_found") {
|
|
108
|
+
logger.warning(`could not take back a notice in ${channel}: ${describe(error)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Answer in the channel itself, as a normal post: where a word typed in the channel, or a
|
|
114
|
+
* click on a list posted there, is answered.
|
|
115
|
+
*/
|
|
116
|
+
async inChannel(channel, text) {
|
|
117
|
+
await this.parts.chat.channel(channel).notice(text);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The thread as a handler answers in it: what it posts clears the live session's status line,
|
|
121
|
+
* which is set again.
|
|
122
|
+
*/
|
|
123
|
+
thread(channel, threadTs) {
|
|
124
|
+
const ref = { channel, thread: threadTs };
|
|
125
|
+
return this.parts.chat.answering(ref, () => this.parts.sessions.wrote(channel, threadTs));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A failure after the checks reaches the owner as a line of its own, never as silence: by
|
|
129
|
+
* `report`, or as a post in the thread, the one push of a turn that failed.
|
|
130
|
+
*/
|
|
131
|
+
async replyOnFailure(channel, threadTs, work, report) {
|
|
132
|
+
const told = report ?? ((text) => this.notice(channel, threadTs, text));
|
|
133
|
+
// Never throws: this runs inside a handler's own `catch`, and a throw would reach the
|
|
134
|
+
// message listener's outer handler, which posts `ERROR_REPLY` threaded under the word.
|
|
135
|
+
const guarded = async (text) => {
|
|
136
|
+
try {
|
|
137
|
+
await told(text);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (isCancelled(error))
|
|
141
|
+
throw error;
|
|
142
|
+
logger.warning(`could not report a failure in ${channel}/${threadTs}: ${describe(error)}`);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
await work();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (isCancelled(error))
|
|
150
|
+
throw error;
|
|
151
|
+
if (error instanceof DirectoryUnavailable ||
|
|
152
|
+
error instanceof SessionClosed ||
|
|
153
|
+
error instanceof SessionGone) {
|
|
154
|
+
await guarded(error.message);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
logger.error(`a request failed in ${channel}/${threadTs}: ${describe(error)}`);
|
|
158
|
+
await guarded(fill(named(texts.ERROR_REPLY, this.parts.names), { error: describe(error) }));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The two checks every inbound path that names a channel runs on its own: the owner acting from
|
|
163
|
+
* the workspace read at startup, then the channel as Slack describes it now. Anyone else gets
|
|
164
|
+
* nothing; the owner, in a channel the guard refuses, is told why (`about` as in
|
|
165
|
+
* `channelAllowed`).
|
|
166
|
+
*/
|
|
167
|
+
async admitted(user, team, channel, threadTs, about = null) {
|
|
168
|
+
if (!this.fromOwner(user, team, channel) || channel === null)
|
|
169
|
+
return false;
|
|
170
|
+
return this.channelAllowed(channel, threadTs, about);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The first of the two checks, with nothing awaited: false once the daemon has stopped, for an
|
|
174
|
+
* event with no channel and for anyone but the owner acting from the workspace read at
|
|
175
|
+
* startup. A listener that keeps an order takes its place only after it, so that anyone else
|
|
176
|
+
* takes none.
|
|
177
|
+
*/
|
|
178
|
+
fromOwner(user, team, channel) {
|
|
179
|
+
if (this.stopped)
|
|
180
|
+
return false;
|
|
181
|
+
if (!channel || !isOwner(this.parts.identity, user, team)) {
|
|
182
|
+
logger.info("ignored an inbound event from someone other than the owner");
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The second check: the channel as Slack describes it now, the owner told why when refused.
|
|
189
|
+
* `about` is the `ts` of the message that asked, null when none did (a click): a refusal about
|
|
190
|
+
* a message at the top level goes to the channel, any other to `threadTs`'s thread.
|
|
191
|
+
*/
|
|
192
|
+
async channelAllowed(channel, threadTs, about = null) {
|
|
193
|
+
const reason = await this.parts.guard.refusal(channel);
|
|
194
|
+
// The check can outlast the daemon: nothing is said, not even to refuse the channel.
|
|
195
|
+
if (this.stopped)
|
|
196
|
+
return false;
|
|
197
|
+
if (reason !== null) {
|
|
198
|
+
// For the owner alone, even from a top-level message: a channel the guard refuses may
|
|
199
|
+
// hold people who must not read the bot's words.
|
|
200
|
+
const told = fill(slackTexts.CHANNEL_REFUSED, { reason });
|
|
201
|
+
if (about === null)
|
|
202
|
+
await this.tellOwner(channel, threadTs, told);
|
|
203
|
+
else
|
|
204
|
+
await this.tellOwnerAbout(channel, threadTs, about, told);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Slack side: every inbound path, each checked on its own before it reaches a session. This
|
|
3
|
+
* builds the Bolt app, with one listener per inbound path (a message, the `file_change` of a
|
|
4
|
+
* clip, a button, a select, a modal, a Home control). The app registers no slash command.
|
|
5
|
+
*
|
|
6
|
+
* What was read in `@slack/bolt` 5.1.0 (`node_modules/@slack/bolt/dist/`), and what follows:
|
|
7
|
+
*
|
|
8
|
+
* - `App.js`, the constructor. With `authorize` and no `token`, `app.client` is built with no
|
|
9
|
+
* token, and `processEvent` makes a client of its own only for a context that carries a
|
|
10
|
+
* `botToken` or a `userToken` (`selectToken`). `authorize` below returns the workspace and the
|
|
11
|
+
* bot user `auth.test` gave at startup and no token: no request costs an API call, and Bolt
|
|
12
|
+
* never builds a client that could call Slack. Every listener calls Slack through the client
|
|
13
|
+
* `buildApp` was given.
|
|
14
|
+
* - `App.js`, `initReceiver`. `socketMode: true` with an `appToken` builds a
|
|
15
|
+
* `SocketModeReceiver` inside the constructor; a `receiver` passed in is used as it is. The
|
|
16
|
+
* receiver is passed in: the daemon gives `socketReceiver()`, a test one that opens no socket
|
|
17
|
+
* and feeds `App.processEvent({ body, ack })`, the entry point `SocketModeReceiver.js` itself
|
|
18
|
+
* calls for each `slack_event`.
|
|
19
|
+
* - `App.js`, `processEvent`. `body` is the envelope as it came, so `team_id` and
|
|
20
|
+
* `is_ext_shared_channel` of an event's envelope reach the listener; `payload` is the event,
|
|
21
|
+
* the first action or the view. An event is acknowledged by Bolt before any listener runs
|
|
22
|
+
* (`await ack()`); an action or a view submission is acknowledged by its listener, and what a
|
|
23
|
+
* view submission answers (`response_action`) is the argument of `ack`. Every listener whose
|
|
24
|
+
* constraints match runs, and one that throws reaches `handleError`, the handler set with
|
|
25
|
+
* `app.error`: an `UnknownError` holding the thrown value in `original`, unless it has a
|
|
26
|
+
* `code` of its own.
|
|
27
|
+
* - `middleware/builtin.js`. `ignoreSelf` (on by default) drops a `bot_message` of this app and
|
|
28
|
+
* any event whose `user` is the bot. It is switched off, which differs from `slack_bolt`'s
|
|
29
|
+
* default (it ignores its own events). That is harmless: the listeners drop what a bot wrote
|
|
30
|
+
* themselves (`guards.isPromptMessage` refuses any event with a `bot_id`, and the owner check
|
|
31
|
+
* refuses a user who is not the owner), so the middleware would add nothing. `app.event("message")`
|
|
32
|
+
* filters nothing else (`onlyEvents`, `matchEventType`): a `message_changed` reaches the
|
|
33
|
+
* listener, which drops it. `app.action` matches on `action_id` alone, `app.view` on
|
|
34
|
+
* `callback_id` and the `view_submission` type.
|
|
35
|
+
* - `conversation-store.js`. The default store adds a lookup per event for a state this app
|
|
36
|
+
* never keeps: switched off (`convoStore: false`).
|
|
37
|
+
*/
|
|
38
|
+
import { App, LogLevel, SocketModeReceiver } from "@slack/bolt";
|
|
39
|
+
import { monotonicClock } from "../../../clock.js";
|
|
40
|
+
import { Listings } from "../../../core/port.js";
|
|
41
|
+
import { download } from "../attachments.js";
|
|
42
|
+
import { BIND_ACTION } from "../bind.js";
|
|
43
|
+
import { HOLD_CANCEL, HOLD_CONTINUE } from "../hold.js";
|
|
44
|
+
import { CLEAN_ACTION, DELETE_ACTION, EDIT_ACTION, FILTER_ACTIONS, HOME_UPDATE_ACTION, NEW_THREAD_ACTION, SHOW_ALL_ACTION, } from "../home.js";
|
|
45
|
+
import { OPEN_BUTTON_ACTION, OPEN_FORM, QUERY_ACTION } from "../openfile/modal.js";
|
|
46
|
+
import { levelLogger } from "../quiet-logger.js";
|
|
47
|
+
import { REQUEST_TIMEOUT_MS } from "../reply/clients.js";
|
|
48
|
+
import { describe } from "../reply/errors.js";
|
|
49
|
+
import { APPROVAL_ALLOW, APPROVAL_DENY, QUESTION_EDIT, QUESTION_FORM, QUESTION_OPEN, QUESTION_SKIP, } from "../requests.js";
|
|
50
|
+
import { RESUME_ACTION } from "../resume.js";
|
|
51
|
+
import { SETUP_BYPASS, SETUP_EFFORT, SETUP_MODEL, SETUP_START } from "../setup.js";
|
|
52
|
+
import { UPDATE_ACTION } from "../update.js";
|
|
53
|
+
import * as voice from "../voice.js";
|
|
54
|
+
import { Answers, logger } from "./answers.js";
|
|
55
|
+
import { Clips } from "./clip.js";
|
|
56
|
+
import { isPromptMessage, isWrittenMessage, messageActor, } from "./guards.js";
|
|
57
|
+
import { HomeControls } from "./home-controls.js";
|
|
58
|
+
import { filesOf, inboundMessage } from "./messages.js";
|
|
59
|
+
import { Open } from "./open.js";
|
|
60
|
+
import { ThreadOrder, threadKey } from "./order.js";
|
|
61
|
+
import { Requests } from "./requests.js";
|
|
62
|
+
import { ResumeBind } from "./resume-bind.js";
|
|
63
|
+
import { str, text } from "./wire.js";
|
|
64
|
+
export const DECISION_ACTIONS = [APPROVAL_ALLOW, APPROVAL_DENY, QUESTION_SKIP];
|
|
65
|
+
// The longest wait between two tries to get the connection back.
|
|
66
|
+
export const RECONNECT_WAIT_SECONDS = 60;
|
|
67
|
+
// The web client `@slack/socket-mode` 3.1.0 asks Slack for a WebSocket URL with. Left to itself
|
|
68
|
+
// the library gives it 100 retries, each wait 1.3 times the one before with no upper limit: with
|
|
69
|
+
// the network gone for two hours it tried 30 times, the last wait 26 minutes, and was still
|
|
70
|
+
// waiting 10 minutes after the network was back (run on mocked timers, 2026-10-10). The waits
|
|
71
|
+
// start as the library's and stop growing at the limit, and the client never gives up, so the
|
|
72
|
+
// connection is back within the limit of the network's return. A try that gets no answer ends
|
|
73
|
+
// at the timeout of the daemon's other clients.
|
|
74
|
+
const RECONNECT = {
|
|
75
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
76
|
+
retryConfig: {
|
|
77
|
+
forever: true,
|
|
78
|
+
retries: 100,
|
|
79
|
+
factor: 1.3,
|
|
80
|
+
maxTimeout: RECONNECT_WAIT_SECONDS * 1000,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The Socket Mode connection the daemon's app listens on; `app.start()` opens it, `app.stop()`
|
|
85
|
+
* closes it. The libraries' own text is never logged (`levelLogger`): the connection's states
|
|
86
|
+
* are, by the names the client gives them.
|
|
87
|
+
*/
|
|
88
|
+
export function socketReceiver(appToken, options = {}) {
|
|
89
|
+
const receiver = new SocketModeReceiver({
|
|
90
|
+
appToken,
|
|
91
|
+
logger: levelLogger("socket mode"),
|
|
92
|
+
logLevel: LogLevel.WARN,
|
|
93
|
+
...(options.clientPingTimeout !== undefined && {
|
|
94
|
+
clientPingTimeout: options.clientPingTimeout,
|
|
95
|
+
}),
|
|
96
|
+
installerOptions: { clientOptions: { ...RECONNECT, ...options.clientOptions } },
|
|
97
|
+
});
|
|
98
|
+
receiver.client.on("connected", () => logger.info("socket mode: connected"));
|
|
99
|
+
receiver.client.on("reconnecting", () => logger.warning("socket mode: reconnecting"));
|
|
100
|
+
receiver.client.on("disconnected", () => logger.info("socket mode: disconnected"));
|
|
101
|
+
return receiver;
|
|
102
|
+
}
|
|
103
|
+
/** The error a listener threw, under Bolt's wrapping of one that has no `code`. */
|
|
104
|
+
function thrown(error) {
|
|
105
|
+
return typeof error === "object" && error !== null && "original" in error
|
|
106
|
+
? error.original
|
|
107
|
+
: error;
|
|
108
|
+
}
|
|
109
|
+
/** The Bolt app, with one listener per inbound path. */
|
|
110
|
+
export function buildApp(options) {
|
|
111
|
+
const { chat, config, identity, sessions } = options;
|
|
112
|
+
const parts = {
|
|
113
|
+
slack: options.slack,
|
|
114
|
+
chat,
|
|
115
|
+
config,
|
|
116
|
+
identity,
|
|
117
|
+
sessions,
|
|
118
|
+
approvals: options.approvals,
|
|
119
|
+
holds: options.holds,
|
|
120
|
+
guard: options.guard,
|
|
121
|
+
state: options.state,
|
|
122
|
+
names: options.names,
|
|
123
|
+
inbound: options.inbound,
|
|
124
|
+
uploads: options.uploads,
|
|
125
|
+
home: options.home,
|
|
126
|
+
limiter: chat.limiter,
|
|
127
|
+
fetch: options.fetch ??
|
|
128
|
+
(({ url, mimetype, limit }) => download(url, config.botToken, mimetype, limit)),
|
|
129
|
+
clock: options.clock ?? monotonicClock,
|
|
130
|
+
stopped: chat.stopped,
|
|
131
|
+
now: options.now ?? (() => Date.now() / 1000),
|
|
132
|
+
listings: options.listings ?? new Listings((directory) => sessions.repository(directory)),
|
|
133
|
+
};
|
|
134
|
+
const answers = new Answers(parts);
|
|
135
|
+
const requests = new Requests(parts, answers);
|
|
136
|
+
const open = new Open(parts, answers);
|
|
137
|
+
const resumeBind = new ResumeBind(parts, answers);
|
|
138
|
+
const clips = new Clips(parts, answers);
|
|
139
|
+
const home = new HomeControls(parts);
|
|
140
|
+
const app = new App({
|
|
141
|
+
receiver: options.receiver,
|
|
142
|
+
// auth.test already ran at startup; who may act is decided by the guards of each listener.
|
|
143
|
+
authorize: async () => ({ teamId: identity.teamId, botUserId: identity.botUserId }),
|
|
144
|
+
ignoreSelf: false,
|
|
145
|
+
convoStore: false,
|
|
146
|
+
logger: levelLogger("bolt"),
|
|
147
|
+
logLevel: LogLevel.WARN,
|
|
148
|
+
});
|
|
149
|
+
const registered = [];
|
|
150
|
+
const order = new ThreadOrder();
|
|
151
|
+
app.event("message", async ({ event, body }) => {
|
|
152
|
+
const message = event;
|
|
153
|
+
const envelope = body;
|
|
154
|
+
if (!isWrittenMessage(message))
|
|
155
|
+
return;
|
|
156
|
+
const [user, team] = messageActor(message, envelope);
|
|
157
|
+
const channel = text(message.channel);
|
|
158
|
+
const ts = str(message.ts);
|
|
159
|
+
const threadTs = str(message.thread_ts || ts);
|
|
160
|
+
// The owner and the workspace first, with nothing awaited: anyone else takes no place.
|
|
161
|
+
if (!answers.fromOwner(user, team, channel) || channel === null)
|
|
162
|
+
return;
|
|
163
|
+
// (sync) Where the owner's message sits among the replies, whatever becomes of it next:
|
|
164
|
+
// also a message the filter below drops.
|
|
165
|
+
parts.chat.ownerPosted(channel, threadTs, ts);
|
|
166
|
+
if (!isPromptMessage(message))
|
|
167
|
+
return;
|
|
168
|
+
// (sync) The place among the thread's messages is taken in the order the listeners were
|
|
169
|
+
// called, before the first await: the channel check below takes longer for one message than
|
|
170
|
+
// for the next, and a message that passed it first would be handed over first. The place is
|
|
171
|
+
// left once the message is handed over (`arrival` in `core/flows/messages.ts` orders it from
|
|
172
|
+
// there), never held through the turn, a download or a setup the owner has not answered.
|
|
173
|
+
// The exception is a clip whose transcript is in the file's `vtt`: `Clips.takeClip` hands it
|
|
174
|
+
// over after the download of that file, so a typed reply sent after it can be handed over
|
|
175
|
+
// first.
|
|
176
|
+
const place = order.enter(threadKey(channel, threadTs));
|
|
177
|
+
let handed;
|
|
178
|
+
try {
|
|
179
|
+
await place.turn;
|
|
180
|
+
// The daemon can have stopped while this message waited for the ones before it: calling
|
|
181
|
+
// Slack then would break the contract of a listener in flight after the stop.
|
|
182
|
+
if (answers.stopped)
|
|
183
|
+
return;
|
|
184
|
+
if (!(await answers.channelAllowed(channel, threadTs, ts)))
|
|
185
|
+
return;
|
|
186
|
+
// (sync) From the check to the call that hands the message over, nothing is awaited.
|
|
187
|
+
const spoken = voice.clip(filesOf(message));
|
|
188
|
+
// A failure of the prompt itself (or of the session it starts) lands in that session's
|
|
189
|
+
// thread: it is the turn's one push. A word answers where it was typed instead. The core
|
|
190
|
+
// tells the owner of a failure of its own.
|
|
191
|
+
handed =
|
|
192
|
+
spoken !== null
|
|
193
|
+
? answers.replyOnFailure(channel, threadTs, () => clips.takeClip(channel, threadTs, ts, message, spoken))
|
|
194
|
+
: parts.inbound.message(inboundMessage(parts, answers, channel, threadTs, ts, message));
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
place.leave();
|
|
198
|
+
}
|
|
199
|
+
await handed;
|
|
200
|
+
});
|
|
201
|
+
app.event("file_change", ({ event }) => clips.onFileChange(event));
|
|
202
|
+
registered.push({ kind: "event", id: "message", acknowledgeOnly: false }, { kind: "event", id: "file_change", acknowledgeOnly: false });
|
|
203
|
+
/** A click or a select: the listener acknowledges, then checks, on its own. */
|
|
204
|
+
const action = (actionId, listener, options = {}) => {
|
|
205
|
+
registered.push({
|
|
206
|
+
kind: "action",
|
|
207
|
+
id: String(actionId),
|
|
208
|
+
acknowledgeOnly: options.acknowledgeOnly ?? false,
|
|
209
|
+
});
|
|
210
|
+
app.action(actionId, ({ ack, body }) => listener(() => ack(), body));
|
|
211
|
+
};
|
|
212
|
+
/** A modal's submit: what the listener answers is what the modal does next. */
|
|
213
|
+
const view = (callbackId, listener) => {
|
|
214
|
+
registered.push({ kind: "view", id: callbackId, acknowledgeOnly: false });
|
|
215
|
+
app.view(callbackId, ({ ack, body }) =>
|
|
216
|
+
// The answer is built to the view submission reference by the listener itself.
|
|
217
|
+
listener((answer) => ack(answer), body));
|
|
218
|
+
};
|
|
219
|
+
action(SETUP_START, requests.onSetupStart);
|
|
220
|
+
action(SETUP_MODEL, requests.onSetupModel);
|
|
221
|
+
// The controls' own state rides on Start's click: a change of either needs nothing here.
|
|
222
|
+
action(SETUP_EFFORT, requests.onSetupEdit, { acknowledgeOnly: true });
|
|
223
|
+
action(SETUP_BYPASS, requests.onSetupEdit, { acknowledgeOnly: true });
|
|
224
|
+
for (const actionId of [HOLD_CONTINUE, HOLD_CANCEL])
|
|
225
|
+
action(actionId, requests.onHoldDecision);
|
|
226
|
+
for (const actionId of DECISION_ACTIONS)
|
|
227
|
+
action(actionId, requests.onDecision);
|
|
228
|
+
action("answer", requests.onQuestionChoice);
|
|
229
|
+
action("other", requests.onQuestionTyping);
|
|
230
|
+
action("notes", requests.onQuestionTyping);
|
|
231
|
+
action(QUESTION_EDIT, requests.onQuestionEdit);
|
|
232
|
+
action(QUESTION_OPEN, requests.onQuestionOpen);
|
|
233
|
+
view(QUESTION_FORM, requests.onQuestionSubmit);
|
|
234
|
+
action(OPEN_BUTTON_ACTION, open.onOpenChoose);
|
|
235
|
+
action(QUERY_ACTION, open.onOpenQuery);
|
|
236
|
+
view(OPEN_FORM, open.onOpenSubmit);
|
|
237
|
+
action(BIND_ACTION, resumeBind.onBind);
|
|
238
|
+
action(RESUME_ACTION, resumeBind.onResume);
|
|
239
|
+
action(UPDATE_ACTION, resumeBind.onUpdate);
|
|
240
|
+
action(NEW_THREAD_ACTION, home.onHomeLink, { acknowledgeOnly: true });
|
|
241
|
+
for (const actionId of FILTER_ACTIONS)
|
|
242
|
+
action(actionId, home.onHomeFilter);
|
|
243
|
+
action(SHOW_ALL_ACTION, home.onHomeShowAll);
|
|
244
|
+
action(EDIT_ACTION, home.onHomeEdit);
|
|
245
|
+
action(DELETE_ACTION, home.onHomeDelete);
|
|
246
|
+
action(CLEAN_ACTION, home.onHomeClean);
|
|
247
|
+
action(HOME_UPDATE_ACTION, home.onHomeUpdate);
|
|
248
|
+
app.error(async (error) => {
|
|
249
|
+
logger.error(`handler failed: ${describe(thrown(error))}`);
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
app,
|
|
253
|
+
registered,
|
|
254
|
+
drafts: requests.drafts,
|
|
255
|
+
close: () => {
|
|
256
|
+
requests.close();
|
|
257
|
+
chat.halt();
|
|
258
|
+
clips.close();
|
|
259
|
+
},
|
|
260
|
+
openFile: (channel, threadTs, folder, words) => open.openWord(channel, threadTs, folder, words),
|
|
261
|
+
};
|
|
262
|
+
}
|