allaboard 0.4.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.
Files changed (53) hide show
  1. package/README.md +9 -7
  2. package/dist/agent/claude/backend.js +73 -2
  3. package/dist/agent/claude/capabilities.js +4 -0
  4. package/dist/agent/claude/module.js +55 -0
  5. package/dist/agent/claude/translate.js +6 -2
  6. package/dist/agent/claude/trust.js +22 -0
  7. package/dist/chat/seam.js +3 -0
  8. package/dist/chat/slack/app/app.js +52 -4
  9. package/dist/chat/slack/app/delete-thread.js +90 -0
  10. package/dist/chat/slack/app/guards.js +44 -0
  11. package/dist/chat/slack/app/home-controls.js +8 -1
  12. package/dist/chat/slack/channels.js +23 -5
  13. package/dist/chat/slack/delete-thread.js +37 -0
  14. package/dist/chat/slack/delete.js +13 -7
  15. package/dist/chat/slack/home.js +194 -35
  16. package/dist/chat/slack/module.js +64 -14
  17. package/dist/chat/slack/onboarding.js +159 -0
  18. package/dist/chat/slack/reply/blocks.js +27 -11
  19. package/dist/chat/slack/reply/sinks.js +3 -1
  20. package/dist/chat/slack/tag.js +317 -0
  21. package/dist/chat/slack/texts.js +8 -0
  22. package/dist/chat/slack/thread.js +62 -2
  23. package/dist/chat/slack/tokens.js +15 -0
  24. package/dist/cli.js +43 -4
  25. package/dist/core/cleanup.js +66 -3
  26. package/dist/core/commands.js +40 -0
  27. package/dist/core/config.js +19 -11
  28. package/dist/core/files/listing.js +19 -0
  29. package/dist/core/flows/bind.js +29 -6
  30. package/dist/core/flows/flows.js +2 -0
  31. package/dist/core/flows/gone.js +39 -0
  32. package/dist/core/flows/inbound.js +3 -0
  33. package/dist/core/flows/messages.js +4 -2
  34. package/dist/core/flows/status.js +98 -48
  35. package/dist/core/flows/words.js +80 -1
  36. package/dist/core/guide.js +2 -0
  37. package/dist/core/port/cleanup.js +1 -0
  38. package/dist/core/port.js +1 -0
  39. package/dist/core/sessions/directory.js +47 -8
  40. package/dist/core/sessions/manager.js +32 -17
  41. package/dist/core/sessions/quote.js +57 -0
  42. package/dist/core/sessions/session.js +324 -35
  43. package/dist/core/sessions/title.js +98 -0
  44. package/dist/core/state.js +25 -0
  45. package/dist/core/texts.js +58 -3
  46. package/dist/core/wizard.js +306 -0
  47. package/dist/daemon.js +23 -11
  48. package/dist/host/program.js +22 -7
  49. package/dist/host/prompter.js +61 -0
  50. package/dist/module-setup.js +6 -0
  51. package/dist/version.js +4 -0
  52. package/package.json +5 -2
  53. 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 the line that says why it is
89
- * still there. Never throws.
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
- return slackTexts.HOME_DELETE_BUSY;
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 fill(named(slackTexts.HOME_DELETE_REFUSED, this.#names()), { count: refused });
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 fill(slackTexts.HOME_DELETE_FAILED, { error: describe(error) });
121
+ return { reason: "failed", error: describe(error) };
117
122
  }
118
123
  finally {
119
- this.#free(channelId, threadTs);
124
+ if (!notHeld)
125
+ this.#free(channelId, threadTs);
120
126
  }
121
127
  logger.info(`deleted thread ${channelId}/${threadTs}`);
122
128
  return null;
@@ -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 = [CHANNEL_ACTION, STATUS_ACTION, DATE_ACTION, SEARCH_ACTION];
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 that
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
- function controlsOf(channels, chosen) {
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([chosen.channel, chosen.status, chosen.date, chosen.search]);
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. It carries the Edit button when threads can be deleted: a context
351
- * block holds no button (context block reference), so the line is a section then.
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, editing, canEdit) {
409
+ function header(time, hasControls) {
354
410
  const text = texts.fill(slackTexts.HOME_HEADER, { time });
355
- if (!canEdit)
356
- return context(text);
357
- const button = {
358
- type: "button",
359
- action_id: EDIT_ACTION,
360
- text: plainTextObject(editing ? slackTexts.HOME_DONE : slackTexts.HOME_EDIT),
361
- value: editing ? EDIT_OFF : EDIT_ON,
362
- };
363
- if (editing)
364
- button.style = "primary";
365
- return { type: "section", text: { type: "mrkdwn", text }, accessory: button };
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 line carries Edit; in `editing` each session
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"), editing, canEdit),
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
- const wanted = new Map([...groups].map(([id, found]) => [id, narrowed(chosen) ? found : found.slice(0, PER_CHANNEL)]));
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 notice;
862
+ let refusal;
756
863
  try {
757
- notice = await this.deleteThread(channelId, threadTs);
864
+ refusal = await this.deleteThread(channelId, threadTs);
758
865
  }
759
866
  finally {
760
867
  this.deleting.delete(key);
761
868
  }
762
- if (notice) {
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
- continue;
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) {