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/dist/cli.js +105 -104
- package/package.json +1 -1
- package/ts/cli.ts +43 -5
- package/ts/quietHours.ts +81 -0
- package/ts/todo.ts +46 -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,
|
|
@@ -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
|
-
|
|
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
|
package/ts/quietHours.ts
ADDED
|
@@ -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 = {
|