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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The Slack module: the chat registry's entry for Slack. `build` makes everything of the Slack
3
+ * side that does not need the core (the clients, the identity `auth.test` gives, the provider,
4
+ * the thread deleter, the Home tab, the channel guard, the uploads folder); what needs the core
5
+ * is read through `context.core()` when it is first used, which is after `build` returned.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { ConfigError, } from "../../core/port.js";
9
+ import { ChatError } from "../seam.js";
10
+ import { socketReceiver } from "./app/app.js";
11
+ import { ChannelGuard } from "./app/guards.js";
12
+ import { prepareUploads } from "./attachments.js";
13
+ import { ThreadDeleter } from "./delete.js";
14
+ import { Home } from "./home.js";
15
+ import { repliesClient, sharedClient } from "./reply/clients.js";
16
+ import { describe } from "./reply/errors.js";
17
+ import { UpdateLimiter } from "./reply/limiter.js";
18
+ import { SlackChat } from "./thread.js";
19
+ /**
20
+ * The daemon's two Slack clients on one token (the retry policy is in `reply/clients.ts`): the
21
+ * shared one for everything but a reply, and the one replies are written with.
22
+ */
23
+ export function makeClients(botToken, options = {}) {
24
+ return [sharedClient(botToken, options), repliesClient(botToken, options)];
25
+ }
26
+ export const CLIENTS = {
27
+ bot: (token, stopped) => makeClients(token, stopped === undefined ? {} : { stopped }),
28
+ owner: (token) => sharedClient(token),
29
+ };
30
+ /** The text of a field `auth.test` must answer, which a log line and an identity rely on. */
31
+ export function authField(answer, key) {
32
+ const value = answer[key];
33
+ if (typeof value !== "string" || value === "")
34
+ throw new Error(`auth.test answered no ${key}`);
35
+ return value;
36
+ }
37
+ /**
38
+ * What deletes a thread for the Home tab, when the owner's user token is configured. The token
39
+ * must be the owner's own, in the bot's workspace: it deletes as whoever it belongs to.
40
+ */
41
+ export async function deleter(userToken, slack, identity, state, sessions, names, clients = CLIENTS) {
42
+ if (userToken === null)
43
+ return null;
44
+ const owner = clients.owner(userToken);
45
+ const who = await owner.auth.test();
46
+ if (authField(who, "user_id") !== identity.ownerUserId ||
47
+ authField(who, "team_id") !== identity.teamId) {
48
+ throw new ConfigError("SLACK_USER_TOKEN must be the owner's own token, in the bot's workspace");
49
+ }
50
+ return new ThreadDeleter(slack, owner, {
51
+ botUserId: identity.botUserId,
52
+ ownerUserId: identity.ownerUserId,
53
+ state,
54
+ names,
55
+ release: (channelId, threadId) => sessions.release(channelId, threadId),
56
+ free: (channelId, threadId) => sessions.free(channelId, threadId),
57
+ });
58
+ }
59
+ /**
60
+ * The lock key of an app-level token: its SHA-256 in hex, so that the key says which app without
61
+ * the token ever leaving this module (Slack documents no call that maps an `xapp-` token to its
62
+ * app).
63
+ */
64
+ export function appTokenKey(appToken) {
65
+ return `app-token:${createHash("sha256").update(appToken).digest("hex")}`;
66
+ }
67
+ /** The variable `context` must hold for `name`: the daemon does not start without the required. */
68
+ function variable(context, name) {
69
+ const value = context.variables[name];
70
+ if (value === undefined)
71
+ throw new ConfigError(`${name} is not set`);
72
+ return value;
73
+ }
74
+ /** The shape of each token, checked before Slack is called: a swapped pair is told at once. */
75
+ function checkTokens(context) {
76
+ if (!variable(context, "SLACK_BOT_TOKEN").startsWith("xoxb-")) {
77
+ throw new ConfigError("SLACK_BOT_TOKEN must be the Bot User OAuth Token (xoxb-...)");
78
+ }
79
+ if (!variable(context, "SLACK_APP_TOKEN").startsWith("xapp-")) {
80
+ throw new ConfigError("SLACK_APP_TOKEN must be the app-level token (xapp-...)");
81
+ }
82
+ const userToken = context.variables.SLACK_USER_TOKEN;
83
+ if (userToken !== undefined && !userToken.startsWith("xoxp-")) {
84
+ throw new ConfigError("SLACK_USER_TOKEN must be the User OAuth Token (xoxp-...)");
85
+ }
86
+ }
87
+ /** The registry's entry for Slack. */
88
+ export function slackChat(options = {}) {
89
+ return {
90
+ required: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_OWNER_USER_ID"],
91
+ optional: ["SLACK_USER_TOKEN"],
92
+ async build(context) {
93
+ checkTokens(context);
94
+ try {
95
+ return await assemble(options, context);
96
+ }
97
+ catch (error) {
98
+ // The daemon logs a start that failed by the name of its error: Slack's code, as before.
99
+ if (error instanceof ConfigError || error instanceof ChatError)
100
+ throw error;
101
+ throw new ChatError(describe(error), { cause: error });
102
+ }
103
+ },
104
+ };
105
+ }
106
+ /** Everything of the Slack side that does not need the core. */
107
+ async function assemble(options, context) {
108
+ const botToken = variable(context, "SLACK_BOT_TOKEN");
109
+ const appToken = variable(context, "SLACK_APP_TOKEN");
110
+ const clients = options.clients ?? CLIENTS;
111
+ // The clients are built before the chat that stops them: its stop is passed on below.
112
+ const stopping = new AbortController();
113
+ const [slack, replySlack] = clients.bot(botToken, stopping.signal);
114
+ const auth = await slack.auth.test();
115
+ const identity = {
116
+ ownerUserId: variable(context, "SLACK_OWNER_USER_ID"),
117
+ teamId: authField(auth, "team_id"),
118
+ botUserId: authField(auth, "user_id"),
119
+ };
120
+ const limiter = new UpdateLimiter({ clock: context.steady });
121
+ // What the Slack side asks of the core is read here when it is asked: the core is built
122
+ // after this provider is, and `context.core()` is not ready until then.
123
+ const state = {
124
+ thread: (channelId, threadId) => context.core().state.thread(channelId, threadId),
125
+ channels: () => context.core().state.channels(),
126
+ threads: () => context.core().state.threads(),
127
+ removeThread: (channelId, threadId) => context.core().state.removeThread(channelId, threadId),
128
+ };
129
+ // The provider is made last, from these parts; they ask it for the names when they show a
130
+ // line, which is after the core exists.
131
+ const names = () => chat.names();
132
+ const threadDeleter = await deleter(context.variables.SLACK_USER_TOKEN ?? null, slack, identity, state, {
133
+ release: (channelId, threadId) => context.core().sessions.release(channelId, threadId),
134
+ free: (channelId, threadId) => context.core().sessions.free(channelId, threadId),
135
+ }, names, clients);
136
+ const home = new Home(slack, {
137
+ ownerUserId: identity.ownerUserId,
138
+ teamId: identity.teamId,
139
+ state,
140
+ names,
141
+ sessionsOf: (directory) => context.core().agent.listSessions(directory),
142
+ ...(threadDeleter !== null && {
143
+ delete: (channelId, threadId) => threadDeleter.delete(channelId, threadId),
144
+ clean: (channelId) => threadDeleter.clean(channelId),
145
+ }),
146
+ update: () => context.core().updates?.line() ?? null,
147
+ clock: context.clock,
148
+ });
149
+ const uploads = options.uploads ?? context.scratchDir;
150
+ await prepareUploads(uploads);
151
+ const chat = new SlackChat({
152
+ slack,
153
+ replies: replySlack,
154
+ identity,
155
+ limiter,
156
+ clock: context.clock,
157
+ steady: context.steady,
158
+ agent: () => context.core().agent,
159
+ app: {
160
+ core: () => context.core(),
161
+ config: { botToken, allowedRoot: context.allowedRoot },
162
+ guard: new ChannelGuard(slack, identity),
163
+ home,
164
+ uploads,
165
+ receiver: (options.receiver ?? socketReceiver)(appToken),
166
+ appKey: appTokenKey(appToken),
167
+ ...(options.fetch !== undefined && { fetch: options.fetch }),
168
+ ...(options.listings !== undefined && { listings: options.listings }),
169
+ ...(options.now !== undefined && { now: options.now }),
170
+ },
171
+ });
172
+ chat.stopped.addEventListener("abort", () => stopping.abort(), { once: true });
173
+ return chat;
174
+ }
@@ -0,0 +1,64 @@
1
+ import { Cancelled } from "../../core/port.js";
2
+ import { ChatError } from "../seam.js";
3
+ import { contextBlock, FALLBACK_LIMIT, noticeText } from "./reply/blocks.js";
4
+ import { take } from "./reply/chars.js";
5
+ import { describe } from "./reply/errors.js";
6
+ /**
7
+ * One message to `place`: a normal post or, with `ephemeral`, one only `ownerUserId` sees. A
8
+ * normal post pushes only inside a thread the owner started; an ephemeral message never does.
9
+ * `blocks` are built to Block Kit's reference by the modules that make them: opaque to the
10
+ * library's types. Returns the `ts` of a normal post, which `chat.delete` takes; null for an
11
+ * ephemeral message, which Slack lets nobody delete or update.
12
+ */
13
+ export async function deliver(slack, ownerUserId, place, text, blocks, options) {
14
+ const where = place.threadTs === null ? {} : { thread_ts: place.threadTs };
15
+ const shown = blocks;
16
+ if (options.ephemeral) {
17
+ await slack.chat.postEphemeral({
18
+ channel: place.channel,
19
+ user: ownerUserId,
20
+ text: take(text, FALLBACK_LIMIT),
21
+ blocks: shown,
22
+ ...where,
23
+ });
24
+ return null;
25
+ }
26
+ const posted = await slack.chat.postMessage({
27
+ channel: place.channel,
28
+ text: take(text, FALLBACK_LIMIT),
29
+ blocks: shown,
30
+ unfurl_links: false,
31
+ unfurl_media: false,
32
+ ...where,
33
+ });
34
+ return posted.ts ?? null;
35
+ }
36
+ /** One of the daemon's notices, small and grey as the footer; `text` is mrkdwn. */
37
+ export function deliverNotice(slack, ownerUserId, place, text, options) {
38
+ return deliver(slack, ownerUserId, place, text, [contextBlock(noticeText(text))], options);
39
+ }
40
+ /**
41
+ * `call`, with a failure crossing the seam as a `ChatError` named by Slack's code. A `Cancelled`
42
+ * is no failure of the chat: it crosses as it is.
43
+ */
44
+ export async function crossing(call) {
45
+ try {
46
+ return await call();
47
+ }
48
+ catch (error) {
49
+ if (error instanceof Cancelled)
50
+ throw error;
51
+ throw new ChatError(describe(error), { cause: error });
52
+ }
53
+ }
54
+ /**
55
+ * The message `ts` rewritten into a notice that says `text`, with nothing left to press: an edit
56
+ * (silent), against the app's `chat.update` budget, which every reply draws from. Rejects with a
57
+ * `ChatError` named by Slack's code.
58
+ */
59
+ export function rewriteAsNotice(slack, limiter, channel, ts, text) {
60
+ return crossing(async () => {
61
+ await limiter.acquire();
62
+ await slack.chat.update({ channel, ts, text, blocks: [contextBlock(noticeText(text))] });
63
+ });
64
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * The picker `!open` shows: the message with its button, the modal with its search field and its
3
+ * rows, what Slack sends back from it, and the order its updates are written in.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { fill, Semaphore } from "../../../core/port.js";
7
+ import { isRecord } from "../../../values.js";
8
+ import { contextBlock, plainTextObject } from "../reply/blocks.js";
9
+ import { len, take } from "../reply/chars.js";
10
+ import { shownAsWritten } from "../reply/escape.js";
11
+ import * as slackTexts from "../texts.js";
12
+ // The button that opens the picker's modal (in the `!open` message and in the one of several
13
+ // matches), the modal's callback id, and the ids of its two input blocks. Slack keeps the state
14
+ // of an input block whose ids do not change across `views.update` (reference, read 2026-10-05
15
+ // and 2026-10-06), so the search field keeps its ids, to keep what was typed, and the rows' block
16
+ // id, which is CHOICE_BLOCK and a mark of the rows it holds, changes with them, to drop a row
17
+ // chosen among others.
18
+ export const OPEN_BUTTON_ACTION = "open_choose";
19
+ export const OPEN_FORM = "open_form";
20
+ export const QUERY_BLOCK = "open_query_block";
21
+ export const QUERY_ACTION = "open_query";
22
+ export const CHOICE_BLOCK = "open_choice_block";
23
+ export const CHOICE_ACTION = "open_choice";
24
+ // Block Kit's limits, read 2026-10-05: a radio button group holds 10 options (radio button group
25
+ // element); an option's `text` and its `description` 75 characters, its `value` 150 (option
26
+ // object); a modal's `private_metadata` 3000 characters, its title, submit and close text 24, and
27
+ // it holds 100 blocks (modal views).
28
+ export const ROW_LIMIT = 10;
29
+ export const TEXT_LIMIT = 75;
30
+ export const DESCRIPTION_LIMIT = 75;
31
+ export const VALUE_LIMIT = 150;
32
+ // The words a search carries: typed in the field, or in a button's value from `!open <words>`.
33
+ export const QUERY_LIMIT = 200;
34
+ // A click waits until this long (seconds) after it arrived for the rows before the modal opens
35
+ // without them: the click's `trigger_id` lives 3 seconds (views.open reference, read 2026-10-05),
36
+ // which the channel check, this wait and `views.open` share, and the rows are put in by an update
37
+ // when they come later.
38
+ export const OPEN_WAIT = 1.0;
39
+ // MODALS_KEPT modals are tracked at most (`ModalUpdates`).
40
+ export const MODALS_KEPT = 16;
41
+ /** `text` cut in its middle with `…` to `room` characters, its end kept longer than its start so an extension stays. */
42
+ function middle(text, room) {
43
+ const characters = Array.from(text);
44
+ if (characters.length <= room)
45
+ return text;
46
+ const end = Math.floor((room - 1) / 2) + ((room - 1) % 2);
47
+ return `${characters.slice(0, room - 1 - end).join("")}…${characters.slice(characters.length - end).join("")}`;
48
+ }
49
+ /** Whether a path can be an option's value; the file stays reachable by `!open <path>`. */
50
+ export function fits(path) {
51
+ return len(path) <= VALUE_LIMIT;
52
+ }
53
+ /**
54
+ * A row for a path: the file's name as plain text, so that nothing in it is read as markup or as
55
+ * an emoji (shortened in its middle past TEXT_LIMIT), its folder below it (shortened from the left
56
+ * past DESCRIPTION_LIMIT, left out for a file at the root of the session's folder), the path
57
+ * itself as the value. Null when the value cannot fit.
58
+ */
59
+ export function option(path) {
60
+ if (!fits(path))
61
+ return null;
62
+ const slash = path.lastIndexOf("/");
63
+ const folder = slash < 0 ? "" : path.slice(0, slash);
64
+ const name = path.slice(slash + 1);
65
+ const row = {
66
+ text: plainTextObject(middle(name, TEXT_LIMIT), false),
67
+ value: path,
68
+ };
69
+ if (folder) {
70
+ const shown = len(folder) <= DESCRIPTION_LIMIT
71
+ ? folder
72
+ : `…${Array.from(folder)
73
+ .slice(-(DESCRIPTION_LIMIT - 1))
74
+ .join("")}`;
75
+ row.description = plainTextObject(shown);
76
+ }
77
+ return row;
78
+ }
79
+ /** The first ROW_LIMIT paths that make a row. */
80
+ export function options(paths) {
81
+ const found = [];
82
+ for (const path of paths) {
83
+ if (found.length >= ROW_LIMIT)
84
+ break;
85
+ const shown = option(path);
86
+ if (shown !== null)
87
+ found.push(shown);
88
+ }
89
+ return found;
90
+ }
91
+ /** Metadata that `Target.dump` did not write. */
92
+ export class BadTarget extends Error {
93
+ constructor(reason) {
94
+ super(reason);
95
+ this.name = "BadTarget";
96
+ }
97
+ }
98
+ /**
99
+ * The thread a modal belongs to, carried by the modal itself (`private_metadata`). It comes back
100
+ * from Slack and is untrusted: the handlers resolve it to the folder of that thread's own session
101
+ * and refuse anything else.
102
+ */
103
+ export class Target {
104
+ channel;
105
+ threadTs;
106
+ constructor(channel, threadTs) {
107
+ this.channel = channel;
108
+ this.threadTs = threadTs;
109
+ }
110
+ dump() {
111
+ return JSON.stringify({ c: this.channel, t: this.threadTs });
112
+ }
113
+ /** Throws `BadTarget` for anything `dump` did not write. */
114
+ static load(text) {
115
+ if (typeof text !== "string")
116
+ throw new BadTarget("no metadata");
117
+ let data;
118
+ try {
119
+ data = JSON.parse(text);
120
+ }
121
+ catch {
122
+ throw new BadTarget("not json");
123
+ }
124
+ if (!isRecord(data))
125
+ throw new BadTarget("not an object");
126
+ const { c: channel, t: threadTs } = data;
127
+ if (typeof channel !== "string" || typeof threadTs !== "string") {
128
+ throw new BadTarget("no thread");
129
+ }
130
+ return new Target(channel, threadTs);
131
+ }
132
+ }
133
+ function own(record, key) {
134
+ return Object.hasOwn(record, key) ? record[key] : undefined;
135
+ }
136
+ /** The state of one element in a view's `state.values` (block id, then action id), `{}` for any shape Slack does not send. */
137
+ function field(values, block, action) {
138
+ const found = isRecord(values) ? own(values, block) : undefined;
139
+ const element = isRecord(found) ? own(found, action) : undefined;
140
+ return isRecord(element) ? element : {};
141
+ }
142
+ /** The text in the search field, from a view's `state.values`; `""` when there is none. */
143
+ export function typedIn(values) {
144
+ const value = field(values, QUERY_BLOCK, QUERY_ACTION).value;
145
+ return typeof value === "string" ? value : "";
146
+ }
147
+ /** The block of rows in a view's own `blocks`; null when the view shows none. */
148
+ export function choiceBlock(view) {
149
+ const blocks = isRecord(view) ? view.blocks : undefined;
150
+ for (const block of Array.isArray(blocks) ? blocks : []) {
151
+ if (isRecord(block) &&
152
+ typeof block.block_id === "string" &&
153
+ block.block_id.startsWith(`${CHOICE_BLOCK}:`)) {
154
+ return block;
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+ /**
160
+ * The value of the row chosen in a view as Slack sends it back (`blocks` and `state.values`), null
161
+ * when none is. Only a row of the block the view shows counts: the state of a block with an id
162
+ * that is gone, or a value that is not among its options, is a choice made among other rows.
163
+ */
164
+ export function chosenIn(view) {
165
+ const block = choiceBlock(view);
166
+ if (!isRecord(view) || block === null)
167
+ return null;
168
+ const state = view.state;
169
+ const values = isRecord(state) ? state.values : undefined;
170
+ const chosen = field(values, block.block_id, CHOICE_ACTION).selected_option;
171
+ const value = isRecord(chosen) ? chosen.value : undefined;
172
+ const element = block.element;
173
+ const shown = isRecord(element) ? element.options : undefined;
174
+ const offered = new Set(Array.isArray(shown) ? shown.filter(isRecord).map((offer) => offer.value) : []);
175
+ return typeof value === "string" && offered.has(value) ? value : null;
176
+ }
177
+ function chooseButton(words) {
178
+ return {
179
+ type: "actions",
180
+ elements: [
181
+ {
182
+ type: "button",
183
+ action_id: OPEN_BUTTON_ACTION,
184
+ text: plainTextObject(slackTexts.OPEN_BUTTON),
185
+ ...(words ? { value: take(words, QUERY_LIMIT) } : {}),
186
+ },
187
+ ],
188
+ };
189
+ }
190
+ /** What `!open` alone posts: the button that opens the modal, and the way to open by name. */
191
+ export function pickerBlocks() {
192
+ return [
193
+ { type: "section", text: { type: "mrkdwn", text: slackTexts.OPEN_TITLE } },
194
+ chooseButton(""),
195
+ contextBlock(slackTexts.OPEN_BY_NAME),
196
+ ];
197
+ }
198
+ /**
199
+ * Files match `words`: how many, and the button that opens the modal with `words` in its field;
200
+ * or, when no path is short enough to be a row, the line that says so. With `complete` false the
201
+ * line that says the folder could not be listed in full comes last.
202
+ */
203
+ export function matchesBlocks(words, found, complete = true) {
204
+ const heading = {
205
+ type: "section",
206
+ text: {
207
+ type: "mrkdwn",
208
+ text: fill(found.length === 1 ? slackTexts.OPEN_MATCHES_ONE : slackTexts.OPEN_MATCHES, {
209
+ count: found.length,
210
+ words: shownAsWritten(words),
211
+ }),
212
+ },
213
+ };
214
+ const blocks = [
215
+ heading,
216
+ found.some(fits) ? chooseButton(words) : contextBlock(slackTexts.OPEN_MATCHES_TOO_LONG),
217
+ ];
218
+ return complete ? blocks : [...blocks, contextBlock(slackTexts.OPEN_PARTIAL)];
219
+ }
220
+ /** What the block of these rows is told apart by: the same rows, the same mark. */
221
+ function mark(rows) {
222
+ const values = rows.map((row) => row.value).join("\0");
223
+ return createHash("sha256").update(values, "utf8").digest("hex").slice(0, 8);
224
+ }
225
+ /**
226
+ * The picker's modal for `target`: the search field holding `words`, and one row for each of the
227
+ * first ROW_LIMIT of `paths`, which are the session's changed files (newest first) while `words`
228
+ * is empty and the files matching it otherwise; null while they are still being listed. `opening`
229
+ * is the view `views.open` takes: it alone sets the field's initial value and focus, since an
230
+ * update keeps what was typed through the field's ids and must not restate it.
231
+ */
232
+ export function modalView(target, words, paths, { count, complete = true, opening = false } = {}) {
233
+ const query = {
234
+ type: "plain_text_input",
235
+ action_id: QUERY_ACTION,
236
+ max_length: QUERY_LIMIT,
237
+ placeholder: plainTextObject(slackTexts.OPEN_QUERY_HINT),
238
+ dispatch_action_config: { trigger_actions_on: ["on_character_entered"] },
239
+ ...(opening ? { focus_on_load: true, ...(words ? { initial_value: words } : {}) } : {}),
240
+ };
241
+ const blocks = [
242
+ {
243
+ type: "input",
244
+ block_id: QUERY_BLOCK,
245
+ dispatch_action: true,
246
+ optional: true,
247
+ label: plainTextObject(slackTexts.OPEN_QUERY_LABEL),
248
+ element: query,
249
+ },
250
+ ];
251
+ if (paths === null) {
252
+ blocks.push(contextBlock(slackTexts.OPEN_LOADING));
253
+ }
254
+ else {
255
+ const rows = options(paths);
256
+ const total = count ?? paths.length;
257
+ let heading;
258
+ if (words) {
259
+ heading = fill(total === 1 ? slackTexts.OPEN_ROWS_MATCH_ONE : slackTexts.OPEN_ROWS_MATCH, {
260
+ count: total,
261
+ });
262
+ }
263
+ else if (total) {
264
+ heading = fill(slackTexts.OPEN_ROWS_CHANGED, { count: total });
265
+ }
266
+ else {
267
+ heading = slackTexts.OPEN_TYPE_A_NAME;
268
+ }
269
+ if (rows.length > 0) {
270
+ blocks.push({
271
+ type: "input",
272
+ block_id: `${CHOICE_BLOCK}:${mark(rows)}`,
273
+ optional: true,
274
+ label: plainTextObject(heading),
275
+ element: { type: "radio_buttons", action_id: CHOICE_ACTION, options: rows },
276
+ });
277
+ }
278
+ else {
279
+ blocks.push(contextBlock(heading));
280
+ }
281
+ if (total > rows.length) {
282
+ blocks.push(contextBlock(rows.length > 0
283
+ ? fill(slackTexts.OPEN_MATCHES_CAPPED, { shown: rows.length, count: total })
284
+ : slackTexts.OPEN_MATCHES_TOO_LONG));
285
+ }
286
+ if (!complete)
287
+ blocks.push(contextBlock(slackTexts.OPEN_PARTIAL));
288
+ }
289
+ return {
290
+ type: "modal",
291
+ callback_id: OPEN_FORM,
292
+ private_metadata: target.dump(),
293
+ title: plainTextObject(slackTexts.OPEN_MODAL_TITLE),
294
+ submit: plainTextObject(slackTexts.OPEN_MODAL_SUBMIT),
295
+ close: plainTextObject(slackTexts.OPEN_MODAL_CLOSE),
296
+ blocks,
297
+ };
298
+ }
299
+ /**
300
+ * Which update of an open modal may still be written, so that an older one never overwrites a
301
+ * newer one. Each keystroke reaches the daemon as its own `block_actions` event, handled on its
302
+ * own, and answers can finish in any order. The daemon is the only writer of its modals, so an
303
+ * update carries no `hash` (optional in `views.update`, reference read 2026-10-06): what orders
304
+ * the updates is a `key` (the event's `action_ts`; 0 for the first fill), and:
305
+ *
306
+ * - `claim` refuses a key that is not newer than one already taken, and any key of a view that was
307
+ * forgotten;
308
+ * - `lock` lets one update of a view run at a time, and `current` says, once its turn has come and
309
+ * again before it writes, whether it is still the newest;
310
+ * - `forget` drops a view whose modal was submitted, for good: nothing of it is tracked again.
311
+ *
312
+ * At most `limit` modals are tracked, the oldest dropped first (it is tracked again by its next
313
+ * update), and at most `limit` forgotten ones are remembered.
314
+ */
315
+ export class ModalUpdates {
316
+ #limit;
317
+ #newest = new Map();
318
+ #locks = new Map();
319
+ #gone = new Set();
320
+ constructor(limit = MODALS_KEPT) {
321
+ this.#limit = limit;
322
+ }
323
+ /** Takes `key` as the newest of the view; false when one as new was taken already. */
324
+ claim(viewId, key) {
325
+ if (this.#gone.has(viewId) || key <= (this.#newest.get(viewId) ?? Number.NEGATIVE_INFINITY)) {
326
+ return false;
327
+ }
328
+ this.#newest.delete(viewId);
329
+ this.#newest.set(viewId, key);
330
+ while (this.#newest.size > this.#limit) {
331
+ this.#drop(this.#newest.keys().next().value);
332
+ }
333
+ return true;
334
+ }
335
+ /** Whether `key` is still the newest taken for the view. */
336
+ current(viewId, key) {
337
+ return this.#newest.get(viewId) === key;
338
+ }
339
+ /** The view's lock, one holder at a time; a forgotten view gets a lock that is kept nowhere. */
340
+ lock(viewId) {
341
+ if (this.#gone.has(viewId))
342
+ return new Semaphore(1);
343
+ let held = this.#locks.get(viewId);
344
+ if (held === undefined) {
345
+ held = new Semaphore(1);
346
+ this.#locks.set(viewId, held);
347
+ }
348
+ return held;
349
+ }
350
+ /** The view's modal was submitted: nothing of it is tracked again. */
351
+ forget(viewId) {
352
+ this.#drop(viewId);
353
+ this.#gone.add(viewId);
354
+ while (this.#gone.size > this.#limit) {
355
+ this.#gone.delete(this.#gone.values().next().value);
356
+ }
357
+ }
358
+ #drop(viewId) {
359
+ this.#newest.delete(viewId);
360
+ this.#locks.delete(viewId);
361
+ }
362
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What `!open` hands to Slack: the bytes the core read, shared into the thread. Which path may be
3
+ * read is the core's (`src/core/files/read.ts`).
4
+ */
5
+ import { posix } from "node:path";
6
+ import { titleOf } from "../../../core/port.js";
7
+ // Slack's documented limit for a snippet: `snippet_too_large` on `files.getUploadURLExternal`
8
+ // (docs.slack.dev/reference/methods/files.getUploadURLExternal, read 2026-10-05).
9
+ export const SNIPPET_LIMIT = 1 << 20;
10
+ /**
11
+ * Shares `content`, the bytes `readOpenable` read from `relative`, into the thread: the basename as
12
+ * the file's name and the path from the folder as its title, nothing posted on success. Rejects
13
+ * with Slack's error.
14
+ *
15
+ * `@slack/web-api` 8.2.0 types `content` as a string (`FileUploadStringContents`,
16
+ * `types/request/files.d.ts`) and encodes it as UTF-8, which would damage a binary file; its
17
+ * `file` field takes a `Buffer` as the bytes (a `string` there is a path read from disk,
18
+ * `dist/file-upload.js` `getFileData`). A Buffer is what is passed.
19
+ */
20
+ export async function uploadFile(client, channel, threadTs, content, relative) {
21
+ const title = titleOf(relative);
22
+ await client.files.uploadV2({
23
+ channel_id: channel,
24
+ thread_ts: threadTs,
25
+ file: content,
26
+ filename: posix.basename(title),
27
+ title,
28
+ });
29
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The loggers the Slack libraries are given, so that none of their text reaches the log.
3
+ *
4
+ * Read in `@slack/web-api` 8.2.0, `@slack/socket-mode` 3.1.0 and `@slack/bolt` 5.1.0 (`dist/`):
5
+ * with no `logger` option each builds a console logger of its own, which writes to standard
6
+ * output and standard error, outside `src/log.ts`. Their lines carry text the daemon withholds
7
+ * elsewhere (`describeRefusal` keeps Slack's sentence out because it can quote a value of the
8
+ * message): every `[ERROR]` and `[WARN]` entry of a refusal's `response_metadata.messages`, the
9
+ * failure message of a request, `${error}` of an error Bolt's receiver reports. At the debug
10
+ * level they also write each request body and each result, which hold the message text and, for
11
+ * `apps.connections.open`, the WebSocket URL with its ticket; Bolt's `processEvent` does the
12
+ * same with the whole event, in developer mode only.
13
+ *
14
+ * A custom `logger` replaces the library's own and its `logLevel` is then ignored, so the
15
+ * debug text is never forwarded here either. A failure the libraries report this way also
16
+ * reaches the code that called them, as a typed error the daemon logs by its name
17
+ * (`describe` in `reply/errors.ts`), or, for a Socket Mode connection that cannot come back, as
18
+ * the unhandled rejection that stops the daemon (`main.ts`).
19
+ */
20
+ import { LogLevel } from "@slack/web-api";
21
+ import { getLogger } from "../../log.js";
22
+ export const logger = getLogger("allaboard.chat.slack.libraries");
23
+ function quiet(write) {
24
+ return {
25
+ debug: () => { },
26
+ info: () => { },
27
+ warn: () => write("warning"),
28
+ error: () => write("error"),
29
+ setLevel: () => { },
30
+ // Warn: a library that asks builds no debug string (Socket Mode's `ack` JSON-encodes its
31
+ // answer only at the debug level).
32
+ getLevel: () => LogLevel.WARN,
33
+ setName: () => { },
34
+ };
35
+ }
36
+ /** Writes nothing, at any level: the web clients, whose failures come back as typed errors. */
37
+ export function silentLogger() {
38
+ return quiet(() => { });
39
+ }
40
+ /**
41
+ * Writes one line per warning or error the library reports, naming the library and the level and
42
+ * nothing the library said: Bolt's app and its Socket Mode receiver, whose lines name no method
43
+ * and no id the daemon could add to.
44
+ */
45
+ export function levelLogger(library) {
46
+ return quiet((level) => {
47
+ if (level === "error")
48
+ logger.error(`${library} reported an error`);
49
+ else
50
+ logger.warning(`${library} reported a warning`);
51
+ });
52
+ }