allaboard 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- package/slack-app-manifest.json +50 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An audio clip the owner sent: held until Slack has its transcript (`voice.ts`), then sent as
|
|
3
|
+
* the message the owner would have typed.
|
|
4
|
+
*/
|
|
5
|
+
import { fill, named, texts } from "../../../core/port.js";
|
|
6
|
+
import { strip } from "../../../text.js";
|
|
7
|
+
import { DownloadFailed } from "../attachments.js";
|
|
8
|
+
import { describe } from "../reply/errors.js";
|
|
9
|
+
import { mrkdwnEscape } from "../reply/escape.js";
|
|
10
|
+
import { Task } from "../reply/tasks.js";
|
|
11
|
+
import * as slackTexts from "../texts.js";
|
|
12
|
+
import * as voice from "../voice.js";
|
|
13
|
+
import { isCancelled, logger } from "./answers.js";
|
|
14
|
+
import { isOwner } from "./guards.js";
|
|
15
|
+
import { inboundMessage } from "./messages.js";
|
|
16
|
+
import { record, str, text } from "./wire.js";
|
|
17
|
+
export class Clips {
|
|
18
|
+
parts;
|
|
19
|
+
answers;
|
|
20
|
+
// Clips waiting for their transcript, by file id. In memory only: a restart forgets them,
|
|
21
|
+
// and the owner sends the clip again.
|
|
22
|
+
clips = new Map();
|
|
23
|
+
constructor(parts, answers) {
|
|
24
|
+
this.parts = parts;
|
|
25
|
+
this.answers = answers;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* An audio clip is a prompt once Slack has its transcript, which Slack writes when asked:
|
|
29
|
+
* until then it waits, and the owner is told what to do. Where a typed message would be
|
|
30
|
+
* refused, the clip is refused at once, before any wait.
|
|
31
|
+
*/
|
|
32
|
+
async takeClip(channel, threadTs, ts, event, file) {
|
|
33
|
+
const { identity, sessions, state } = this.parts;
|
|
34
|
+
if (!isOwner(identity, text(file.user), text(file.user_team))) {
|
|
35
|
+
await this.answers.tellOwnerAbout(channel, threadTs, ts, named(slackTexts.CLIP_NOT_YOURS, this.parts.names));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (threadTs !== ts && !sessions.live(channel, threadTs)) {
|
|
39
|
+
await this.answers.tellOwner(channel, threadTs, texts.NOT_A_SESSION);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (threadTs === ts && state.channel(channel) === null) {
|
|
43
|
+
await this.parts.inbound.unbound(channel);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const waiting = { channel, threadTs, ts, event, expiry: null, notice: null };
|
|
47
|
+
if (voice.ready(file)) {
|
|
48
|
+
await this.sendClip(waiting, file);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const fileId = str(file.id);
|
|
52
|
+
// the same file sent again: one wait, the later one, and one line that says so
|
|
53
|
+
const earlier = this.clips.get(fileId);
|
|
54
|
+
earlier?.expiry?.cancel();
|
|
55
|
+
waiting.expiry = new Task((signal) => this.giveUp(fileId, signal));
|
|
56
|
+
this.clips.set(fileId, waiting);
|
|
57
|
+
if (earlier !== undefined)
|
|
58
|
+
await this.endWait(earlier);
|
|
59
|
+
const notice = await this.say(waiting, named(slackTexts.CLIP_WAITING, this.parts.names));
|
|
60
|
+
// The wait can have ended while Slack took the line: nobody is left to take it back.
|
|
61
|
+
if (this.clips.get(fileId) === waiting)
|
|
62
|
+
waiting.notice = notice;
|
|
63
|
+
else if (notice !== null)
|
|
64
|
+
await this.answers.withdraw(channel, notice);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A line about the clip that stays: a normal post in the clip's thread, or in the channel
|
|
68
|
+
* for a clip at the top level. Returns its `ts`, null when it was not posted.
|
|
69
|
+
*/
|
|
70
|
+
say(waiting, line) {
|
|
71
|
+
return this.answers.tellAbout(waiting.channel, waiting.threadTs, waiting.ts, line);
|
|
72
|
+
}
|
|
73
|
+
/** The wait is over, whichever way: the line that said the clip waits goes. */
|
|
74
|
+
async endWait(waiting) {
|
|
75
|
+
const notice = waiting.notice;
|
|
76
|
+
waiting.notice = null;
|
|
77
|
+
if (notice !== null)
|
|
78
|
+
await this.answers.withdraw(waiting.channel, notice);
|
|
79
|
+
}
|
|
80
|
+
async giveUp(fileId, signal) {
|
|
81
|
+
await this.parts.clock.sleep(voice.WAIT_SECONDS, signal);
|
|
82
|
+
// (sync) Taken out with nothing awaited since the sleep: a transcript that arrives now
|
|
83
|
+
// finds no clip, and one that arrived a moment ago cancelled this wait.
|
|
84
|
+
const waiting = this.clips.get(fileId);
|
|
85
|
+
if (waiting === undefined)
|
|
86
|
+
return;
|
|
87
|
+
this.clips.delete(fileId);
|
|
88
|
+
// Whole minutes: 300 seconds are 5 whichever way a tie rounds (`Math.round` goes up, Python's
|
|
89
|
+
// `round` to even).
|
|
90
|
+
const minutes = Math.round(voice.WAIT_SECONDS / 60);
|
|
91
|
+
await this.endWait(waiting);
|
|
92
|
+
// What follows is a post anyone in the channel reads, minutes after the clip passed the
|
|
93
|
+
// check: the channel is read again, and a refused one is said to the owner alone.
|
|
94
|
+
const { channel, threadTs, ts } = waiting;
|
|
95
|
+
if (!(await this.answers.channelAllowed(channel, threadTs, ts)))
|
|
96
|
+
return;
|
|
97
|
+
await this.say(waiting, fill(named(slackTexts.CLIP_NOT_SENT, this.parts.names), { minutes }));
|
|
98
|
+
}
|
|
99
|
+
/** Send the clip's transcript as the message the owner would have typed. */
|
|
100
|
+
async sendClip(waiting, file) {
|
|
101
|
+
let heard = voice.preview(file);
|
|
102
|
+
if (heard === null) {
|
|
103
|
+
let body;
|
|
104
|
+
try {
|
|
105
|
+
body = await this.parts.fetch({
|
|
106
|
+
url: str(file.vtt),
|
|
107
|
+
mimetype: "text/vtt",
|
|
108
|
+
limit: voice.VTT_LIMIT,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (!(error instanceof DownloadFailed))
|
|
113
|
+
throw error;
|
|
114
|
+
await this.say(waiting, fill(named(slackTexts.CLIP_UNREADABLE, this.parts.names), {
|
|
115
|
+
reason: mrkdwnEscape(error.message),
|
|
116
|
+
}));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// A byte that is no UTF-8 becomes U+FFFD.
|
|
120
|
+
heard = voice.vttText(new TextDecoder("utf-8").decode(body));
|
|
121
|
+
}
|
|
122
|
+
if (!heard) {
|
|
123
|
+
await this.say(waiting, named(slackTexts.CLIP_EMPTY, this.parts.names));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const typed = strip(text(waiting.event.text) ?? "");
|
|
127
|
+
// the composer's blocks describe the typed text alone
|
|
128
|
+
const { blocks: _blocks, ...rest } = waiting.event;
|
|
129
|
+
const said = { ...rest, text: typed ? `${typed}\n\n${heard}` : heard, files: [] };
|
|
130
|
+
await this.parts.inbound.message(inboundMessage(this.parts, this.answers, waiting.channel, waiting.threadTs, waiting.ts, said, { spoken: true }));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Slack changed a file: the one inbound path with no user and no channel in it. It acts only
|
|
134
|
+
* on a clip the owner's own message left waiting, and checks the owner and the channel again
|
|
135
|
+
* on the file Slack now describes.
|
|
136
|
+
*/
|
|
137
|
+
onFileChange = async (event) => {
|
|
138
|
+
const { slack, identity } = this.parts;
|
|
139
|
+
const fileId = str(event.file_id || record(event.file).id || "");
|
|
140
|
+
if (!this.clips.has(fileId))
|
|
141
|
+
return;
|
|
142
|
+
let file;
|
|
143
|
+
try {
|
|
144
|
+
const answer = await slack.files.info({ file: fileId });
|
|
145
|
+
if (typeof answer.file !== "object" || answer.file === null) {
|
|
146
|
+
throw new TypeError("files.info answered no file");
|
|
147
|
+
}
|
|
148
|
+
file = answer.file;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (isCancelled(error))
|
|
152
|
+
throw error;
|
|
153
|
+
logger.warning(`could not read a waiting clip: ${describe(error)}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// (sync) Slack sends the event several times while it writes one transcript: the first
|
|
157
|
+
// that finds it complete takes the clip, with nothing awaited in between.
|
|
158
|
+
const waiting = this.clips.get(fileId);
|
|
159
|
+
if (waiting === undefined || !voice.ready(file))
|
|
160
|
+
return;
|
|
161
|
+
this.clips.delete(fileId);
|
|
162
|
+
waiting.expiry?.cancel();
|
|
163
|
+
// Started now and awaited last: the transcript never waits on the line's removal.
|
|
164
|
+
const lineGone = this.endWait(waiting);
|
|
165
|
+
try {
|
|
166
|
+
const user = text(file.user);
|
|
167
|
+
const team = text(file.user_team);
|
|
168
|
+
if (!isOwner(identity, user, team)) {
|
|
169
|
+
await this.answers.tellOwnerAbout(waiting.channel, waiting.threadTs, waiting.ts, named(slackTexts.CLIP_NOT_YOURS, this.parts.names));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// The channel is read again: its members can have changed while the clip waited.
|
|
173
|
+
const { channel, threadTs, ts } = waiting;
|
|
174
|
+
if (!(await this.answers.admitted(user, team, channel, threadTs, ts)))
|
|
175
|
+
return;
|
|
176
|
+
await this.answers.replyOnFailure(channel, threadTs, () => this.sendClip(waiting, file));
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await lineGone;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Give every wait up without a word: the daemon is stopping. A timer of Node's keeps the
|
|
184
|
+
* process alive until it fires.
|
|
185
|
+
*/
|
|
186
|
+
close() {
|
|
187
|
+
for (const waiting of this.clips.values())
|
|
188
|
+
waiting.expiry?.cancel();
|
|
189
|
+
this.clips.clear();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may talk to the daemon, and in which channels it may answer.
|
|
3
|
+
*
|
|
4
|
+
* Every inbound path calls these on its own: a button is never trusted because of the message it
|
|
5
|
+
* sits on, and the channel is re-read from Slack each time, since membership can change.
|
|
6
|
+
*/
|
|
7
|
+
import { getLogger } from "../../../log.js";
|
|
8
|
+
import { isSpace } from "../../../text.js";
|
|
9
|
+
import { describe } from "../reply/errors.js";
|
|
10
|
+
import * as slackTexts from "../texts.js";
|
|
11
|
+
export const logger = getLogger("allaboard.chat.slack.app.guards");
|
|
12
|
+
function text(value) {
|
|
13
|
+
return typeof value === "string" ? value : null;
|
|
14
|
+
}
|
|
15
|
+
/** Whether a parsed JSON value is present: `[]`, `{}`, `""`, `0`, `false` and null are not. */
|
|
16
|
+
function truthy(value) {
|
|
17
|
+
if (value === undefined || value === null)
|
|
18
|
+
return false;
|
|
19
|
+
if (Array.isArray(value))
|
|
20
|
+
return value.length > 0;
|
|
21
|
+
switch (typeof value) {
|
|
22
|
+
case "object":
|
|
23
|
+
return Object.keys(value).length > 0;
|
|
24
|
+
case "string":
|
|
25
|
+
return value.length > 0;
|
|
26
|
+
case "number":
|
|
27
|
+
return value !== 0;
|
|
28
|
+
default:
|
|
29
|
+
return Boolean(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function record(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
34
|
+
? value
|
|
35
|
+
: {};
|
|
36
|
+
}
|
|
37
|
+
/** True only for the configured owner acting from the workspace read at startup. */
|
|
38
|
+
export function isOwner(identity, userId, teamId) {
|
|
39
|
+
return Boolean(userId) && userId === identity.ownerUserId && teamId === identity.teamId;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The user and the workspace of a message, `body` being the envelope the event came in. Two
|
|
43
|
+
* events carry no `team`. A file_share (measured 2026-09-25): its files name the uploader's
|
|
44
|
+
* workspace, and must all name the same one. A thread_broadcast, a reply also sent to the
|
|
45
|
+
* channel (recorded 2026-10-09): the envelope's `team_id` stands in, and only when the envelope
|
|
46
|
+
* says the channel is not shared outside the workspace, since that id names where the event
|
|
47
|
+
* happened and not the writer's own workspace. One that carries files follows the files' rule,
|
|
48
|
+
* the stricter of the two.
|
|
49
|
+
*/
|
|
50
|
+
export function messageActor(event, body) {
|
|
51
|
+
let team;
|
|
52
|
+
// Only a missing or null `team` falls back: a value of another type is a team nobody has,
|
|
53
|
+
// never a reason to read the files or the envelope instead.
|
|
54
|
+
if (event.team !== undefined && event.team !== null) {
|
|
55
|
+
team = text(event.team);
|
|
56
|
+
}
|
|
57
|
+
else if (truthy(event.files)) {
|
|
58
|
+
team = filesTeam(event.files);
|
|
59
|
+
}
|
|
60
|
+
else if (event.subtype === "thread_broadcast" && body.is_ext_shared_channel === false) {
|
|
61
|
+
team = text(body.team_id);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
team = null;
|
|
65
|
+
}
|
|
66
|
+
return [text(event.user), team];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The one workspace all the files name, else null. A `files` that is not a list of objects names
|
|
70
|
+
* no workspace, which refuses the message.
|
|
71
|
+
*/
|
|
72
|
+
function filesTeam(files) {
|
|
73
|
+
if (!Array.isArray(files))
|
|
74
|
+
return null;
|
|
75
|
+
const teams = new Set(files.map((file) => text(record(file).user_team)));
|
|
76
|
+
const [only] = teams;
|
|
77
|
+
return teams.size === 1 && only !== undefined ? only : null;
|
|
78
|
+
}
|
|
79
|
+
/** The user and the workspace of a click; a user whose home team differs counts as foreign. */
|
|
80
|
+
export function interactionActor(body) {
|
|
81
|
+
const user = record(body.user);
|
|
82
|
+
const team = text(record(body.team).id);
|
|
83
|
+
// The home team is compared as it came: only a missing or null one defers to `team`.
|
|
84
|
+
const home = user.team_id;
|
|
85
|
+
const same = home === undefined || home === null || (typeof home === "string" && home === team);
|
|
86
|
+
return [text(user.id), same ? team : null];
|
|
87
|
+
}
|
|
88
|
+
// The subtypes a message a person wrote can carry: a message with files, and a thread reply also
|
|
89
|
+
// sent to the channel. Any other subtype is an edit, a deletion or a join.
|
|
90
|
+
export const PROMPT_SUBTYPES = ["file_share", "thread_broadcast"];
|
|
91
|
+
function hasText(value) {
|
|
92
|
+
return typeof value === "string" && [...value].some((char) => !isSpace(char.charCodeAt(0)));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A message a person wrote: with no subtype or one of PROMPT_SUBTYPES, from no bot, with some
|
|
96
|
+
* text or a file.
|
|
97
|
+
*/
|
|
98
|
+
export function isPromptMessage(event) {
|
|
99
|
+
// An absent subtype passes; a subtype that is present and null does not.
|
|
100
|
+
const subtype = "subtype" in event ? event.subtype : "file_share";
|
|
101
|
+
return (event.type === "message" &&
|
|
102
|
+
typeof subtype === "string" &&
|
|
103
|
+
PROMPT_SUBTYPES.includes(subtype) &&
|
|
104
|
+
!("bot_id" in event) &&
|
|
105
|
+
(hasText(event.text) || (Array.isArray(event.files) && event.files.length > 0)));
|
|
106
|
+
}
|
|
107
|
+
// The subtypes of a message a person wrote that sits in a thread whatever the daemon makes of it:
|
|
108
|
+
// those of a prompt, and a `/me` line. Edits, deletions and joins are not new messages.
|
|
109
|
+
const WRITTEN_SUBTYPES = [...PROMPT_SUBTYPES, "me_message"];
|
|
110
|
+
/**
|
|
111
|
+
* A message a person wrote, prompt or not: it takes a place in its thread, below the replies
|
|
112
|
+
* above it, even when nothing is made of it (a `/me` line, a share with attachments alone).
|
|
113
|
+
*/
|
|
114
|
+
export function isWrittenMessage(event) {
|
|
115
|
+
const subtype = "subtype" in event ? event.subtype : "file_share";
|
|
116
|
+
return (event.type === "message" &&
|
|
117
|
+
typeof subtype === "string" &&
|
|
118
|
+
WRITTEN_SUBTYPES.includes(subtype) &&
|
|
119
|
+
!("bot_id" in event));
|
|
120
|
+
}
|
|
121
|
+
const SHARED_FLAGS = ["is_shared", "is_ext_shared", "is_org_shared", "is_pending_ext_shared"];
|
|
122
|
+
export class ChannelGuard {
|
|
123
|
+
#slack;
|
|
124
|
+
#identity;
|
|
125
|
+
#log;
|
|
126
|
+
constructor(slack, identity, log = logger) {
|
|
127
|
+
this.#slack = slack;
|
|
128
|
+
this.#identity = identity;
|
|
129
|
+
this.#log = log;
|
|
130
|
+
}
|
|
131
|
+
/** Null when the channel is private, unshared and holds exactly the owner and the bot. */
|
|
132
|
+
async refusal(channelId) {
|
|
133
|
+
let info;
|
|
134
|
+
let members;
|
|
135
|
+
try {
|
|
136
|
+
const answer = await this.#slack.conversations.info({ channel: channelId });
|
|
137
|
+
if (answer.channel === undefined)
|
|
138
|
+
throw new Error("conversations.info answered no channel");
|
|
139
|
+
info = answer.channel;
|
|
140
|
+
members = await this.#slack.conversations.members({ channel: channelId, limit: 10 });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
// A network failure too: an unread channel is never trusted.
|
|
144
|
+
this.#log.warning(`could not read channel ${channelId}: ${describe(error)}`);
|
|
145
|
+
return slackTexts.REASON_UNREADABLE;
|
|
146
|
+
}
|
|
147
|
+
if (!truthy(info.is_private) || truthy(info.is_im) || truthy(info.is_mpim)) {
|
|
148
|
+
return slackTexts.REASON_NOT_PRIVATE;
|
|
149
|
+
}
|
|
150
|
+
if (SHARED_FLAGS.some((flag) => truthy(info[flag])))
|
|
151
|
+
return slackTexts.REASON_SHARED;
|
|
152
|
+
const more = truthy(members.response_metadata?.next_cursor);
|
|
153
|
+
if (members.members === undefined)
|
|
154
|
+
throw new Error("conversations.members answered no members");
|
|
155
|
+
const present = new Set(members.members);
|
|
156
|
+
const expected = new Set([this.#identity.ownerUserId, this.#identity.botUserId]);
|
|
157
|
+
const same = present.size === expected.size && [...present].every((id) => expected.has(id));
|
|
158
|
+
if (more || !same)
|
|
159
|
+
return slackTexts.REASON_MEMBERS;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
import { EDIT_ON, readFilter } from "../home.js";
|
|
6
|
+
import { logger } from "./answers.js";
|
|
7
|
+
import { interactionActor, isOwner } from "./guards.js";
|
|
8
|
+
import { firstAction, record, str } from "./wire.js";
|
|
9
|
+
export class HomeControls {
|
|
10
|
+
parts;
|
|
11
|
+
home;
|
|
12
|
+
constructor(parts) {
|
|
13
|
+
this.parts = parts;
|
|
14
|
+
this.home = parts.home;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether a use of a Home tab control is the owner's. The page is published to the owner
|
|
18
|
+
* alone, and each use is still checked on its own, as every inbound path is; a Home tab
|
|
19
|
+
* payload names no channel, so there is none to guard.
|
|
20
|
+
*/
|
|
21
|
+
homeOwner(body) {
|
|
22
|
+
if (this.parts.stopped.aborted)
|
|
23
|
+
return false; // the daemon is gone: nothing to publish or delete
|
|
24
|
+
const [user, team] = interactionActor(body);
|
|
25
|
+
if (isOwner(this.parts.identity, user, team))
|
|
26
|
+
return true;
|
|
27
|
+
logger.info("ignored an inbound event from someone other than the owner");
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
// The Home tab's link button: Slack follows the link itself and still sends the click, which
|
|
31
|
+
// only needs acknowledging. Nothing is read from it and nothing is done, whoever clicked.
|
|
32
|
+
onHomeLink = async (ack) => {
|
|
33
|
+
await ack();
|
|
34
|
+
};
|
|
35
|
+
onHomeFilter = async (ack, body) => {
|
|
36
|
+
await ack();
|
|
37
|
+
if (!this.homeOwner(body))
|
|
38
|
+
return;
|
|
39
|
+
// The controls' own state rides on the payload: every filter is read from it at once.
|
|
40
|
+
const values = record(record(body.view).state).values;
|
|
41
|
+
await this.home.choose(readFilter(values, this.home.chosen));
|
|
42
|
+
};
|
|
43
|
+
onHomeShowAll = async (ack, body) => {
|
|
44
|
+
await ack();
|
|
45
|
+
if (!this.homeOwner(body))
|
|
46
|
+
return;
|
|
47
|
+
// Showing all of a channel is choosing that channel in the filter (`Home.choose` checks it
|
|
48
|
+
// is a bound one).
|
|
49
|
+
const channel = str(firstAction(body).value);
|
|
50
|
+
await this.home.choose({ ...this.home.chosen, channel });
|
|
51
|
+
};
|
|
52
|
+
onHomeEdit = async (ack, body) => {
|
|
53
|
+
await ack();
|
|
54
|
+
if (!this.homeOwner(body))
|
|
55
|
+
return;
|
|
56
|
+
await this.home.edit(str(firstAction(body).value) === EDIT_ON);
|
|
57
|
+
};
|
|
58
|
+
// Slack sends this click only once the owner confirmed in the button's own dialog.
|
|
59
|
+
onHomeDelete = async (ack, body) => {
|
|
60
|
+
await ack();
|
|
61
|
+
if (!this.homeOwner(body))
|
|
62
|
+
return;
|
|
63
|
+
// The value names the thread; `Home.delete` deletes only one `state.json` holds.
|
|
64
|
+
const value = str(firstAction(body).value);
|
|
65
|
+
const at = value.indexOf(":");
|
|
66
|
+
const channel = at === -1 ? value : value.slice(0, at);
|
|
67
|
+
const threadTs = at === -1 ? "" : value.slice(at + 1);
|
|
68
|
+
await this.home.delete(channel, threadTs);
|
|
69
|
+
};
|
|
70
|
+
// Sent, like Delete, only once the owner confirmed in the button's own dialog.
|
|
71
|
+
onHomeClean = async (ack, body) => {
|
|
72
|
+
await ack();
|
|
73
|
+
if (!this.homeOwner(body))
|
|
74
|
+
return;
|
|
75
|
+
await this.home.clean(str(firstAction(body).value));
|
|
76
|
+
};
|
|
77
|
+
// Sent only once the owner confirmed in the button's own dialog. The page has no message and no
|
|
78
|
+
// channel to answer in: the click reaches the core without either, and the page shows the state.
|
|
79
|
+
onHomeUpdate = async (ack, body) => {
|
|
80
|
+
await ack();
|
|
81
|
+
if (!this.homeOwner(body))
|
|
82
|
+
return;
|
|
83
|
+
// The version is the button's value, untrusted like every click's.
|
|
84
|
+
await this.parts.inbound.picked({
|
|
85
|
+
type: "update",
|
|
86
|
+
channel: null,
|
|
87
|
+
version: str(firstAction(body).value),
|
|
88
|
+
list: null,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message the owner wrote, read from Slack's payload into the message the core is told of
|
|
3
|
+
* (`InboundMessage`): its text as typed and with the composer's marks removed, whether it
|
|
4
|
+
* carries files or was spoken, and the prompt its files make. What the message means, where it
|
|
5
|
+
* is routed and in what order it is sent are the core's (`core/flows/messages.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Ordering. The core takes a thread's messages in the order they were handed over, although
|
|
8
|
+
* files take a while to download. The provider keeps its part: the listener takes its place among
|
|
9
|
+
* its thread's messages before its channel check (`ThreadOrder`), so the messages are handed
|
|
10
|
+
* over in the order the listeners were called, and from the check to the call
|
|
11
|
+
* `ChatInbound.message` nothing is awaited (`(sync)` in `app.ts`). `content` is read by the core
|
|
12
|
+
* inside the message's own turn.
|
|
13
|
+
*/
|
|
14
|
+
import { fill, named } from "../../../core/port.js";
|
|
15
|
+
import { strip } from "../../../text.js";
|
|
16
|
+
import { DownloadFailed, imagesRefusal, isImage, limitFor, promptFor, refusal, save, } from "../attachments.js";
|
|
17
|
+
import { mrkdwnEscape } from "../reply/escape.js";
|
|
18
|
+
import * as slackTexts from "../texts.js";
|
|
19
|
+
import { items, str, text } from "./wire.js";
|
|
20
|
+
// Slack sends a link as <url|label> or <url> (message formatting reference, read 2026-09-25).
|
|
21
|
+
// Mentions (<@U…>, <#C…>) stay as sent: naming them would need a scope the app does not have.
|
|
22
|
+
const LINK = /<((?:https?|mailto):[^|>]+)(?:\|([^>]+))?>/g;
|
|
23
|
+
const SCHEME = /^(?:https?:\/\/|mailto:)/;
|
|
24
|
+
function link(_whole, url, label) {
|
|
25
|
+
if (label === undefined)
|
|
26
|
+
return url;
|
|
27
|
+
// A link Slack made from a typed name carries that name as its label; one the owner named
|
|
28
|
+
// keeps its address, which the agent needs to open it.
|
|
29
|
+
return url.replace(SCHEME, "") === label ? label : `${label} (${url})`;
|
|
30
|
+
}
|
|
31
|
+
/** The text as the owner typed it, with the address of any link the owner named. */
|
|
32
|
+
export function slackUnescape(sent) {
|
|
33
|
+
return sent
|
|
34
|
+
.replace(LINK, link)
|
|
35
|
+
.replaceAll("<", "<")
|
|
36
|
+
.replaceAll(">", ">")
|
|
37
|
+
.replaceAll("&", "&");
|
|
38
|
+
}
|
|
39
|
+
// The parts of a composer message a word is read through. A quote or a list is left out: what
|
|
40
|
+
// opens it is not what the owner typed first.
|
|
41
|
+
const READ_THROUGH = new Set(["rich_text_section", "rich_text_preformatted"]);
|
|
42
|
+
// What Slack writes in a message's text around code, bold, italic, strikethrough.
|
|
43
|
+
const MARKS = "`*_~";
|
|
44
|
+
/** The first element of a list, or an empty record for anything else. */
|
|
45
|
+
function firstOf(value) {
|
|
46
|
+
const head = Array.isArray(value) ? value[0] : undefined;
|
|
47
|
+
return typeof head === "object" && head !== null ? head : {};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The text a composer message opens with, without its formatting; null for a message that opens
|
|
51
|
+
* with anything else (a quote, a list, an emoji, a mention) or has no composer block.
|
|
52
|
+
*/
|
|
53
|
+
function firstRun(blocks) {
|
|
54
|
+
const block = firstOf(blocks);
|
|
55
|
+
const part = block.type === "rich_text" ? firstOf(block.elements) : {};
|
|
56
|
+
const leaf = typeof part.type === "string" && READ_THROUGH.has(part.type) ? firstOf(part.elements) : {};
|
|
57
|
+
if (leaf.type !== "text")
|
|
58
|
+
return null;
|
|
59
|
+
return typeof leaf.text === "string" ? leaf.text : "";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `text` without the marks around the run it opens with, when that run starts with `!`: the
|
|
63
|
+
* composer's block says what the run is (`firstRun`), the text holds the rest as the owner sent
|
|
64
|
+
* it. Empty when the message does not open with a formatted `!`, or when the text does not read as
|
|
65
|
+
* its marks, that run, then the same marks closing it or the message.
|
|
66
|
+
*/
|
|
67
|
+
export function unformatted(text, blocks) {
|
|
68
|
+
const run = firstRun(blocks);
|
|
69
|
+
const stripped = strip(text);
|
|
70
|
+
const at = stripped.indexOf("!");
|
|
71
|
+
const opening = at === -1 ? stripped : stripped.slice(0, at);
|
|
72
|
+
const body = at === -1 ? "" : stripped.slice(at);
|
|
73
|
+
if (!run?.startsWith("!") || !opening || [...opening].some((c) => !MARKS.includes(c))) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
if (!body.startsWith(run))
|
|
77
|
+
return "";
|
|
78
|
+
const after = body.slice(run.length);
|
|
79
|
+
const closing = [...opening].reverse().join("");
|
|
80
|
+
if (after.startsWith(closing))
|
|
81
|
+
return run + after.slice(closing.length);
|
|
82
|
+
if (after.endsWith(closing))
|
|
83
|
+
return run + after.slice(0, after.length - closing.length);
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The files a message carries. A `files` that is not a list of objects throws: such a message
|
|
88
|
+
* never becomes a prompt, and never a word either.
|
|
89
|
+
*/
|
|
90
|
+
export function filesOf(event) {
|
|
91
|
+
const files = event.files;
|
|
92
|
+
if (files === undefined || files === null)
|
|
93
|
+
return [];
|
|
94
|
+
if (!Array.isArray(files))
|
|
95
|
+
throw new TypeError("the message's files are not a list");
|
|
96
|
+
return items(files).map((file) => {
|
|
97
|
+
if (typeof file !== "object" || file === null || Array.isArray(file)) {
|
|
98
|
+
throw new TypeError("a file of the message is not an object");
|
|
99
|
+
}
|
|
100
|
+
return file;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The prompt for `sent` and its files; null, after telling the owner why under the message `ts`,
|
|
105
|
+
* when any file cannot reach the agent: the message is sent whole or not at all.
|
|
106
|
+
*/
|
|
107
|
+
async function withAttachments(parts, answers, channel, threadTs, ts, sent, files) {
|
|
108
|
+
const { fetch, uploads } = parts;
|
|
109
|
+
const refuse = async (file, reason) => {
|
|
110
|
+
const name = mrkdwnEscape(str(file.name || file.id));
|
|
111
|
+
await answers.tellOwnerAbout(channel, threadTs, ts, fill(named(slackTexts.UPLOAD_FAILED, parts.names), { name, reason }));
|
|
112
|
+
};
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
const reason = refusal(file, parts.names);
|
|
115
|
+
if (reason !== null) {
|
|
116
|
+
await refuse(file, reason);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const together = imagesRefusal(files, parts.names);
|
|
121
|
+
if (together !== null) {
|
|
122
|
+
await answers.tellOwnerAbout(channel, threadTs, ts, together);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const fetched = await Promise.allSettled(files.map((file) => fetch({
|
|
126
|
+
url: str(file.url_private_download),
|
|
127
|
+
mimetype: str(file.mimetype),
|
|
128
|
+
limit: limitFor(file),
|
|
129
|
+
})));
|
|
130
|
+
// Nothing is saved until every file arrived: a failed message leaves no copy behind.
|
|
131
|
+
for (const [index, outcome] of fetched.entries()) {
|
|
132
|
+
if (outcome.status === "fulfilled")
|
|
133
|
+
continue;
|
|
134
|
+
if (outcome.reason instanceof DownloadFailed) {
|
|
135
|
+
await refuse(files[index], fill(slackTexts.UPLOAD_DOWNLOAD, { error: outcome.reason.message }));
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
throw outcome.reason;
|
|
139
|
+
}
|
|
140
|
+
const images = [];
|
|
141
|
+
const paths = [];
|
|
142
|
+
for (const [index, outcome] of fetched.entries()) {
|
|
143
|
+
const file = files[index];
|
|
144
|
+
if (outcome.status !== "fulfilled")
|
|
145
|
+
continue;
|
|
146
|
+
if (isImage(file)) {
|
|
147
|
+
images.push({ mediaType: str(file.mimetype), data: outcome.value });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
paths.push(await save(uploads, file, outcome.value));
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (!(error instanceof DownloadFailed))
|
|
155
|
+
throw error;
|
|
156
|
+
await refuse(file, error.message);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return promptFor(sent, images, paths);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The message `event` is, for the core: written in `channel`, in the thread `threadTs`, with
|
|
164
|
+
* the timestamp `ts`. With `spoken` (a clip's transcript) it is literal whatever its text; so is
|
|
165
|
+
* a message with files, whose `content` downloads them when the core takes the message's turn.
|
|
166
|
+
* Throws when the event's `files` are not a list of objects.
|
|
167
|
+
*/
|
|
168
|
+
export function inboundMessage(parts, answers, channel, threadTs, ts, event, options = {}) {
|
|
169
|
+
const sent = slackUnescape(text(event.text) ?? "");
|
|
170
|
+
const files = filesOf(event);
|
|
171
|
+
return {
|
|
172
|
+
ref: { channel, thread: threadTs },
|
|
173
|
+
id: ts,
|
|
174
|
+
topLevel: threadTs === ts,
|
|
175
|
+
text: sent,
|
|
176
|
+
// The composer's blocks tell a mark around a `!` from the text alone.
|
|
177
|
+
plain: unformatted(sent, event.blocks) || sent,
|
|
178
|
+
literal: files.length > 0 || (options.spoken ?? false),
|
|
179
|
+
content: async () => files.length > 0 ? withAttachments(parts, answers, channel, threadTs, ts, sent, files) : sent,
|
|
180
|
+
};
|
|
181
|
+
}
|