slack-term 1.29.0 → 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.29.0",
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,
@@ -892,14 +893,34 @@ function friendlySlackError(e: unknown): string {
892
893
 
893
894
  /** Dry-run gate: print context, print required --code=, exit 1.
894
895
  * Call this when --code is absent or wrong. */
895
- 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 {
896
917
  // The clock goes to stdout with the rest of the preview, not to stderr with
897
918
  // the code. The preview is what you are asked to LOOK at before committing,
898
919
  // and "what time is it where they are" is part of that picture — a caller
899
920
  // doing `send ... 2>/dev/null` to read the preview would otherwise drop the
900
921
  // one line added to stop a 01:00 send.
901
922
  for (const line of contextLines) console.log(line);
902
- const clock = quietHoursNotice();
923
+ const clock = quietHoursNotice(new Date(), recipientTz);
903
924
  if (clock) console.log(clock);
904
925
  if (provided !== undefined) {
905
926
  console.error(`Code mismatch (got ${provided}, expected ${expected})`);
@@ -1498,6 +1519,9 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1498
1519
  // profile / SLACK_TOKEN / --as-bot switch between preview and confirm
1499
1520
  // re-previews instead). Empty on lookup failure, same fail-soft as lastText.
1500
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);
1501
1525
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
1502
1526
 
1503
1527
  if (args.code !== code) {
@@ -1520,7 +1544,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1520
1544
  ` → ${dest} thread of ${parentLine} — THREAD REPLY`,
1521
1545
  ` Message: ${message}`,
1522
1546
  `--------------------------------────────────`,
1523
- ]);
1547
+ ], recipientTz);
1524
1548
  } else {
1525
1549
  requireCode(args.code, code, [
1526
1550
  `--- Last message in channel ------------------`,
@@ -1531,7 +1555,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1531
1555
  ` → ${dest} — NEW top-level message`,
1532
1556
  ` Message: ${message}`,
1533
1557
  `--------------------------------────────────`,
1534
- ]);
1558
+ ], recipientTz);
1535
1559
  }
1536
1560
  }
1537
1561
  const ts = await slackSend(token, channelId, message, threadTs, args.broadcast, cookie);
@@ -2035,6 +2059,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2035
2059
  // satisfies by calling twice. The hash binds destination, thread, the exact
2036
2060
  // posted body (question AND choices) and the asking identity.
2037
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);
2038
2065
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
2039
2066
  if (args.code !== code) {
2040
2067
  const dest = await destLabel(token, channelId, ref, cookie);
@@ -2056,7 +2083,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2056
2083
  ` Answerable by: ${audienceLabel}`,
2057
2084
  ` Via: ${threadOnly ? "reactions or thread replies" : "reactions or replies in this DM"}`,
2058
2085
  `--------------------------------────────────`,
2059
- ]);
2086
+ ], recipientTz);
2060
2087
  }
2061
2088
 
2062
2089
  // `plain`: with blocks attached Slack rewrites the stored text (newlines to
@@ -2353,6 +2380,9 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2353
2380
  }
2354
2381
 
2355
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);
2356
2386
  const code = safetyCode(channelId, threadTs ?? "", lastText, message, self?.userId ?? "");
2357
2387
  if (args.code !== code) {
2358
2388
  const dest = await destLabel(token, channelId, ref, cookie);
@@ -2369,7 +2399,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
2369
2399
  // Said out loud at the gate: this is the surprising half of the command.
2370
2400
  ` Note: あなた自身の票は数えません (ピルはあなたが付けた種なので区別できません)`,
2371
2401
  `--------------------------------────────────`,
2372
- ]);
2402
+ ], recipientTz);
2373
2403
  }
2374
2404
 
2375
2405
  // `plain`: no blocks, or Slack rewrites the stored text and the ballot
package/ts/quietHours.ts CHANGED
@@ -9,11 +9,35 @@
9
9
  * path every send already takes does not. (Real incident 2026-08-26: messages
10
10
  * sent to an external partner at 00:44 and 01:12 JST.)
11
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
+ *
12
32
  * Deliberately a WARNING, not a block: pager-duty style P1 outage notices
13
33
  * legitimately go out at 3am, and this CLI cannot tell those apart. Blocking
14
34
  * would train people to bypass the gate entirely. */
15
- export function quietHoursNotice(now: Date = new Date()): string | null {
16
- const tz = process.env.SLACK_QUIET_TZ ?? "Asia/Tokyo";
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;
17
41
  const start = Number(process.env.SLACK_QUIET_START ?? 23); // inclusive
18
42
  const end = Number(process.env.SLACK_QUIET_END ?? 8); // exclusive
19
43
  let hour: number;
@@ -37,7 +61,21 @@ export function quietHoursNotice(now: Date = new Date()): string | null {
37
61
  return null; // bad TZ override — never let the clock break sending
38
62
  }
39
63
  const quiet = start > end ? hour >= start || hour < end : hour >= start && hour < end;
40
- const label = `🕐 ${stamp} (${tz})`;
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})`;
41
68
  if (!quiet) return label;
42
- return `${label}\n⚠ QUIET HOURS (${start}:00-${end}:00) — this will buzz a phone. Send only for a production incident; otherwise hold it until morning.`;
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
+ }
43
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 = {