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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { named } from "../../core/port.js";
|
|
2
|
+
import * as slackTexts from "./texts.js";
|
|
3
|
+
/** `!delete-thread`'s question as Slack blocks. */
|
|
4
|
+
export const DELETE_THREAD_CONFIRM = "delete_thread_confirm";
|
|
5
|
+
export const DELETE_THREAD_CANCEL = "delete_thread_cancel";
|
|
6
|
+
/**
|
|
7
|
+
* The question under the owner's word: the Home tab's own title and warning in a `mrkdwn`
|
|
8
|
+
* section, then its two buttons. The click carries no `message` and is read by its container
|
|
9
|
+
* (`clickThread`), so the buttons hold no value. Delete is `danger`, as on the Home tab.
|
|
10
|
+
*/
|
|
11
|
+
export function deleteThreadBlocks(names) {
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
type: "section",
|
|
15
|
+
text: {
|
|
16
|
+
type: "mrkdwn",
|
|
17
|
+
text: `*${slackTexts.HOME_DELETE_TITLE}*\n${named(slackTexts.HOME_DELETE_TEXT, names)}`,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: "actions",
|
|
22
|
+
elements: [
|
|
23
|
+
{
|
|
24
|
+
type: "button",
|
|
25
|
+
action_id: DELETE_THREAD_CONFIRM,
|
|
26
|
+
style: "danger",
|
|
27
|
+
text: { type: "plain_text", text: slackTexts.HOME_DELETE_CONFIRM },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "button",
|
|
31
|
+
action_id: DELETE_THREAD_CANCEL,
|
|
32
|
+
text: { type: "plain_text", text: slackTexts.HOME_DELETE_DENY },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
@@ -85,8 +85,8 @@ export class ThreadDeleter {
|
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* Delete every message of a thread that holds a session and forget the thread. Returns null
|
|
88
|
-
* once the thread is gone (or was not one of the daemon's), else
|
|
89
|
-
*
|
|
88
|
+
* once the thread is gone (or was not one of the daemon's), else why it is still there. Never
|
|
89
|
+
* throws.
|
|
90
90
|
*/
|
|
91
91
|
async delete(channelId, threadTs) {
|
|
92
92
|
if (this.#state.thread(channelId, threadTs) === null) {
|
|
@@ -98,25 +98,31 @@ export class ThreadDeleter {
|
|
|
98
98
|
if (this.#state.thread(channelId, threadTs) === null) {
|
|
99
99
|
return null; // deleted while this call waited its turn
|
|
100
100
|
}
|
|
101
|
+
// A refused release took no hold, and the hold on the thread is then another's (a deletion of
|
|
102
|
+
// its first message being forgotten): it is not this call's to free.
|
|
103
|
+
let notHeld = false;
|
|
101
104
|
try {
|
|
102
105
|
// From here until `free`, the thread is held: a message sent in it while its messages go
|
|
103
106
|
// finds no session, and none is rebuilt from the entry `state.json` still has
|
|
104
107
|
// (`SessionManager.release`).
|
|
105
|
-
if (!(await this.#release(channelId, threadTs)))
|
|
106
|
-
|
|
108
|
+
if (!(await this.#release(channelId, threadTs))) {
|
|
109
|
+
notHeld = true;
|
|
110
|
+
return { reason: "busy" };
|
|
111
|
+
}
|
|
107
112
|
const refused = await this.#empty(channelId, threadTs);
|
|
108
113
|
if (refused > 0) {
|
|
109
114
|
// The root stays while a reply does, so the thread can still be opened.
|
|
110
|
-
return
|
|
115
|
+
return { reason: "refused", count: refused };
|
|
111
116
|
}
|
|
112
117
|
this.#state.removeThread(channelId, threadTs);
|
|
113
118
|
}
|
|
114
119
|
catch (error) {
|
|
115
120
|
logger.warning(`could not delete ${channelId}/${threadTs}: ${describe(error)}`);
|
|
116
|
-
return
|
|
121
|
+
return { reason: "failed", error: describe(error) };
|
|
117
122
|
}
|
|
118
123
|
finally {
|
|
119
|
-
|
|
124
|
+
if (!notHeld)
|
|
125
|
+
this.#free(channelId, threadTs);
|
|
120
126
|
}
|
|
121
127
|
logger.info(`deleted thread ${channelId}/${threadTs}`);
|
|
122
128
|
return null;
|
package/dist/chat/slack/home.js
CHANGED
|
@@ -46,9 +46,10 @@
|
|
|
46
46
|
*/
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
48
48
|
import { systemClock } from "../../clock.js";
|
|
49
|
-
import { Cancelled, Mutex, named, oneLine, Semaphore, TITLE_LIMIT, texts, } from "../../core/port.js";
|
|
49
|
+
import { Cancelled, Mutex, named, oneLine, Semaphore, TAG_NAME, TITLE_LIMIT, texts, } from "../../core/port.js";
|
|
50
50
|
import { getLogger } from "../../log.js";
|
|
51
51
|
import { setupGuide } from "../../version.js";
|
|
52
|
+
import { isTombstone } from "./channels.js";
|
|
52
53
|
import { contextBlock, noticeText, plainTextObject } from "./reply/blocks.js";
|
|
53
54
|
import { take } from "./reply/chars.js";
|
|
54
55
|
import { describe, gone } from "./reply/errors.js";
|
|
@@ -56,6 +57,7 @@ import { mrkdwnEscape, shownAsWritten } from "./reply/escape.js";
|
|
|
56
57
|
import { REACTIONS, Status } from "./reply/status.js";
|
|
57
58
|
import { Task } from "./reply/tasks.js";
|
|
58
59
|
import { ID_SHOWN } from "./resume.js";
|
|
60
|
+
import { rootMarks } from "./tag.js";
|
|
59
61
|
import * as slackTexts from "./texts.js";
|
|
60
62
|
import { updateButton, withChangelog } from "./update.js";
|
|
61
63
|
export const logger = getLogger("allaboard.chat.slack.home");
|
|
@@ -79,10 +81,13 @@ export const SEARCH_BLOCK = "home_search";
|
|
|
79
81
|
export const CHANNEL_ACTION = "home_channel";
|
|
80
82
|
export const STATUS_ACTION = "home_status";
|
|
81
83
|
export const DATE_ACTION = "home_date";
|
|
84
|
+
export const TAG_ACTION = "home_tag";
|
|
82
85
|
export const SEARCH_ACTION = "home_search_text";
|
|
83
86
|
export const SHOW_ALL_ACTION = "home_show_all";
|
|
84
87
|
export const NEW_THREAD_ACTION = "home_new_thread";
|
|
85
88
|
export const EDIT_ACTION = "home_edit";
|
|
89
|
+
export const REFRESH_ACTION = "home_refresh";
|
|
90
|
+
export const CONTROLS_BLOCK = "home_controls";
|
|
86
91
|
export const DELETE_ACTION = "home_delete";
|
|
87
92
|
export const CLEAN_ACTION = "home_clean";
|
|
88
93
|
export const HOME_UPDATE_ACTION = "home_update";
|
|
@@ -91,9 +96,17 @@ export const NOTICES_SHOWN = 5;
|
|
|
91
96
|
// What the Edit button carries: the mode a click asks for, so a click sent twice asks the same.
|
|
92
97
|
export const EDIT_ON = "edit";
|
|
93
98
|
export const EDIT_OFF = "done";
|
|
99
|
+
// What the Refresh button carries: it has one meaning, so there is nothing to choose between.
|
|
100
|
+
export const REFRESH_VALUE = "refresh";
|
|
94
101
|
// A confirmation dialog's text holds 300 characters (confirmation dialog object reference).
|
|
95
102
|
export const CONFIRM_TEXT = 300;
|
|
96
|
-
export const FILTER_ACTIONS = [
|
|
103
|
+
export const FILTER_ACTIONS = [
|
|
104
|
+
CHANNEL_ACTION,
|
|
105
|
+
STATUS_ACTION,
|
|
106
|
+
DATE_ACTION,
|
|
107
|
+
TAG_ACTION,
|
|
108
|
+
SEARCH_ACTION,
|
|
109
|
+
];
|
|
97
110
|
// A blank row between two sessions: a context line that holds a zero-width space, since Slack
|
|
98
111
|
// sets the distance between blocks itself and takes no empty text.
|
|
99
112
|
export const SPACER = "";
|
|
@@ -162,23 +175,26 @@ export function threadFacts(root) {
|
|
|
162
175
|
const names = reactions
|
|
163
176
|
.filter((r) => typeof r === "object" && r !== null && !Array.isArray(r))
|
|
164
177
|
.map((r) => String(r.name));
|
|
178
|
+
const { tag, pinned } = rootMarks(root);
|
|
165
179
|
return {
|
|
166
180
|
replies: root.reply_count ? pyInt(root.reply_count) : 0,
|
|
167
181
|
latestReply: latest ? Math.trunc(pyFloat(latest)) : null,
|
|
168
182
|
latestTs: latest ? String(latest) : null,
|
|
169
183
|
reaction: names.find((name) => WORDS.has(name)) ?? null,
|
|
184
|
+
tag,
|
|
185
|
+
pinned,
|
|
170
186
|
};
|
|
171
187
|
}
|
|
172
188
|
/** A `HomeFilter` with every field not given at its default. */
|
|
173
189
|
export function homeFilter(fields = {}) {
|
|
174
|
-
return { channel: null, status: null, date: LAST_48, search: "", ...fields };
|
|
190
|
+
return { channel: null, status: null, date: LAST_48, tag: null, search: "", ...fields };
|
|
175
191
|
}
|
|
176
192
|
/**
|
|
177
|
-
* Whether a channel, a status or a search is chosen: the page then shows every session
|
|
178
|
-
* matches. The period alone keeps the page's shape, each channel cut to its newest.
|
|
193
|
+
* Whether a channel, a status, a tag or a search is chosen: the page then shows every session
|
|
194
|
+
* that matches. The period alone keeps the page's shape, each channel cut to its newest.
|
|
179
195
|
*/
|
|
180
196
|
export function narrowed(filter) {
|
|
181
|
-
return Boolean(filter.channel || filter.status || filter.search);
|
|
197
|
+
return Boolean(filter.channel || filter.status || filter.tag || filter.search);
|
|
182
198
|
}
|
|
183
199
|
/** The day of the machine's own calendar an instant falls on, as a day number. */
|
|
184
200
|
function localDay(epochSeconds) {
|
|
@@ -197,15 +213,20 @@ function casefold(text) {
|
|
|
197
213
|
* Whether `row` passes every chosen filter. The date is the thread's last reply: `today` and
|
|
198
214
|
* `yesterday` are calendar days of the machine's own time zone, each instant read with the
|
|
199
215
|
* offset in force at that instant (a day a clock change makes 23 or 25 hours long stays one day);
|
|
200
|
-
* the others reach back from now.
|
|
216
|
+
* the others reach back from now. A pinned session passes the period whatever its last reply;
|
|
217
|
+
* the channel, the status, the tag and the search still apply to it.
|
|
201
218
|
*/
|
|
202
219
|
export function matches(filter, row, now) {
|
|
203
220
|
if (filter.channel && row.channelId !== filter.channel)
|
|
204
221
|
return false;
|
|
205
222
|
if (filter.status && row.status !== filter.status)
|
|
206
223
|
return false;
|
|
224
|
+
if (filter.tag && row.tag !== filter.tag)
|
|
225
|
+
return false;
|
|
207
226
|
if (filter.search && !casefold(row.title).includes(casefold(filter.search)))
|
|
208
227
|
return false;
|
|
228
|
+
if (row.pinned)
|
|
229
|
+
return true;
|
|
209
230
|
if (filter.date === TODAY || filter.date === YESTERDAY) {
|
|
210
231
|
const days = localDay(now.getTime() / 1000) - localDay(row.lastActivity);
|
|
211
232
|
return days === (filter.date === TODAY ? 0 : 1);
|
|
@@ -253,10 +274,13 @@ export function readFilter(values, current) {
|
|
|
253
274
|
const typed = box.value;
|
|
254
275
|
search = typeof typed === "string" ? oneLine(typed, SEARCH_LIMIT) : "";
|
|
255
276
|
}
|
|
277
|
+
// A tag is any name `!tag` accepts; anything else Slack sends keeps the current one.
|
|
278
|
+
const tag = picked(TAG_ACTION, current.tag, null);
|
|
256
279
|
return {
|
|
257
280
|
channel: picked(CHANNEL_ACTION, current.channel, null),
|
|
258
281
|
status: picked(STATUS_ACTION, current.status, WORDS),
|
|
259
282
|
date: picked(DATE_ACTION, current.date, DATES),
|
|
283
|
+
tag: tag === null || TAG_NAME.test(tag) ? tag : current.tag,
|
|
260
284
|
search,
|
|
261
285
|
};
|
|
262
286
|
}
|
|
@@ -280,12 +304,35 @@ function select(actionId, options, chosen) {
|
|
|
280
304
|
const initial = byValue.get(chosen || ALL) ?? byValue.get(ALL);
|
|
281
305
|
return { type: "static_select", action_id: actionId, options: built, initial_option: initial };
|
|
282
306
|
}
|
|
283
|
-
|
|
307
|
+
/**
|
|
308
|
+
* The tags the Tag menu offers: those the rows carry, sorted and cut to what a menu holds beside
|
|
309
|
+
* "All tags", and the one chosen, which stays on the menu when no row carries it (a page built
|
|
310
|
+
* without an answer from Slack) or when the cut leaves it out.
|
|
311
|
+
*/
|
|
312
|
+
function tagsOf(rows, chosen) {
|
|
313
|
+
const found = new Set(rows.flatMap((row) => (row.tag === null ? [] : [row.tag])));
|
|
314
|
+
const listed = [...found].sort().slice(0, CHANNEL_OPTIONS);
|
|
315
|
+
// The chosen tag always stands on the menu: past the cut it takes the last place, so that the
|
|
316
|
+
// menu never reads "All tags" while a tag filters.
|
|
317
|
+
if (chosen && !listed.includes(chosen)) {
|
|
318
|
+
if (listed.length === CHANNEL_OPTIONS)
|
|
319
|
+
listed.pop();
|
|
320
|
+
listed.push(chosen);
|
|
321
|
+
}
|
|
322
|
+
return listed;
|
|
323
|
+
}
|
|
324
|
+
function controlsOf(channels, chosen, tags) {
|
|
284
325
|
// Slack keeps what a control shows for as long as its block keeps its id, whatever
|
|
285
326
|
// `initial_option` a later page carries (seen 2026-10-01: after a restart the menus still
|
|
286
327
|
// showed the choices of the run before). An id that follows the choice makes the controls
|
|
287
328
|
// show what the page was built with: a restart, Show all and a dropped channel included.
|
|
288
|
-
const choice = JSON.stringify([
|
|
329
|
+
const choice = JSON.stringify([
|
|
330
|
+
chosen.channel,
|
|
331
|
+
chosen.status,
|
|
332
|
+
chosen.date,
|
|
333
|
+
chosen.tag,
|
|
334
|
+
chosen.search,
|
|
335
|
+
]);
|
|
289
336
|
const mark = createHash("sha256").update(choice).digest("hex").slice(0, 8);
|
|
290
337
|
const search = {
|
|
291
338
|
type: "plain_text_input",
|
|
@@ -314,6 +361,15 @@ function controlsOf(channels, chosen) {
|
|
|
314
361
|
...[...DATES].map(([key, text]) => [text, key]),
|
|
315
362
|
[slackTexts.HOME_ANY_TIME, ALL],
|
|
316
363
|
], chosen.date),
|
|
364
|
+
// Offered only once a root carries a tag, or a tag is chosen.
|
|
365
|
+
...(tags.length === 0
|
|
366
|
+
? []
|
|
367
|
+
: [
|
|
368
|
+
select(TAG_ACTION, [
|
|
369
|
+
[slackTexts.HOME_ALL_TAGS, ALL],
|
|
370
|
+
...tags.map((tag) => [tag, tag]),
|
|
371
|
+
], chosen.tag),
|
|
372
|
+
]),
|
|
317
373
|
],
|
|
318
374
|
},
|
|
319
375
|
{
|
|
@@ -347,22 +403,40 @@ function cleaningLine(count, names) {
|
|
|
347
403
|
: texts.fill(named(slackTexts.HOME_CLEANING_MANY, names), { count });
|
|
348
404
|
}
|
|
349
405
|
/**
|
|
350
|
-
* The line above the sessions.
|
|
351
|
-
*
|
|
406
|
+
* The line above the sessions. With a control to offer it is a section of full size and the
|
|
407
|
+
* buttons sit in a row under it (`controlsRow`); with none it stays a small context line.
|
|
352
408
|
*/
|
|
353
|
-
function header(time,
|
|
409
|
+
function header(time, hasControls) {
|
|
354
410
|
const text = texts.fill(slackTexts.HOME_HEADER, { time });
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
411
|
+
return hasControls ? section(text) : context(text);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The buttons under the header, Edit first: Edit (Done while editing) when threads can be
|
|
415
|
+
* deleted, Refresh when the daemon can clean up on request. An actions block holds up to 25
|
|
416
|
+
* elements (actions block reference).
|
|
417
|
+
*/
|
|
418
|
+
function controlsRow(editing, canEdit, canRefresh) {
|
|
419
|
+
const elements = [];
|
|
420
|
+
if (canEdit) {
|
|
421
|
+
const edit = {
|
|
422
|
+
type: "button",
|
|
423
|
+
action_id: EDIT_ACTION,
|
|
424
|
+
text: plainTextObject(editing ? slackTexts.HOME_DONE : slackTexts.HOME_EDIT),
|
|
425
|
+
value: editing ? EDIT_OFF : EDIT_ON,
|
|
426
|
+
};
|
|
427
|
+
if (editing)
|
|
428
|
+
edit.style = "primary";
|
|
429
|
+
elements.push(edit);
|
|
430
|
+
}
|
|
431
|
+
if (canRefresh) {
|
|
432
|
+
elements.push({
|
|
433
|
+
type: "button",
|
|
434
|
+
action_id: REFRESH_ACTION,
|
|
435
|
+
text: plainTextObject(slackTexts.HOME_REFRESH),
|
|
436
|
+
value: REFRESH_VALUE,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return elements.length === 0 ? [] : [{ type: "actions", block_id: CONTROLS_BLOCK, elements }];
|
|
366
440
|
}
|
|
367
441
|
/**
|
|
368
442
|
* The update line under the header, in the phase of the update: the offer with its link and an
|
|
@@ -437,6 +511,17 @@ function channelHeader(teamId, channelId, options) {
|
|
|
437
511
|
}
|
|
438
512
|
return head;
|
|
439
513
|
}
|
|
514
|
+
/** The page's line for a delete that left the thread, which the page goes on listing. */
|
|
515
|
+
function deleteLine(refusal, names) {
|
|
516
|
+
switch (refusal.reason) {
|
|
517
|
+
case "busy":
|
|
518
|
+
return slackTexts.HOME_DELETE_BUSY;
|
|
519
|
+
case "refused":
|
|
520
|
+
return texts.fill(named(slackTexts.HOME_DELETE_REFUSED, names), { count: refusal.count });
|
|
521
|
+
case "failed":
|
|
522
|
+
return texts.fill(slackTexts.HOME_DELETE_FAILED, { error: refusal.error });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
440
525
|
function repliesOf(row) {
|
|
441
526
|
return row.replies === 1
|
|
442
527
|
? slackTexts.HOME_REPLY
|
|
@@ -477,7 +562,12 @@ function card(row, channelName, editing, names) {
|
|
|
477
562
|
word = slackTexts.HOME_DELETING;
|
|
478
563
|
else if (row.status)
|
|
479
564
|
word = WORDS.get(row.status) ?? null;
|
|
565
|
+
// A tag satisfies `TAG_NAME`, so it holds no character that mrkdwn reads as markup.
|
|
566
|
+
const marks = [row.pinned ? ":pushpin:" : null, row.tag === null ? null : `\`[${row.tag}]\``]
|
|
567
|
+
.filter((mark) => mark !== null)
|
|
568
|
+
.join(" ");
|
|
480
569
|
const details = [
|
|
570
|
+
marks,
|
|
481
571
|
word,
|
|
482
572
|
row.replies ? repliesOf(row) : null,
|
|
483
573
|
texts.fill(when, { when: dateToken(row.lastActivity, "ago") }),
|
|
@@ -498,7 +588,8 @@ function card(row, channelName, editing, names) {
|
|
|
498
588
|
* search chosen, every channel is a group, the ones with sessions first by their newest, each
|
|
499
589
|
* showing its `PER_CHANNEL` newest and a button to see them all (which chooses that channel);
|
|
500
590
|
* otherwise only what matches, with no such cut. Never past Slack's 100 blocks: the page says
|
|
501
|
-
* when it stops short. With `canEdit` the header
|
|
591
|
+
* when it stops short. With `canEdit` the row under the header carries Edit, and with
|
|
592
|
+
* `canRefresh` Refresh beside it; in `editing` each session
|
|
502
593
|
* that is not in use carries Delete and no channel carries New thread. `notices` are the lines
|
|
503
594
|
* the deletes and clean-ups that did not end left, under the header; `deleting` is how many
|
|
504
595
|
* threads are being deleted or wait for it, said in a line of full size under the header, since a
|
|
@@ -506,12 +597,13 @@ function card(row, channelName, editing, names) {
|
|
|
506
597
|
*/
|
|
507
598
|
export function homeView(rows, channels, options, names) {
|
|
508
599
|
const { teamId, chosen, now } = options;
|
|
509
|
-
const { canEdit = false, deleting = 0, canClean = false } = options;
|
|
600
|
+
const { canEdit = false, canRefresh = false, deleting = 0, canClean = false } = options;
|
|
510
601
|
const cleaning = options.cleaning ?? new Set();
|
|
511
602
|
const editing = (options.editing ?? false) && canEdit;
|
|
512
603
|
const blocks = [
|
|
513
|
-
...controlsOf(channels, chosen),
|
|
514
|
-
header(dateToken(Math.trunc(now.getTime() / 1000), "time"),
|
|
604
|
+
...controlsOf(channels, chosen, tagsOf(rows, chosen.tag)),
|
|
605
|
+
header(dateToken(Math.trunc(now.getTime() / 1000), "time"), canEdit || canRefresh),
|
|
606
|
+
...controlsRow(editing, canEdit, canRefresh),
|
|
515
607
|
...(options.update ? [updateLine(options.update)] : []),
|
|
516
608
|
...(deleting ? [section(deletingLine(deleting, names))] : []),
|
|
517
609
|
...(cleaning.size > 0 ? [section(cleaningLine(cleaning.size, names))] : []),
|
|
@@ -547,7 +639,15 @@ export function homeView(rows, channels, options, names) {
|
|
|
547
639
|
if (row.deleting)
|
|
548
640
|
going.set(row.channelId, (going.get(row.channelId) ?? 0) + 1);
|
|
549
641
|
}
|
|
550
|
-
|
|
642
|
+
// Pinned sessions first, each kind newest first (the rows are): a channel with no filter but the
|
|
643
|
+
// period shows every pinned session and the newest `PER_CHANNEL` of the others.
|
|
644
|
+
const wanted = new Map();
|
|
645
|
+
for (const [id, found] of groups) {
|
|
646
|
+
const pinned = found.filter((row) => row.pinned);
|
|
647
|
+
const others = found.filter((row) => !row.pinned);
|
|
648
|
+
groups.set(id, [...pinned, ...others]);
|
|
649
|
+
wanted.set(id, [...pinned, ...(narrowed(chosen) ? others : others.slice(0, PER_CHANNEL))]);
|
|
650
|
+
}
|
|
551
651
|
let shown = 0;
|
|
552
652
|
for (const [channelId, found] of groups) {
|
|
553
653
|
const cards = wanted.get(channelId) ?? [];
|
|
@@ -625,6 +725,7 @@ export class Home {
|
|
|
625
725
|
deleteThread;
|
|
626
726
|
cleanChannel;
|
|
627
727
|
updateOf;
|
|
728
|
+
cleanupOf;
|
|
628
729
|
debounce;
|
|
629
730
|
clock;
|
|
630
731
|
// Kept in memory like the filters: a restart starts out of edit mode.
|
|
@@ -651,6 +752,8 @@ export class Home {
|
|
|
651
752
|
off = false;
|
|
652
753
|
// Whether the page being built lacks something Slack gave no answer about.
|
|
653
754
|
incomplete = false;
|
|
755
|
+
// Set by Refresh: the next publish asks again about every name and root it holds.
|
|
756
|
+
reread = false;
|
|
654
757
|
current = homeFilter();
|
|
655
758
|
// One publish at a time, each built inside the lock: the one that lands last was built last,
|
|
656
759
|
// so a filter just chosen is never overwritten by an older page.
|
|
@@ -668,6 +771,9 @@ export class Home {
|
|
|
668
771
|
// The threads a state write changed since their root was read: read again at the next
|
|
669
772
|
// publish, which every turn causes at its start and at its end.
|
|
670
773
|
stale = new Set();
|
|
774
|
+
// The channels whose name is asked again at the next publish, the old name standing until Slack
|
|
775
|
+
// answers (a Refresh).
|
|
776
|
+
renew = new Set();
|
|
671
777
|
constructor(slack, options) {
|
|
672
778
|
this.slack = slack;
|
|
673
779
|
this.owner = options.ownerUserId;
|
|
@@ -678,6 +784,7 @@ export class Home {
|
|
|
678
784
|
this.deleteThread = options.delete;
|
|
679
785
|
this.cleanChannel = options.clean;
|
|
680
786
|
this.updateOf = options.update;
|
|
787
|
+
this.cleanupOf = options.cleanup;
|
|
681
788
|
this.debounce = options.debounce ?? DEBOUNCE_SECONDS;
|
|
682
789
|
this.clock = options.clock ?? systemClock;
|
|
683
790
|
}
|
|
@@ -752,14 +859,15 @@ export class Home {
|
|
|
752
859
|
this.deleting.add(key);
|
|
753
860
|
this.notices.delete(key);
|
|
754
861
|
await this.publish();
|
|
755
|
-
let
|
|
862
|
+
let refusal;
|
|
756
863
|
try {
|
|
757
|
-
|
|
864
|
+
refusal = await this.deleteThread(channelId, threadTs);
|
|
758
865
|
}
|
|
759
866
|
finally {
|
|
760
867
|
this.deleting.delete(key);
|
|
761
868
|
}
|
|
762
|
-
if (
|
|
869
|
+
if (refusal !== null) {
|
|
870
|
+
const notice = deleteLine(refusal, this.textNames());
|
|
763
871
|
const label = this.labels.get(key);
|
|
764
872
|
this.say(key, label ? `“${label}”: ${notice}` : notice);
|
|
765
873
|
}
|
|
@@ -799,6 +907,24 @@ export class Home {
|
|
|
799
907
|
}
|
|
800
908
|
await this.publish();
|
|
801
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* The owner's Refresh: the daemon's cleanup runs now (the same pass as at start and every six
|
|
912
|
+
* hours, so a channel Slack no longer has is forgotten), then the page is written again from
|
|
913
|
+
* fresh lookups. A cleanup that fails is logged and the page is written anyway.
|
|
914
|
+
*/
|
|
915
|
+
async refresh() {
|
|
916
|
+
const cleanup = this.cleanupOf?.();
|
|
917
|
+
if (cleanup === null || cleanup === undefined)
|
|
918
|
+
return;
|
|
919
|
+
try {
|
|
920
|
+
await cleanup.request();
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
logger.warning(`could not clean up for Refresh: ${describe(error)}`);
|
|
924
|
+
}
|
|
925
|
+
this.reread = true;
|
|
926
|
+
await this.publish();
|
|
927
|
+
}
|
|
802
928
|
/**
|
|
803
929
|
* Publish what a pending request still owed, then stop: the page a stop leaves behind shows the
|
|
804
930
|
* sessions as the stop left them. Gives up after `CLOSE_SECONDS`.
|
|
@@ -858,6 +984,16 @@ export class Home {
|
|
|
858
984
|
}
|
|
859
985
|
}
|
|
860
986
|
async publishPage(signal) {
|
|
987
|
+
if (this.reread) {
|
|
988
|
+
// Marked, not forgotten: a read that gets no answer then falls back on what was read before
|
|
989
|
+
// (`root`, `channelsOf`), so a rate limit during a Refresh does not empty the page. A
|
|
990
|
+
// permalink depends on its message's `ts` alone and is asked again only when that changed.
|
|
991
|
+
this.reread = false;
|
|
992
|
+
for (const channelId of this.names.keys())
|
|
993
|
+
this.renew.add(channelId);
|
|
994
|
+
for (const key of this.facts.keys())
|
|
995
|
+
this.stale.add(key);
|
|
996
|
+
}
|
|
861
997
|
const bound = this.state.channels();
|
|
862
998
|
const channels = await this.channelsOf(bound, signal);
|
|
863
999
|
if (bound.length > 0 && channels.size === 0 && this.incomplete) {
|
|
@@ -870,11 +1006,18 @@ export class Home {
|
|
|
870
1006
|
if (chosen && (!bound.includes(chosen) || this.names.get(chosen) === null)) {
|
|
871
1007
|
this.current = { ...this.current, channel: null };
|
|
872
1008
|
}
|
|
1009
|
+
// A tag no root carries any more (an `!untag`) is no filter, unless a root Slack did not
|
|
1010
|
+
// answer about may still carry it.
|
|
1011
|
+
const tag = this.current.tag;
|
|
1012
|
+
if (tag && !this.incomplete && !rows.some((row) => row.tag === tag)) {
|
|
1013
|
+
this.current = { ...this.current, tag: null };
|
|
1014
|
+
}
|
|
873
1015
|
const view = homeView(rows, channels, {
|
|
874
1016
|
teamId: this.team,
|
|
875
1017
|
chosen: this.current,
|
|
876
1018
|
now: new Date(this.clock.time() * 1000),
|
|
877
1019
|
canEdit: this.deleteThread !== undefined,
|
|
1020
|
+
canRefresh: (this.cleanupOf?.() ?? null) !== null,
|
|
878
1021
|
editing: this.editing,
|
|
879
1022
|
notices: [...this.notices.values()],
|
|
880
1023
|
deleting: this.deleting.size,
|
|
@@ -894,12 +1037,15 @@ export class Home {
|
|
|
894
1037
|
const found = new Map();
|
|
895
1038
|
// A channel forgotten and bound again later is asked about again.
|
|
896
1039
|
for (const unbound of [...this.names.keys()]) {
|
|
897
|
-
if (!bound.includes(unbound))
|
|
1040
|
+
if (!bound.includes(unbound)) {
|
|
898
1041
|
this.names.delete(unbound);
|
|
1042
|
+
this.renew.delete(unbound);
|
|
1043
|
+
}
|
|
899
1044
|
}
|
|
900
1045
|
for (const channelId of bound) {
|
|
901
1046
|
signal?.throwIfAborted();
|
|
902
|
-
if (!this.names.has(channelId)) {
|
|
1047
|
+
if (!this.names.has(channelId) || this.renew.has(channelId)) {
|
|
1048
|
+
this.renew.delete(channelId);
|
|
903
1049
|
try {
|
|
904
1050
|
const info = await this.slack.conversations.info({ channel: channelId });
|
|
905
1051
|
const name = info.channel?.name;
|
|
@@ -911,9 +1057,13 @@ export class Home {
|
|
|
911
1057
|
logger.warning(`could not read channel ${channelId}: ${describe(error)}`);
|
|
912
1058
|
if (!gone(error)) {
|
|
913
1059
|
this.incomplete = true;
|
|
914
|
-
|
|
1060
|
+
// The name read before stands; with none the channel is left out for now.
|
|
1061
|
+
if (!this.names.has(channelId))
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
this.names.set(channelId, null);
|
|
915
1066
|
}
|
|
916
|
-
this.names.set(channelId, null);
|
|
917
1067
|
}
|
|
918
1068
|
}
|
|
919
1069
|
const name = this.names.get(channelId);
|
|
@@ -958,6 +1108,8 @@ export class Home {
|
|
|
958
1108
|
replies: facts.replies,
|
|
959
1109
|
lastActivity: facts.latestReply || Math.trunc(pyFloat(threadTs)),
|
|
960
1110
|
permalink: link,
|
|
1111
|
+
tag: facts.tag,
|
|
1112
|
+
pinned: facts.pinned,
|
|
961
1113
|
deleting: this.deleting.has(keyOf(channelId, threadTs)),
|
|
962
1114
|
});
|
|
963
1115
|
});
|
|
@@ -1019,6 +1171,13 @@ export class Home {
|
|
|
1019
1171
|
const root = messages.find((m) => isObject(m) && String(m.ts) === threadTs);
|
|
1020
1172
|
if (!isObject(root))
|
|
1021
1173
|
throw new RootNotReturned();
|
|
1174
|
+
// A root deleted while replies remain is returned as a tombstone: the thread is gone, as
|
|
1175
|
+
// when Slack answers `thread_not_found` (`channels.isTombstone`).
|
|
1176
|
+
if (isTombstone(root)) {
|
|
1177
|
+
this.stale.delete(key);
|
|
1178
|
+
this.facts.set(key, null);
|
|
1179
|
+
return null;
|
|
1180
|
+
}
|
|
1022
1181
|
facts = threadFacts(root);
|
|
1023
1182
|
}
|
|
1024
1183
|
catch (error) {
|