slack-term 1.28.1 → 1.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.28.1",
3
+ "version": "1.30.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -85,6 +85,7 @@ import {
85
85
  todoDoctor,
86
86
  todoFlag,
87
87
  userHandleCached,
88
+ userTzCached,
88
89
  todoSet,
89
90
  withRateLimitRetry,
90
91
  DOCTOR_DEFAULT_LIMIT,
@@ -95,6 +96,7 @@ import {
95
96
  } from "./todo.ts";
96
97
  import { setCacheEnabled } from "./cache.ts";
97
98
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
99
+ import { quietHoursNotice } from "./quietHours.ts";
98
100
 
99
101
  function loadDotenv(path: string): void {
100
102
  if (!existsSync(path)) return;
@@ -891,8 +893,35 @@ function friendlySlackError(e: unknown): string {
891
893
 
892
894
  /** Dry-run gate: print context, print required --code=, exit 1.
893
895
  * Call this when --code is absent or wrong. */
894
- function requireCode(provided: string | undefined, expected: string, contextLines: string[]): void {
896
+ /** The recipient's timezone for the quiet-hours label — DM only.
897
+ *
898
+ * A channel has many recipients in potentially many zones, and resolving every
899
+ * member would cost a lookup per person to answer a question that has no single
900
+ * answer. DMs are also where a 3am notification actually lands on someone's
901
+ * phone, so this is where the check earns its keep.
902
+ *
903
+ * Returns null (= "unknown", label says so) for channels, for a self-DM, and
904
+ * for anyone Slack has no timezone for. */
905
+ async function recipientTzFor(token: string, channelId: string, cookie?: string): Promise<string | null> {
906
+ if (!channelId.startsWith("D")) return null;
907
+ try {
908
+ const other = await imCounterpart(token, channelId, cookie);
909
+ if (!other) return null;
910
+ return await userTzCached(token, other, userInfo, cookie);
911
+ } catch {
912
+ return null;
913
+ }
914
+ }
915
+
916
+ function requireCode(provided: string | undefined, expected: string, contextLines: string[], recipientTz?: string | null): void {
917
+ // The clock goes to stdout with the rest of the preview, not to stderr with
918
+ // the code. The preview is what you are asked to LOOK at before committing,
919
+ // and "what time is it where they are" is part of that picture — a caller
920
+ // doing `send ... 2>/dev/null` to read the preview would otherwise drop the
921
+ // one line added to stop a 01:00 send.
895
922
  for (const line of contextLines) console.log(line);
923
+ const clock = quietHoursNotice(new Date(), recipientTz);
924
+ if (clock) console.log(clock);
896
925
  if (provided !== undefined) {
897
926
  console.error(`Code mismatch (got ${provided}, expected ${expected})`);
898
927
  }
@@ -1490,6 +1519,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1490
1519
  // profile / SLACK_TOKEN / --as-bot switch between preview and confirm
1491
1520
  // re-previews instead). Empty on lookup failure, same fail-soft as lastText.
1492
1521
  const self = await getSelf();
1522
+ // Resolved before the gate so the clock can name the recipient's zone. DM
1523
+ // only; null means "unknown" and the label says so rather than guessing.
1524
+ const recipientTz = await recipientTzFor(token, channelId, cookie);
1493
1525
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
1494
1526
 
1495
1527
  if (args.code !== code) {
@@ -1512,7 +1544,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1512
1544
  ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1513
1545
  ` Message: ${message}`,
1514
1546
  `--------------------------------────────────`,
1515
- ]);
1547
+ ], recipientTz);
1516
1548
  } else {
1517
1549
  requireCode(args.code, code, [
1518
1550
  `--- Last message in channel ------------------`,
@@ -1523,7 +1555,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1523
1555
  ` → ${dest} — NEW top-level message`,
1524
1556
  ` Message: ${message}`,
1525
1557
  `--------------------------------────────────`,
1526
- ]);
1558
+ ], recipientTz);
1527
1559
  }
1528
1560
  }
1529
1561
  const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie);
@@ -2027,6 +2059,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2027
2059
  // satisfies by calling twice. The hash binds destination, thread, the exact
2028
2060
  // posted body (question AND choices) and the asking identity.
2029
2061
  const self = await getSelf();
2062
+ // Resolved before the gate so the clock can name the recipient's zone. DM
2063
+ // only; null means "unknown" and the label says so rather than guessing.
2064
+ const recipientTz = await recipientTzFor(token, channelId, cookie);
2030
2065
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
2031
2066
  if (args.code !== code) {
2032
2067
  const dest = await destLabel(token, channelId, ref, cookie);
@@ -2048,7 +2083,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2048
2083
  ` Answerable by: ${audienceLabel}`,
2049
2084
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
2050
2085
  `--------------------------------────────────`,
2051
- ]);
2086
+ ], recipientTz);
2052
2087
  }
2053
2088
 
2054
2089
  // `plain`: with blocks attached Slack rewrites the stored text (newlines to
@@ -2345,6 +2380,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2345
2380
  }
2346
2381
 
2347
2382
  const self = await getSelf();
2383
+ // Resolved before the gate so the clock can name the recipient's zone. DM
2384
+ // only; null means "unknown" and the label says so rather than guessing.
2385
+ const recipientTz = await recipientTzFor(token, channelId, cookie);
2348
2386
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
2349
2387
  if (args.code !== code) {
2350
2388
  const dest = await destLabel(token, channelId, ref, cookie);
@@ -2361,7 +2399,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2361
2399
  // Said out loud at the gate: this is the surprising half of the command.
2362
2400
  ` Note: あなた自身の票は数えません (ピルはあなたが付けた種なので区別できません)`,
2363
2401
  `--------------------------------────────────`,
2364
- ]);
2402
+ ], recipientTz);
2365
2403
  }
2366
2404
 
2367
2405
  // `plain`: no blocks, or Slack rewrites the stored text and the ballot
@@ -0,0 +1,81 @@
1
+ /** Quiet hours in the recipients' timezone: sending here makes a human's phone
2
+ * buzz while they're asleep. Default JST because this workspace is in Japan;
3
+ * override with SLACK_QUIET_TZ / SLACK_QUIET_START / SLACK_QUIET_END.
4
+ *
5
+ * WHY this lives in the confirm gate rather than in a doc: an agent driving
6
+ * this CLI has no reason to look at a clock — the moment work finishes IS the
7
+ * moment it sends, and in a long session it doesn't notice the date rolled
8
+ * over. A rule that must be remembered gets forgotten; a line printed on the
9
+ * path every send already takes does not. (Real incident 2026-08-26: messages
10
+ * sent to an external partner at 00:44 and 01:12 JST.)
11
+ *
12
+ * WHOSE clock: the recipient's, when we can learn it — "is it the middle of
13
+ * the night" is a fact about the person whose phone buzzes, not about the
14
+ * sender. `recipientTz` carries that when the caller could resolve it.
15
+ *
16
+ * When it is UNKNOWN we still judge, using the sender's zone, and say plainly
17
+ * that we did. That is the whole design decision here, and the tempting
18
+ * alternatives are both worse:
19
+ *
20
+ * - Silently falling back to the default zone LOOKS clean and reads as if
21
+ * we knew the recipient's time. It lies.
22
+ * - Declining to judge at all is honest, but it hands the decision back to
23
+ * an agent that has no reason to look at a clock — which is the exact
24
+ * failure this exists to prevent. Measured against the real incident,
25
+ * that option is the ONLY one of the three that stays silent at 00:44.
26
+ *
27
+ * So: warn, and label the basis. Unknown is not the rare case — Slack Connect
28
+ * counterparts have no `tz` field at all (measured 2026-08-26 on every
29
+ * external user in this workspace, including the person the 00:44 message
30
+ * actually went to). Falling silent there would reinstate the incident.
31
+ *
32
+ * Deliberately a WARNING, not a block: pager-duty style P1 outage notices
33
+ * legitimately go out at 3am, and this CLI cannot tell those apart. Blocking
34
+ * would train people to bypass the gate entirely. */
35
+ export function quietHoursNotice(now: Date = new Date(), recipientTz?: string | null): string | null {
36
+ const fallbackTz = process.env.SLACK_QUIET_TZ ?? "Asia/Tokyo";
37
+ // An unusable recipient zone is treated as unknown, not as a reason to fail:
38
+ // the sender's zone still gives a defensible answer, and the label says so.
39
+ const known = !!recipientTz && isUsableTz(recipientTz);
40
+ const tz = known ? recipientTz! : fallbackTz;
41
+ const start = Number(process.env.SLACK_QUIET_START ?? 23); // inclusive
42
+ const end = Number(process.env.SLACK_QUIET_END ?? 8); // exclusive
43
+ let hour: number;
44
+ let stamp: string;
45
+ try {
46
+ const fmt = new Intl.DateTimeFormat("en-GB", {
47
+ timeZone: tz,
48
+ hour12: false,
49
+ weekday: "short",
50
+ year: "numeric",
51
+ month: "2-digit",
52
+ day: "2-digit",
53
+ hour: "2-digit",
54
+ minute: "2-digit",
55
+ });
56
+ stamp = fmt.format(now);
57
+ hour = Number(
58
+ fmt.formatToParts(now).find((p) => p.type === "hour")?.value ?? "12",
59
+ );
60
+ } catch {
61
+ return null; // bad TZ override — never let the clock break sending
62
+ }
63
+ const quiet = start > end ? hour >= start || hour < end : hour >= start && hour < end;
64
+ // Name the basis, always. A reader who cannot tell whose clock this is cannot
65
+ // judge how much to trust it.
66
+ const basis = known ? `${tz}, recipient` : `${tz}, sender — recipient's timezone unknown`;
67
+ const label = `🕐 ${stamp} (${basis})`;
68
+ if (!quiet) return label;
69
+ const caveat = known ? "" : " Their local time may differ.";
70
+ return `${label}\n⚠ QUIET HOURS (${start}:00-${end}:00) — this will buzz a phone.${caveat} Send only for a production incident; otherwise hold it until morning.`;
71
+ }
72
+
73
+ /** Does Intl accept this zone? A bad value must degrade to "unknown", never throw. */
74
+ function isUsableTz(tz: string): boolean {
75
+ try {
76
+ new Intl.DateTimeFormat("en-GB", { timeZone: tz });
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
package/ts/todo.ts CHANGED
@@ -299,6 +299,52 @@ export async function userHandleCached(
299
299
  return handle;
300
300
  }
301
301
 
302
+ /** The recipient's IANA timezone, or null when Slack does not know it.
303
+ *
304
+ * Cached on the same 1h TTL as handles, so a send costs no extra API call in
305
+ * practice. `users:read` failures are swallowed: a missing scope must degrade
306
+ * the quiet-hours label, never block sending.
307
+ *
308
+ * NOTE the shape check. Slack Connect counterparts have NO `tz` KEY at all —
309
+ * not `tz: null`, the key is simply absent (measured 2026-08-26 on every
310
+ * external user in this workspace). So `=== null` would sail straight past it;
311
+ * only a truthy-string test is correct here. */
312
+ export async function userTzCached(
313
+ token: string,
314
+ userId: string,
315
+ lookup: (token: string, userId: string, cookie?: string) => Promise<unknown>,
316
+ cookie?: string,
317
+ ): Promise<string | null> {
318
+ const scope = await cacheScope(token, cookie);
319
+ const hit = cacheGet("users", scope, `tz:${userId}`);
320
+ if (hit) return hit === "?" ? null : hit;
321
+ let tz: string | null = null;
322
+ try {
323
+ const resp = (await withRateLimitRetry("users.info", () => lookup(token, userId, cookie))) as
324
+ { user?: { tz?: unknown; is_bot?: unknown } };
325
+ // A bot reports America/Los_Angeles (Slack's own default) and has no phone
326
+ // to buzz, so its "timezone" is a false signal — treat it as unknown rather
327
+ // than warn about the middle of the night in California. Measured on this
328
+ // workspace's bot user, and the same bogus value shows up in users.list.
329
+ if (resp?.user?.is_bot === true) return cacheThen(scope, userId, null);
330
+ const raw = resp?.user?.tz;
331
+ tz = typeof raw === "string" && raw ? raw : null;
332
+ } catch {
333
+ // No users:read, rate limit exhausted, network — all mean "unknown", and
334
+ // unknown is a state the caller already handles. Never fail the send.
335
+ return null;
336
+ }
337
+ return cacheThen(scope, userId, tz);
338
+ }
339
+
340
+ /** Persist and return. "?" records a definite "Slack has no timezone for this
341
+ * person", so we do not re-ask every send for the external users and bots that
342
+ * will never have a usable one. */
343
+ function cacheThen(scope: string, userId: string, tz: string | null): string | null {
344
+ cacheSet("users", scope, `tz:${userId}`, tz ?? "?");
345
+ return tz;
346
+ }
347
+
302
348
  // ── set / flag ────────────────────────────────────────────────────────────
303
349
 
304
350
  export type SetResult = {