slack-term 1.29.0 → 1.30.1
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/dist/cli.js +105 -105
- package/package.json +1 -1
- package/ts/cli.ts +36 -6
- package/ts/quietHours.ts +42 -4
- package/ts/todo.ts +54 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,60 @@ 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: 12 of
|
|
328
|
+
// this workspace's 14 bots report exactly that, so it is Slack's default
|
|
329
|
+
// rather than one app's misconfiguration.
|
|
330
|
+
//
|
|
331
|
+
// USLACKBOT needs naming separately because it reports `is_bot: false`
|
|
332
|
+
// while carrying the same bogus zone — the one account where the flag and
|
|
333
|
+
// the reality disagree. `format.ts` and the `user ls` filter already
|
|
334
|
+
// special-case it for the same reason.
|
|
335
|
+
if (resp?.user?.is_bot === true || userId === "USLACKBOT") {
|
|
336
|
+
return cacheThen(scope, userId, null);
|
|
337
|
+
}
|
|
338
|
+
const raw = resp?.user?.tz;
|
|
339
|
+
tz = typeof raw === "string" && raw ? raw : null;
|
|
340
|
+
} catch {
|
|
341
|
+
// No users:read, rate limit exhausted, network — all mean "unknown", and
|
|
342
|
+
// unknown is a state the caller already handles. Never fail the send.
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
return cacheThen(scope, userId, tz);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Persist and return. "?" records a definite "Slack has no timezone for this
|
|
349
|
+
* person", so we do not re-ask every send for the external users and bots that
|
|
350
|
+
* will never have a usable one. */
|
|
351
|
+
function cacheThen(scope: string, userId: string, tz: string | null): string | null {
|
|
352
|
+
cacheSet("users", scope, `tz:${userId}`, tz ?? "?");
|
|
353
|
+
return tz;
|
|
354
|
+
}
|
|
355
|
+
|
|
302
356
|
// ── set / flag ────────────────────────────────────────────────────────────
|
|
303
357
|
|
|
304
358
|
export type SetResult = {
|