allaboard 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/agent/claude/backend.js +73 -2
- package/dist/agent/claude/capabilities.js +4 -0
- package/dist/agent/claude/module.js +55 -0
- package/dist/agent/claude/translate.js +6 -2
- package/dist/agent/claude/trust.js +22 -0
- package/dist/chat/seam.js +3 -0
- package/dist/chat/slack/app/app.js +52 -4
- package/dist/chat/slack/app/delete-thread.js +90 -0
- package/dist/chat/slack/app/guards.js +44 -0
- package/dist/chat/slack/app/home-controls.js +8 -1
- package/dist/chat/slack/channels.js +23 -5
- package/dist/chat/slack/delete-thread.js +37 -0
- package/dist/chat/slack/delete.js +13 -7
- package/dist/chat/slack/home.js +194 -35
- package/dist/chat/slack/module.js +64 -14
- package/dist/chat/slack/onboarding.js +159 -0
- package/dist/chat/slack/reply/blocks.js +27 -11
- package/dist/chat/slack/reply/sinks.js +3 -1
- package/dist/chat/slack/tag.js +317 -0
- package/dist/chat/slack/texts.js +8 -0
- package/dist/chat/slack/thread.js +62 -2
- package/dist/chat/slack/tokens.js +15 -0
- package/dist/cli.js +43 -4
- package/dist/core/cleanup.js +66 -3
- package/dist/core/commands.js +40 -0
- package/dist/core/config.js +19 -11
- package/dist/core/files/listing.js +19 -0
- package/dist/core/flows/bind.js +29 -6
- package/dist/core/flows/flows.js +2 -0
- package/dist/core/flows/gone.js +39 -0
- package/dist/core/flows/inbound.js +3 -0
- package/dist/core/flows/messages.js +4 -2
- package/dist/core/flows/status.js +98 -48
- package/dist/core/flows/words.js +80 -1
- package/dist/core/guide.js +2 -0
- package/dist/core/port/cleanup.js +1 -0
- package/dist/core/port.js +1 -0
- package/dist/core/sessions/directory.js +47 -8
- package/dist/core/sessions/manager.js +32 -17
- package/dist/core/sessions/quote.js +57 -0
- package/dist/core/sessions/session.js +324 -35
- package/dist/core/sessions/title.js +98 -0
- package/dist/core/state.js +25 -0
- package/dist/core/texts.js +58 -3
- package/dist/core/wizard.js +306 -0
- package/dist/daemon.js +23 -11
- package/dist/host/program.js +22 -7
- package/dist/host/prompter.js +61 -0
- package/dist/module-setup.js +6 -0
- package/dist/version.js +4 -0
- package/package.json +5 -2
- package/slack-app-manifest.json +2 -1
package/README.md
CHANGED
|
@@ -14,18 +14,20 @@ Claude Code keeps running on each Mac, with its own files and its own setup.
|
|
|
14
14
|
npm install -g allaboard
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The [setup guide](https://github.com/duqaXxX/allaboard/blob/v0.
|
|
17
|
+
The [setup guide](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md) says which
|
|
18
18
|
systems this version supports, and takes you through
|
|
19
|
-
[creating the Slack app](https://github.com/duqaXxX/allaboard/blob/v0.
|
|
20
|
-
from [`slack-app-manifest.json`](https://github.com/duqaXxX/allaboard/blob/v0.
|
|
21
|
-
the settings, and the service that starts allaboard at login
|
|
19
|
+
[creating the Slack app](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md#part-1-the-slack-app)
|
|
20
|
+
from [`slack-app-manifest.json`](https://github.com/duqaXxX/allaboard/blob/v0.6.0/slack-app-manifest.json),
|
|
21
|
+
the settings, and the service that starts allaboard at login. `allaboard setup` asks for the
|
|
22
|
+
tokens, checks each with Slack, writes the configuration and offers to install the service
|
|
23
|
+
(`allaboard service install` does that later):
|
|
22
24
|
|
|
23
25
|
```sh
|
|
24
|
-
allaboard
|
|
26
|
+
allaboard setup
|
|
25
27
|
```
|
|
26
28
|
|
|
27
29
|
To bring another Mac or another Claude Code account into the same Slack, see
|
|
28
|
-
[the guide's Part 6](https://github.com/duqaXxX/allaboard/blob/v0.
|
|
30
|
+
[the guide's Part 6](https://github.com/duqaXxX/allaboard/blob/v0.6.0/docs/setup.md#part-6-a-second-instance).
|
|
29
31
|
|
|
30
32
|
## Update
|
|
31
33
|
|
|
@@ -40,5 +42,5 @@ allaboard update
|
|
|
40
42
|
## More
|
|
41
43
|
|
|
42
44
|
- Documentation and source: [github.com/duqaXxX/allaboard](https://github.com/duqaXxX/allaboard)
|
|
43
|
-
- Security model: [SECURITY.md](https://github.com/duqaXxX/allaboard/blob/v0.
|
|
45
|
+
- Security model: [SECURITY.md](https://github.com/duqaXxX/allaboard/blob/v0.6.0/SECURITY.md)
|
|
44
46
|
- License: MIT
|
|
@@ -4,18 +4,53 @@
|
|
|
4
4
|
* session's start is the core's (`folderTrusted` is its question to ask), not this class's.
|
|
5
5
|
*/
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
+
import { getSessionInfo as sdkGetSessionInfo, tagSession as sdkTagSession, } from "@anthropic-ai/claude-agent-sdk";
|
|
7
8
|
import { shellQuote } from "../../text.js";
|
|
8
9
|
import { CAPABILITIES } from "./capabilities.js";
|
|
9
10
|
import { chromeEnabled } from "./chrome.js";
|
|
10
11
|
import { aliveSessions, byLastActivity, directorySessions, projectsDir } from "./listing.js";
|
|
11
12
|
import { ClaudeSession } from "./session.js";
|
|
12
|
-
import { trustedRepository, workspaceTrusted } from "./trust.js";
|
|
13
|
+
import { trustedRepository, untrustedRepository, workspaceTrusted } from "./trust.js";
|
|
13
14
|
import { UsageProbe } from "./usage-probe.js";
|
|
14
15
|
export const NAME = "Claude Code";
|
|
15
16
|
/** The shell line that forks a session in a terminal; `environment` is what precedes `claude`. */
|
|
16
17
|
function terminalWith(environment) {
|
|
17
18
|
return `cd {directory} && ${environment}claude --resume {session} --fork-session`;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The shell line that opens Claude Code in a folder, whose trust dialog records the folder for
|
|
22
|
+
* the login `environment` names (`CLAUDE_CONFIG_DIR`): another login's record does not count.
|
|
23
|
+
*/
|
|
24
|
+
function trustWith(environment) {
|
|
25
|
+
return `cd {directory} && ${environment}claude`;
|
|
26
|
+
}
|
|
27
|
+
// The commands `docs/limits.md` lists as not offered, which a session of the SDK refuses with
|
|
28
|
+
// `/<name> isn't available in this environment.` (measured on Claude Code 2.1.292 on 2026-10-09
|
|
29
|
+
// and 2.1.303 on 2026-10-20; claim P23 sends each to every release). `test/docs.test.ts` holds
|
|
30
|
+
// the two lists to the same names. What follows a name is what the page says to do instead.
|
|
31
|
+
const SETTINGS_APPLY = "The settings you made in the terminal apply: change them there.";
|
|
32
|
+
const BOUND_FOLDER = "A thread works in the folder its channel was bound to.";
|
|
33
|
+
const NOT_OFFERED = Object.freeze({
|
|
34
|
+
rewind: null,
|
|
35
|
+
plan: null,
|
|
36
|
+
branch: null,
|
|
37
|
+
fork: null,
|
|
38
|
+
subtask: null,
|
|
39
|
+
btw: null,
|
|
40
|
+
background: null,
|
|
41
|
+
cd: BOUND_FOLDER,
|
|
42
|
+
"add-dir": BOUND_FOLDER,
|
|
43
|
+
diff: "Ask Claude for the diff.",
|
|
44
|
+
export: "Copy the reply from its thread.",
|
|
45
|
+
copy: "Copy the reply from its thread.",
|
|
46
|
+
permissions: SETTINGS_APPLY,
|
|
47
|
+
memory: SETTINGS_APPLY,
|
|
48
|
+
hooks: SETTINGS_APPLY,
|
|
49
|
+
skills: SETTINGS_APPLY,
|
|
50
|
+
tasks: SETTINGS_APPLY,
|
|
51
|
+
sandbox: SETTINGS_APPLY,
|
|
52
|
+
voice: "Send an audio clip instead.",
|
|
53
|
+
});
|
|
19
54
|
export const WORDS = Object.freeze({
|
|
20
55
|
author: "Claude",
|
|
21
56
|
command: "claude",
|
|
@@ -27,6 +62,8 @@ export const WORDS = Object.freeze({
|
|
|
27
62
|
login: "login",
|
|
28
63
|
logout: "logout",
|
|
29
64
|
terminal: terminalWith(""),
|
|
65
|
+
trust: trustWith(""),
|
|
66
|
+
notOffered: NOT_OFFERED,
|
|
30
67
|
});
|
|
31
68
|
/**
|
|
32
69
|
* `WORDS` for a back end whose sessions use the login in `configDir`: the line that continues a
|
|
@@ -38,7 +75,12 @@ function wordsFor(configDir) {
|
|
|
38
75
|
if (configDir === undefined)
|
|
39
76
|
return WORDS;
|
|
40
77
|
const folder = shellQuote(configDir).replaceAll("{", `'"{"'`);
|
|
41
|
-
|
|
78
|
+
const environment = `CLAUDE_CONFIG_DIR=${folder} `;
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
...WORDS,
|
|
81
|
+
terminal: terminalWith(environment),
|
|
82
|
+
trust: trustWith(environment),
|
|
83
|
+
});
|
|
42
84
|
}
|
|
43
85
|
export class ClaudeBackend {
|
|
44
86
|
name = NAME;
|
|
@@ -113,10 +155,39 @@ export class ClaudeBackend {
|
|
|
113
155
|
trustedRepository(folder) {
|
|
114
156
|
return trustedRepository(folder, this.#home, this.#configDir);
|
|
115
157
|
}
|
|
158
|
+
untrustedRepository(folder) {
|
|
159
|
+
return untrustedRepository(folder, this.#home, this.#configDir);
|
|
160
|
+
}
|
|
116
161
|
/** The limits `/usage` reports now, read by a session of the back end's own. */
|
|
117
162
|
usageLimits() {
|
|
118
163
|
return this.#probe.read();
|
|
119
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Sets or clears (`tag` null) the tag of a session of `folder`, in its transcript: the SDK
|
|
167
|
+
* appends a `tag` entry, an empty one to clear, and the last entry wins. Like the SDK's
|
|
168
|
+
* listing it finds the transcript through `CLAUDE_CONFIG_DIR` in the daemon's environment,
|
|
169
|
+
* which `module.claudeAgent` writes from the same folder it gives this back end as `configDir`
|
|
170
|
+
* (an instance with a login of its own); with none set, the SDK uses the default login's.
|
|
171
|
+
* Measured on 2026-10-19 (SDK 0.3.302, CLI 2.1.302): setting, clearing and changing the tag
|
|
172
|
+
* of a session whose query is alive holds through later turns and the close; the tag reaches
|
|
173
|
+
* none of the 8 request bodies captured, so the model never sees it; the terminal's
|
|
174
|
+
* `/resume` picker prints it as `#name`.
|
|
175
|
+
*/
|
|
176
|
+
tagSession(folder, session, tag) {
|
|
177
|
+
return sdkTagSession(session, tag, { dir: folder });
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The title Claude Code shows for a session of `folder`: `customTitle`, which holds the one
|
|
181
|
+
* Claude Code generated and the owner's `/rename` alike (the declaration says "/rename only";
|
|
182
|
+
* measured on 2026-10-20, SDK 0.3.302, CLI 2.1.302: a generated title is there at the `result`
|
|
183
|
+
* of the turn that wrote it, usually the first, sometimes the third or later). `summary` is not
|
|
184
|
+
* read: it falls back to the first prompt while there is no title. Reads the session's file
|
|
185
|
+
* through `getSessionInfo` alone, since it runs after every turn.
|
|
186
|
+
*/
|
|
187
|
+
async sessionTitle(folder, session) {
|
|
188
|
+
const info = await sdkGetSessionInfo(session, { dir: folder });
|
|
189
|
+
return info?.customTitle?.trim() || null;
|
|
190
|
+
}
|
|
120
191
|
/** Stops the usage probe: its session is closed and no read starts another. */
|
|
121
192
|
close() {
|
|
122
193
|
return this.#probe.stop();
|
|
@@ -12,6 +12,15 @@ import { ClaudeBackend } from "./backend.js";
|
|
|
12
12
|
// Claude Code account (environment variables page, "useful for running multiple accounts side by
|
|
13
13
|
// side", read 2026-10-17); it is handled below, since the daemon reads the folder too.
|
|
14
14
|
export const VARIABLES = ["BASH_DEFAULT_TIMEOUT_MS", "CLAUDE_CONFIG_DIR"];
|
|
15
|
+
const MS_PER_HOUR = 3_600_000;
|
|
16
|
+
const DEFAULT_TIMEOUT_HOURS = 4;
|
|
17
|
+
// The largest delay a Node timer takes (2^31 - 1 ms); Claude Code's own limit on the variable.
|
|
18
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
19
|
+
// The shortest timeout the wizard takes: 1 minute, stated as 0.02 hours (72000 ms).
|
|
20
|
+
const MIN_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MIN_TIMEOUT_HOURS = 0.02;
|
|
22
|
+
// One decimal below the cap (596.5 hours is 2147400000 ms), so the stated bound is accepted.
|
|
23
|
+
const MAX_TIMEOUT_HOURS = 596.5;
|
|
15
24
|
/**
|
|
16
25
|
* The declared variables the owner's file sets, each read by its name: the daemon hands a module
|
|
17
26
|
* its variables readable by lookup and shown by nothing, so a spread of them copies none.
|
|
@@ -48,6 +57,50 @@ function configDirOf(written, home) {
|
|
|
48
57
|
}
|
|
49
58
|
return expanded;
|
|
50
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The two questions of `allaboard setup`: the timeout, which Enter sets to 4 hours, and the folder, which Enter skips. The folder is not
|
|
62
|
+
* required to exist: a second login creates it. The same rule the daemon applies at build.
|
|
63
|
+
*/
|
|
64
|
+
export function claudeSetup(home) {
|
|
65
|
+
return {
|
|
66
|
+
intro: [],
|
|
67
|
+
questions: [
|
|
68
|
+
{
|
|
69
|
+
variable: "BASH_DEFAULT_TIMEOUT_MS",
|
|
70
|
+
message: `Bash command timeout in hours (BASH_DEFAULT_TIMEOUT_MS, Enter for ${DEFAULT_TIMEOUT_HOURS}):`,
|
|
71
|
+
secret: false,
|
|
72
|
+
optional: false,
|
|
73
|
+
default: String(DEFAULT_TIMEOUT_HOURS),
|
|
74
|
+
check: async (answer) => {
|
|
75
|
+
const hours = /^\d+(\.\d+)?$/.test(answer) ? Number(answer) : Number.NaN;
|
|
76
|
+
const ms = Math.round(hours * MS_PER_HOUR);
|
|
77
|
+
return ms >= MIN_TIMEOUT_MS && ms <= MAX_TIMER_MS
|
|
78
|
+
? { value: String(ms), note: `${ms} ms` }
|
|
79
|
+
: {
|
|
80
|
+
refusal: `A timeout is a number of hours from ${MIN_TIMEOUT_HOURS} to ${MAX_TIMEOUT_HOURS}, such as 0.5 or 4.`,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
variable: "CLAUDE_CONFIG_DIR",
|
|
86
|
+
message: "Claude Code configuration folder (CLAUDE_CONFIG_DIR, optional, Enter for ~/.claude):",
|
|
87
|
+
secret: false,
|
|
88
|
+
optional: true,
|
|
89
|
+
check: async (answer) => {
|
|
90
|
+
try {
|
|
91
|
+
configDirOf(answer, home);
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof AgentConfigError)
|
|
96
|
+
return { refusal: error.message };
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
51
104
|
/**
|
|
52
105
|
* The entry that builds the Claude back end. `options` are what a test replaces: the SDK's
|
|
53
106
|
* query, the owner's home, the usage probe's start.
|
|
@@ -56,6 +109,8 @@ export function claudeAgent(options = {}) {
|
|
|
56
109
|
return {
|
|
57
110
|
required: [],
|
|
58
111
|
optional: VARIABLES,
|
|
112
|
+
executable: "claude",
|
|
113
|
+
setup: () => claudeSetup(options.home ?? homedir()),
|
|
59
114
|
build: (context) => {
|
|
60
115
|
const variables = set(context.variables);
|
|
61
116
|
const configDir = configDirOf(variables.CLAUDE_CONFIG_DIR, options.home ?? homedir());
|
|
@@ -416,7 +416,11 @@ function taskNotification(memory, record) {
|
|
|
416
416
|
return [];
|
|
417
417
|
const usage = isRecord(record.usage) ? record.usage : {};
|
|
418
418
|
// The replay of a folded notification names the run, which the task's start recorded: a task
|
|
419
|
-
// whose run this process never saw cannot be named (`SDKTaskStartedMessage.run_id`).
|
|
419
|
+
// whose run this process never saw cannot be named (`SDKTaskStartedMessage.run_id`). Nor can a
|
|
420
|
+
// stopped one, which gets no notification of its own (`SDKMessageOrigin.runId`): a Monitor
|
|
421
|
+
// past its deadline reaches the turn as one of its lines, with no run (recorded 2026-10-20,
|
|
422
|
+
// `monitor-expired-in-turn`, issue #392), and no stopped task of the other recordings
|
|
423
|
+
// (`interrupt`, `stop-close-resume`, `background-forms`) has its end replayed with its run.
|
|
420
424
|
const runId = string(record.run_id);
|
|
421
425
|
return [
|
|
422
426
|
{
|
|
@@ -426,7 +430,7 @@ function taskNotification(memory, record) {
|
|
|
426
430
|
summary,
|
|
427
431
|
durationMs: number(usage.duration_ms),
|
|
428
432
|
callId: words(record.tool_use_id),
|
|
429
|
-
foldNamed: runId !== null && memory.runs.has(runId),
|
|
433
|
+
foldNamed: reported !== "stopped" && runId !== null && memory.runs.has(runId),
|
|
430
434
|
},
|
|
431
435
|
];
|
|
432
436
|
}
|
|
@@ -539,6 +539,28 @@ export async function trustedRepository(directory, home = homedir(), configDir)
|
|
|
539
539
|
}
|
|
540
540
|
return (await isTrusted(repository.key, home, configDir)) ? repository : null;
|
|
541
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* The repository holding `directory` when the owner has not trusted its key in Claude Code: the
|
|
544
|
+
* one `trustedRepository` refuses for that alone. Null with no repository, with a trusted one,
|
|
545
|
+
* and where the layout has no key, since no trust the dialog gives would cover that. Nothing
|
|
546
|
+
* here runs git.
|
|
547
|
+
*/
|
|
548
|
+
export async function untrustedRepository(directory, home = homedir(), configDir) {
|
|
549
|
+
let repository;
|
|
550
|
+
try {
|
|
551
|
+
repository = await locate(directory);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error instanceof Unkeyed) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
if (repository === null) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
return (await isTrusted(repository.key, home, configDir)) ? null : repository;
|
|
563
|
+
}
|
|
542
564
|
/**
|
|
543
565
|
* Whether the owner trusted `directory` in Claude Code, as the terminal would decide; false where
|
|
544
566
|
* git would find a repository that no trusted path stands for.
|
package/dist/chat/seam.js
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
* (`channel_not_found`), or the error's own name: all a log line may say of it, never content.
|
|
4
4
|
*/
|
|
5
5
|
export class ChatError extends Error {
|
|
6
|
+
/** The chat will refuse the same call again, whenever it is made: retrying cannot help. */
|
|
7
|
+
permanent;
|
|
6
8
|
constructor(code, options) {
|
|
7
9
|
super(code, options);
|
|
8
10
|
this.name = code;
|
|
11
|
+
this.permanent = options?.permanent === true;
|
|
9
12
|
}
|
|
10
13
|
}
|
|
@@ -40,8 +40,9 @@ import { monotonicClock } from "../../../clock.js";
|
|
|
40
40
|
import { Listings } from "../../../core/port.js";
|
|
41
41
|
import { download } from "../attachments.js";
|
|
42
42
|
import { BIND_ACTION } from "../bind.js";
|
|
43
|
+
import { DELETE_THREAD_CANCEL, DELETE_THREAD_CONFIRM } from "../delete-thread.js";
|
|
43
44
|
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 { CLEAN_ACTION, DELETE_ACTION, EDIT_ACTION, FILTER_ACTIONS, HOME_UPDATE_ACTION, NEW_THREAD_ACTION, REFRESH_ACTION, SHOW_ALL_ACTION, } from "../home.js";
|
|
45
46
|
import { OPEN_BUTTON_ACTION, OPEN_FORM, QUERY_ACTION } from "../openfile/modal.js";
|
|
46
47
|
import { levelLogger } from "../quiet-logger.js";
|
|
47
48
|
import { REQUEST_TIMEOUT_MS } from "../reply/clients.js";
|
|
@@ -53,7 +54,8 @@ import { UPDATE_ACTION } from "../update.js";
|
|
|
53
54
|
import * as voice from "../voice.js";
|
|
54
55
|
import { Answers, logger } from "./answers.js";
|
|
55
56
|
import { Clips } from "./clip.js";
|
|
56
|
-
import {
|
|
57
|
+
import { DeleteThread } from "./delete-thread.js";
|
|
58
|
+
import { deletedAuthor, deletedTs, isPromptMessage, isTombstoneRemoval, isWrittenMessage, messageActor, } from "./guards.js";
|
|
57
59
|
import { HomeControls } from "./home-controls.js";
|
|
58
60
|
import { filesOf, inboundMessage } from "./messages.js";
|
|
59
61
|
import { Open } from "./open.js";
|
|
@@ -123,6 +125,7 @@ export function buildApp(options) {
|
|
|
123
125
|
inbound: options.inbound,
|
|
124
126
|
uploads: options.uploads,
|
|
125
127
|
home: options.home,
|
|
128
|
+
...(options.deleteThread !== undefined && { deleteThread: options.deleteThread }),
|
|
126
129
|
limiter: chat.limiter,
|
|
127
130
|
fetch: options.fetch ??
|
|
128
131
|
(({ url, mimetype, limit }) => download(url, config.botToken, mimetype, limit)),
|
|
@@ -137,6 +140,7 @@ export function buildApp(options) {
|
|
|
137
140
|
const resumeBind = new ResumeBind(parts, answers);
|
|
138
141
|
const clips = new Clips(parts, answers);
|
|
139
142
|
const home = new HomeControls(parts);
|
|
143
|
+
const deleteThread = new DeleteThread(parts, answers);
|
|
140
144
|
const app = new App({
|
|
141
145
|
receiver: options.receiver,
|
|
142
146
|
// auth.test already ran at startup; who may act is decided by the guards of each listener.
|
|
@@ -198,8 +202,48 @@ export function buildApp(options) {
|
|
|
198
202
|
}
|
|
199
203
|
await handed;
|
|
200
204
|
});
|
|
205
|
+
// A first message the owner deleted by hand. Its own listener: the guard above stays closed to
|
|
206
|
+
// every subtype but a person's message. Three checks, on this path's own: the envelope names the
|
|
207
|
+
// owner's workspace, its ts is a thread the daemon keeps, and the deleted message was the owner's
|
|
208
|
+
// (`previous_message.user`). No field of either event names who DELETED the message, so the last
|
|
209
|
+
// check proves whose message went, not who removed it. This may be revisited.
|
|
210
|
+
app.event("message", async ({ event, body }) => {
|
|
211
|
+
const message = event;
|
|
212
|
+
const deleted = deletedTs(message);
|
|
213
|
+
if (deleted === null)
|
|
214
|
+
return;
|
|
215
|
+
const channel = text(message.channel);
|
|
216
|
+
if (answers.stopped || channel === null)
|
|
217
|
+
return;
|
|
218
|
+
if (text(body.team_id) !== identity.teamId) {
|
|
219
|
+
logger.info("ignored a deleted message from another workspace");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// The kept thread first: a deletion of the daemon's own message, or the removal of a tombstone,
|
|
223
|
+
// is no kept root and is dropped without a word.
|
|
224
|
+
if (parts.state.thread(channel, deleted) === null)
|
|
225
|
+
return;
|
|
226
|
+
// Silently: the tombstone's own removal, which the earlier event already acted on.
|
|
227
|
+
if (isTombstoneRemoval(message))
|
|
228
|
+
return;
|
|
229
|
+
if (deletedAuthor(message) !== identity.ownerUserId) {
|
|
230
|
+
logger.info(`ignored the deletion of the first message of ${channel}/${deleted}: not the owner's`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const ref = { channel, thread: deleted };
|
|
234
|
+
try {
|
|
235
|
+
await parts.inbound.rootGone(ref);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
logger.warning(`could not forget ${channel}/${deleted}: ${describe(error)}`);
|
|
239
|
+
}
|
|
240
|
+
// Forgetting the entry already asked the index (`onSessionsChange`); one left stands for a
|
|
241
|
+
// turn that runs, or for a call that failed, and the index reads its row again.
|
|
242
|
+
if (parts.state.thread(channel, deleted) !== null)
|
|
243
|
+
parts.chat.index?.refresh([ref]);
|
|
244
|
+
});
|
|
201
245
|
app.event("file_change", ({ event }) => clips.onFileChange(event));
|
|
202
|
-
registered.push({ kind: "event", id: "message", acknowledgeOnly: false }, { kind: "event", id: "file_change", acknowledgeOnly: false });
|
|
246
|
+
registered.push({ kind: "event", id: "message", acknowledgeOnly: false }, { kind: "event", id: "message deleted", acknowledgeOnly: false }, { kind: "event", id: "file_change", acknowledgeOnly: false });
|
|
203
247
|
/** A click or a select: the listener acknowledges, then checks, on its own. */
|
|
204
248
|
const action = (actionId, listener, options = {}) => {
|
|
205
249
|
registered.push({
|
|
@@ -207,7 +251,7 @@ export function buildApp(options) {
|
|
|
207
251
|
id: String(actionId),
|
|
208
252
|
acknowledgeOnly: options.acknowledgeOnly ?? false,
|
|
209
253
|
});
|
|
210
|
-
app.action(actionId, ({ ack, body }) => listener(() => ack(), body));
|
|
254
|
+
app.action(actionId, ({ ack, body, respond }) => listener(() => ack(), body, respond));
|
|
211
255
|
};
|
|
212
256
|
/** A modal's submit: what the listener answers is what the modal does next. */
|
|
213
257
|
const view = (callbackId, listener) => {
|
|
@@ -234,6 +278,8 @@ export function buildApp(options) {
|
|
|
234
278
|
action(OPEN_BUTTON_ACTION, open.onOpenChoose);
|
|
235
279
|
action(QUERY_ACTION, open.onOpenQuery);
|
|
236
280
|
view(OPEN_FORM, open.onOpenSubmit);
|
|
281
|
+
action(DELETE_THREAD_CONFIRM, deleteThread.onConfirm);
|
|
282
|
+
action(DELETE_THREAD_CANCEL, deleteThread.onCancel);
|
|
237
283
|
action(BIND_ACTION, resumeBind.onBind);
|
|
238
284
|
action(RESUME_ACTION, resumeBind.onResume);
|
|
239
285
|
action(UPDATE_ACTION, resumeBind.onUpdate);
|
|
@@ -242,6 +288,7 @@ export function buildApp(options) {
|
|
|
242
288
|
action(actionId, home.onHomeFilter);
|
|
243
289
|
action(SHOW_ALL_ACTION, home.onHomeShowAll);
|
|
244
290
|
action(EDIT_ACTION, home.onHomeEdit);
|
|
291
|
+
action(REFRESH_ACTION, home.onHomeRefresh);
|
|
245
292
|
action(DELETE_ACTION, home.onHomeDelete);
|
|
246
293
|
action(CLEAN_ACTION, home.onHomeClean);
|
|
247
294
|
action(HOME_UPDATE_ACTION, home.onHomeUpdate);
|
|
@@ -258,5 +305,6 @@ export function buildApp(options) {
|
|
|
258
305
|
clips.close();
|
|
259
306
|
},
|
|
260
307
|
openFile: (channel, threadTs, folder, words) => open.openWord(channel, threadTs, folder, words),
|
|
308
|
+
askDelete: (channel, threadTs) => deleteThread.ask(channel, threadTs),
|
|
261
309
|
};
|
|
262
310
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!delete-thread` as the Slack provider hears it: a question only the owner sees, under their
|
|
3
|
+
* word in the thread, with Delete thread and Cancel. The thread is deleted by the Home tab's own
|
|
4
|
+
* deleter (`ThreadDeleter.delete`), so both ways share one path and one rule.
|
|
5
|
+
*
|
|
6
|
+
* The question is ephemeral: nothing of it is left to delete or to store. A click on it carries
|
|
7
|
+
* `response_url` and `container.is_ephemeral`, and no `message` (measured 2026-10-19 on bolt
|
|
8
|
+
* 5.1.0), so the thread is read from the container (`clickThread`). Either button takes the
|
|
9
|
+
* question away with `respond({ delete_original: true })`: `chat.update` cannot edit an
|
|
10
|
+
* ephemeral message (chat.update reference, read 2026-10-19), and a question rewritten instead
|
|
11
|
+
* stays in the channel's view once its thread is gone, until a reload (measured 2026-10-19).
|
|
12
|
+
*/
|
|
13
|
+
import { fill, named, texts } from "../../../core/port.js";
|
|
14
|
+
import { deleteThreadBlocks } from "../delete-thread.js";
|
|
15
|
+
import { describe } from "../reply/errors.js";
|
|
16
|
+
import * as slackTexts from "../texts.js";
|
|
17
|
+
import { logger } from "./answers.js";
|
|
18
|
+
import { interactionActor } from "./guards.js";
|
|
19
|
+
import { clickThread, record, text } from "./wire.js";
|
|
20
|
+
/** The line, under the owner's word, for a delete that left the thread. */
|
|
21
|
+
function deleteLine(refusal, names) {
|
|
22
|
+
switch (refusal.reason) {
|
|
23
|
+
case "busy":
|
|
24
|
+
return texts.DELETE_THREAD_BUSY;
|
|
25
|
+
case "refused":
|
|
26
|
+
return fill(named(slackTexts.THREAD_DELETE_REFUSED, names), { count: refusal.count });
|
|
27
|
+
case "failed":
|
|
28
|
+
return fill(slackTexts.THREAD_DELETE_FAILED, { error: refusal.error });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class DeleteThread {
|
|
32
|
+
parts;
|
|
33
|
+
answers;
|
|
34
|
+
constructor(parts, answers) {
|
|
35
|
+
this.parts = parts;
|
|
36
|
+
this.answers = answers;
|
|
37
|
+
}
|
|
38
|
+
/** Shows the owner alone the question, in the thread; resolves once it is posted. */
|
|
39
|
+
async ask(channel, threadTs) {
|
|
40
|
+
await this.answers.deliver(channel, threadTs, slackTexts.HOME_DELETE_TITLE, deleteThreadBlocks(this.parts.names), { ephemeral: true });
|
|
41
|
+
}
|
|
42
|
+
/** Delete thread: the question goes, then the thread; a delete that stopped says why. */
|
|
43
|
+
onConfirm = async (ack, body, respond) => {
|
|
44
|
+
const where = await this.admitted(ack, body);
|
|
45
|
+
if (where === null)
|
|
46
|
+
return;
|
|
47
|
+
const [channel, threadTs] = where;
|
|
48
|
+
const { deleteThread, chat, names } = this.parts;
|
|
49
|
+
if (deleteThread === undefined) {
|
|
50
|
+
// A question left from a run that had the owner's token: it stays, and says why.
|
|
51
|
+
await this.answers.tellOwner(channel, threadTs, named(texts.DELETE_THREAD_UNAVAILABLE, names));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await this.withdraw(respond);
|
|
55
|
+
const refusal = await deleteThread(channel, threadTs);
|
|
56
|
+
// A thread that is gone needs no line, and its removal from `state.json` refreshes the Home.
|
|
57
|
+
// One still there says why, and its root is read again: the delete may have stopped half way.
|
|
58
|
+
if (refusal === null)
|
|
59
|
+
return;
|
|
60
|
+
chat.index?.refresh([{ channel, thread: threadTs }]);
|
|
61
|
+
await this.answers.tellOwner(channel, threadTs, deleteLine(refusal, names));
|
|
62
|
+
};
|
|
63
|
+
/** Cancel: the question goes, and nothing else changes. */
|
|
64
|
+
onCancel = async (ack, body, respond) => {
|
|
65
|
+
if ((await this.admitted(ack, body)) !== null)
|
|
66
|
+
await this.withdraw(respond);
|
|
67
|
+
};
|
|
68
|
+
/** Acknowledges, then the channel and the thread of the owner's click; null for anyone else's. */
|
|
69
|
+
async admitted(ack, body) {
|
|
70
|
+
await ack();
|
|
71
|
+
const [user, team] = interactionActor(body);
|
|
72
|
+
const channel = text(record(body.channel).id);
|
|
73
|
+
const threadTs = clickThread(body);
|
|
74
|
+
if (!(await this.answers.admitted(user, team, channel, threadTs)) || channel === null) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return [channel, threadTs];
|
|
78
|
+
}
|
|
79
|
+
/** Takes the question away. A failure leaves it showing until a reload, and is logged. */
|
|
80
|
+
async withdraw(respond) {
|
|
81
|
+
if (respond === undefined)
|
|
82
|
+
return; // a click with no `response_url`: nothing to answer
|
|
83
|
+
try {
|
|
84
|
+
await respond({ delete_original: true });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
logger.warning(`could not take back the delete question: ${describe(error)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { getLogger } from "../../../log.js";
|
|
8
8
|
import { isSpace } from "../../../text.js";
|
|
9
|
+
import { isTombstone } from "../channels.js";
|
|
9
10
|
import { describe } from "../reply/errors.js";
|
|
10
11
|
import * as slackTexts from "../texts.js";
|
|
11
12
|
export const logger = getLogger("allaboard.chat.slack.app.guards");
|
|
@@ -118,6 +119,49 @@ export function isWrittenMessage(event) {
|
|
|
118
119
|
WRITTEN_SUBTYPES.includes(subtype) &&
|
|
119
120
|
!("bot_id" in event));
|
|
120
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The `ts` of the message a deletion removed, when `event` says one was deleted; null for any
|
|
124
|
+
* other event. Two shapes, both seen on 2026-10-20 when the owner's user token deleted roots:
|
|
125
|
+
* `message_deleted` (`deleted_ts`; the reference, docs.slack.dev/reference/events/message/
|
|
126
|
+
* message_deleted, read 2026-10-20), which comes for a message with no replies and for the last
|
|
127
|
+
* reply deleted afterwards; and a `message_changed` whose inner `message.subtype` is `tombstone`,
|
|
128
|
+
* which comes for a root whose replies remain (the reference for `message_changed` does not
|
|
129
|
+
* name the subtype: it is measured). A `message_changed` of an edit has no such subtype, so the
|
|
130
|
+
* daemon's own edits of a root (title, tag) are not deletions.
|
|
131
|
+
*
|
|
132
|
+
* Neither event says who deleted the message. `deletedAuthor` gives who wrote it, which is the
|
|
133
|
+
* most the events prove.
|
|
134
|
+
*/
|
|
135
|
+
export function deletedTs(event) {
|
|
136
|
+
if (event.type !== "message")
|
|
137
|
+
return null;
|
|
138
|
+
if (event.subtype === "message_deleted")
|
|
139
|
+
return text(event.deleted_ts);
|
|
140
|
+
if (event.subtype === "message_changed") {
|
|
141
|
+
const inner = record(event.message);
|
|
142
|
+
return isTombstone(inner) ? text(inner.ts) : null;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The author of the message a deletion event removed: `previous_message.user`, measured on
|
|
148
|
+
* 2026-10-20 in both shapes (the root's author, the owner, for a root; the bot for a reply). Not
|
|
149
|
+
* `message.user` of a tombstone, which is a Slack system user. Null when the event has no
|
|
150
|
+
* `previous_message`. The removal of a tombstone never reaches it (`isTombstoneRemoval` drops it
|
|
151
|
+
* first). This proves whose message was deleted, not who deleted it, which no field of the events
|
|
152
|
+
* says.
|
|
153
|
+
*/
|
|
154
|
+
export function deletedAuthor(event) {
|
|
155
|
+
return text(record(event.previous_message).user);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether `event` is the removal of a tombstone: the last reply of a deleted root went, and the
|
|
159
|
+
* deleted message was the tombstone (`previous_message.subtype` `tombstone`, measured
|
|
160
|
+
* 2026-10-20). The earlier tombstone event already forgot the thread.
|
|
161
|
+
*/
|
|
162
|
+
export function isTombstoneRemoval(event) {
|
|
163
|
+
return isTombstone(event.previous_message);
|
|
164
|
+
}
|
|
121
165
|
const SHARED_FLAGS = ["is_shared", "is_ext_shared", "is_org_shared", "is_pending_ext_shared"];
|
|
122
166
|
export class ChannelGuard {
|
|
123
167
|
#slack;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The controls of the session index (the app's Home tab, `home.ts`): its filters, Show all, Edit,
|
|
3
|
-
* Delete, Clean up and the update line's button.
|
|
3
|
+
* Refresh, Delete, Clean up and the update line's button.
|
|
4
4
|
*/
|
|
5
5
|
import { EDIT_ON, readFilter } from "../home.js";
|
|
6
6
|
import { logger } from "./answers.js";
|
|
@@ -55,6 +55,13 @@ export class HomeControls {
|
|
|
55
55
|
return;
|
|
56
56
|
await this.home.edit(str(firstAction(body).value) === EDIT_ON);
|
|
57
57
|
};
|
|
58
|
+
// The click asks for a cleanup of state.json and a fresh page; `Home.refresh` never throws.
|
|
59
|
+
onHomeRefresh = async (ack, body) => {
|
|
60
|
+
await ack();
|
|
61
|
+
if (!this.homeOwner(body))
|
|
62
|
+
return;
|
|
63
|
+
await this.home.refresh();
|
|
64
|
+
};
|
|
58
65
|
// Slack sends this click only once the owner confirmed in the button's own dialog.
|
|
59
66
|
onHomeDelete = async (ack, body) => {
|
|
60
67
|
await ack();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** What the daemon asks Slack about the channels it is bound to, and the threads in them. */
|
|
2
|
+
import { isRecord } from "../../values.js";
|
|
2
3
|
import { ChatError } from "../seam.js";
|
|
3
4
|
import { THREAD_NOT_FOUND } from "./delete.js";
|
|
4
5
|
import { describe } from "./reply/errors.js";
|
|
@@ -23,15 +24,32 @@ export function channelLookup(slack) {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Whether `root`, a message `conversations.replies` returned, is a tombstone: a root deleted while
|
|
28
|
+
* replies remain, which Slack returns with `subtype` `tombstone` and the text `This message was
|
|
29
|
+
* deleted.` (measured 2026-10-20 on a development app; the subtype is not in the method's
|
|
30
|
+
* reference). Such a root is gone, as one answered with `thread_not_found` is.
|
|
31
|
+
*/
|
|
32
|
+
export function isTombstone(root) {
|
|
33
|
+
return isRecord(root) && root.subtype === "tombstone";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What the cleanup asks about a thread: `conversations.replies` on its root with `limit=1`. A root
|
|
37
|
+
* deleted with no replies, or whose last reply was deleted since, answers `thread_not_found`
|
|
38
|
+
* (measured 2026-10-01, see `home.ts`). A root deleted while replies remain is returned as a
|
|
39
|
+
* tombstone, a message with `subtype` `tombstone` and the text `This message was deleted.` (measured
|
|
40
|
+
* 2026-10-20; the subtype is not in the method's reference). Those two say the thread is gone; any
|
|
41
|
+
* other failure crosses as a `ChatError` named by Slack's code.
|
|
29
42
|
*/
|
|
30
43
|
export function threadLookup(slack) {
|
|
31
44
|
return async (ref) => {
|
|
32
45
|
try {
|
|
33
|
-
await slack.conversations.replies({
|
|
34
|
-
|
|
46
|
+
const answer = await slack.conversations.replies({
|
|
47
|
+
channel: ref.channel,
|
|
48
|
+
ts: ref.thread,
|
|
49
|
+
limit: 1,
|
|
50
|
+
});
|
|
51
|
+
const root = answer.messages?.find((message) => message.ts === ref.thread);
|
|
52
|
+
return isTombstone(root) ? "gone" : "there";
|
|
35
53
|
}
|
|
36
54
|
catch (error) {
|
|
37
55
|
const code = describe(error);
|