slack-term 1.39.1 → 1.40.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/README.md +1 -1
- package/SKILL.md +24 -2
- package/dist/cli.js +175 -161
- package/package.json +1 -1
- package/ts/attachments.ts +71 -0
- package/ts/cli.ts +265 -16
- package/ts/slack.ts +56 -11
- package/ts/slackTodos.ts +85 -0
- package/ts/stdout.ts +59 -0
- package/ts/urlGuard.ts +162 -10
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Message attachments — forwarded/shared messages and legacy bot attachments.
|
|
2
|
+
//
|
|
3
|
+
// A message forwarded with Slack's "Forward" (or shared via its link) arrives
|
|
4
|
+
// as an ordinary message whose own `text` is only the comment typed with it;
|
|
5
|
+
// the forwarded message itself lives in `attachments[]`, flagged `is_share`
|
|
6
|
+
// (or `is_msg_unfurl` for a pasted message link), carrying the original
|
|
7
|
+
// author, channel, ts, text and permalink (`from_url`). Older shares put the
|
|
8
|
+
// text in `message_blocks[].message` instead, with `fallback` as the summary.
|
|
9
|
+
// Plain link unfurls (`original_url` set) are previews of a web page, not
|
|
10
|
+
// content anyone wrote, so the text view leaves them out.
|
|
11
|
+
|
|
12
|
+
import type { Json } from "./slack.ts";
|
|
13
|
+
|
|
14
|
+
const rec = (v: Json | undefined): Record<string, Json> =>
|
|
15
|
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
|
|
16
|
+
const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
|
|
17
|
+
|
|
18
|
+
export type Attachment = {
|
|
19
|
+
/** A forwarded/shared Slack message (as opposed to a bot or link attachment). */
|
|
20
|
+
shared: boolean;
|
|
21
|
+
/** A web-page link preview — omitted from the text view. */
|
|
22
|
+
unfurl: boolean;
|
|
23
|
+
author_id: string | null;
|
|
24
|
+
author_name: string | null;
|
|
25
|
+
channel_id: string | null;
|
|
26
|
+
ts: string | null;
|
|
27
|
+
title: string | null;
|
|
28
|
+
text: string;
|
|
29
|
+
permalink: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Older shares carry the text only inside message_blocks[].message.text.
|
|
33
|
+
function messageBlocksText(a: Record<string, Json>): string {
|
|
34
|
+
const blocks = Array.isArray(a.message_blocks) ? a.message_blocks : [];
|
|
35
|
+
return blocks.map((b) => str(rec(rec(b).message).text)).filter(Boolean).join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseAttachments(m: Record<string, Json>): Attachment[] {
|
|
39
|
+
const list = Array.isArray(m.attachments) ? m.attachments : [];
|
|
40
|
+
return list.map(rec).map((a) => {
|
|
41
|
+
const shared = a.is_share === true || a.is_msg_unfurl === true || Array.isArray(a.message_blocks);
|
|
42
|
+
const mb = rec(rec((Array.isArray(a.message_blocks) ? a.message_blocks : [])[0]));
|
|
43
|
+
return {
|
|
44
|
+
shared,
|
|
45
|
+
unfurl: !shared && !!str(a.original_url),
|
|
46
|
+
author_id: str(a.author_id) || str(rec(mb.message).user) || null,
|
|
47
|
+
author_name: str(a.author_name) || null,
|
|
48
|
+
channel_id: str(a.channel_id) || str(mb.channel) || null,
|
|
49
|
+
ts: str(a.ts) || str(mb.ts) || null,
|
|
50
|
+
title: str(a.title) || null,
|
|
51
|
+
text: str(a.text) || messageBlocksText(a) || str(a.pretext) || str(a.fallback),
|
|
52
|
+
permalink: str(a.from_url) || str(a.title_link) || str(a.original_url) || null,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The text-view lines for a message's attachments (each starts with "\n "). */
|
|
58
|
+
export function attachmentLines(atts: Attachment[], tsLabel: (ts: string) => string): string {
|
|
59
|
+
return atts
|
|
60
|
+
.filter((a) => !a.unfurl && (a.text || a.title))
|
|
61
|
+
.map((a) => {
|
|
62
|
+
const who = a.author_name ?? a.author_id;
|
|
63
|
+
const head = a.shared
|
|
64
|
+
? `↪ shared message${who ? ` from @${who}` : ""}${a.channel_id ? ` in <#${a.channel_id}>` : ""}${a.ts ? ` · ${tsLabel(a.ts)}` : ""}`
|
|
65
|
+
: `▸ ${a.title ?? "attachment"}`;
|
|
66
|
+
const lines = a.text ? a.text.replace(/\n+$/, "").split("\n").map((l) => `\n │ ${l}`).join("") : "";
|
|
67
|
+
const link = a.permalink ? `\n ${a.permalink}` : "";
|
|
68
|
+
return `\n ${head}${lines}${link}`;
|
|
69
|
+
})
|
|
70
|
+
.join("");
|
|
71
|
+
}
|
package/ts/cli.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
authTestSession,
|
|
51
51
|
conversationInfoSession,
|
|
52
52
|
createChannel,
|
|
53
|
+
renameChannel,
|
|
53
54
|
inviteToChannel,
|
|
54
55
|
createDraft,
|
|
55
56
|
deleteDraft,
|
|
@@ -95,7 +96,9 @@ import {
|
|
|
95
96
|
userName,
|
|
96
97
|
getPath,
|
|
97
98
|
type Json,
|
|
99
|
+
savedList,
|
|
98
100
|
} from "./slack.ts";
|
|
101
|
+
import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
|
|
99
102
|
import {
|
|
100
103
|
buildTodoQuery,
|
|
101
104
|
loadTodoConfig,
|
|
@@ -114,8 +117,10 @@ import {
|
|
|
114
117
|
} from "./todo.ts";
|
|
115
118
|
import { setCacheEnabled } from "./cache.ts";
|
|
116
119
|
import { escapeArg, unescapeArg } from "./escapes.ts";
|
|
120
|
+
import { attachmentLines, parseAttachments } from "./attachments.ts";
|
|
117
121
|
import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
|
|
118
122
|
import { quietHoursNotice } from "./quietHours.ts";
|
|
123
|
+
import { installSyncConsole } from "./stdout.ts";
|
|
119
124
|
import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
|
|
120
125
|
|
|
121
126
|
function loadDotenv(path: string): void {
|
|
@@ -273,7 +278,12 @@ async function formatMsgLine(
|
|
|
273
278
|
// top-level history can see there is more to read without opening each ts.
|
|
274
279
|
const replyCount = Number(m.reply_count ?? 0);
|
|
275
280
|
const threadMark = replyCount > 0 ? ` [+${replyCount} ${replyCount === 1 ? "reply" : "replies"}]` : "";
|
|
276
|
-
|
|
281
|
+
// Forwarded/shared messages live in attachments[], not in the message text.
|
|
282
|
+
const atts = await Promise.all(parseAttachments(m).map(async (a) => ({
|
|
283
|
+
...a,
|
|
284
|
+
text: a.text ? resolveDateMarkup(await resolveMentions(token, a.text, cache, cookie)) : "",
|
|
285
|
+
})));
|
|
286
|
+
const tail = (reactions ? `\n ${reactions}` : "") + attachTail + attachmentLines(atts, slackTsToIso);
|
|
277
287
|
return `${stamp} ${who}: ${body}${threadMark}${tail}`;
|
|
278
288
|
}
|
|
279
289
|
|
|
@@ -302,6 +312,7 @@ function slimFiles(m: Record<string, Json>): Record<string, Json>[] {
|
|
|
302
312
|
// users.list) so callers can resolve mentions without scraping history by hand.
|
|
303
313
|
function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
304
314
|
const files = slimFiles(m);
|
|
315
|
+
const attachments = parseAttachments(m);
|
|
305
316
|
return {
|
|
306
317
|
ts: m.ts ?? null,
|
|
307
318
|
user: m.user ?? null,
|
|
@@ -329,6 +340,10 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
|
329
340
|
// Present only when the message carries attachments, so scripts can reliably
|
|
330
341
|
// detect files without the key adding noise to every plain-text line.
|
|
331
342
|
...(files.length > 0 ? { files } : {}),
|
|
343
|
+
// Forwarded/shared messages (and bot attachments): the original author,
|
|
344
|
+
// channel, ts, text and permalink. The message's own `text` is only the
|
|
345
|
+
// comment typed with the share.
|
|
346
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
332
347
|
};
|
|
333
348
|
}
|
|
334
349
|
|
|
@@ -747,6 +762,8 @@ function blocksToText(blocks: Json[]): string {
|
|
|
747
762
|
if (node && typeof node === "object") {
|
|
748
763
|
const obj = node as Record<string, Json>;
|
|
749
764
|
if (typeof obj.text === "string") { parts.push(obj.text); return; }
|
|
765
|
+
// A bare `link` element carries only its url — which IS its visible text.
|
|
766
|
+
if (obj.type === "link" && typeof obj.url === "string") { parts.push(obj.url); return; }
|
|
750
767
|
if (Array.isArray(obj.elements)) walk(obj.elements);
|
|
751
768
|
else if (Array.isArray(obj.content)) walk(obj.content);
|
|
752
769
|
}
|
|
@@ -829,6 +846,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
|
|
|
829
846
|
}
|
|
830
847
|
}
|
|
831
848
|
|
|
849
|
+
// --- todos (Slack's To-dos tab) ---
|
|
850
|
+
// Read-only. Pages through saved.list up to `limit` items, then shows each
|
|
851
|
+
// to-do with its due date, where it lives, and the message it points at.
|
|
852
|
+
async function cmdTodos(
|
|
853
|
+
token: string,
|
|
854
|
+
cookie: string | undefined,
|
|
855
|
+
opts: { filter: TodoFilter; limit: number; json: boolean },
|
|
856
|
+
): Promise<void> {
|
|
857
|
+
const items: TodoItem[] = [];
|
|
858
|
+
let cursor: string | undefined;
|
|
859
|
+
do {
|
|
860
|
+
const resp = asRecord((await savedList(token, { filter: opts.filter, limit: Math.min(opts.limit, 100), cursor }, cookie)) as Json);
|
|
861
|
+
items.push(...parseSavedItems(resp as Json));
|
|
862
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
863
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
864
|
+
} while (cursor && items.length < opts.limit);
|
|
865
|
+
const shown = sortTodos(items).slice(0, opts.limit);
|
|
866
|
+
|
|
867
|
+
if (shown.length === 0) {
|
|
868
|
+
// --json is JSONL on stdout: an empty list is no lines, the note goes to stderr.
|
|
869
|
+
(opts.json ? console.error : console.log)(`No ${opts.filter === "todo" ? "to-dos" : `${opts.filter} items`}.`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
let teamUrl = "";
|
|
874
|
+
let myUserId = "";
|
|
875
|
+
try { ({ url: teamUrl, userId: myUserId } = await authScopes(token, cookie)); } catch { /* links degrade to channel:ts */ }
|
|
876
|
+
const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
|
|
877
|
+
const now = Date.now() / 1000;
|
|
878
|
+
|
|
879
|
+
for (const t of shown) {
|
|
880
|
+
// The message a to-do points at. conversations.replies on its own ts
|
|
881
|
+
// returns it whether or not it has a thread; a thread reply comes back
|
|
882
|
+
// inside its thread, so match by ts. Fail-soft: an unreadable message
|
|
883
|
+
// still lists, with its link.
|
|
884
|
+
let msg: Record<string, Json> | undefined;
|
|
885
|
+
if (t.channel && t.ts) {
|
|
886
|
+
try {
|
|
887
|
+
const r = asRecord((await replies(token, t.channel, t.ts, 1, cookie)) as Json);
|
|
888
|
+
msg = asArray(r.messages).map(asRecord).find((m) => m.ts === t.ts);
|
|
889
|
+
} catch { /* not readable */ }
|
|
890
|
+
}
|
|
891
|
+
const link = t.channel && t.ts
|
|
892
|
+
? (teamUrl ? messagePermalink(teamUrl, t.channel, t.ts) : `${t.channel}:${t.ts}`)
|
|
893
|
+
: "";
|
|
894
|
+
if (opts.json) {
|
|
895
|
+
console.log(JSON.stringify({ ...t, done: isDone(t), permalink: link, user: msg?.user ?? null, text: msg?.text ?? null }));
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const where = t.channel ? await resolveChLabel(t.channel) : `(${t.itemType || "item"})`;
|
|
899
|
+
const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
|
|
900
|
+
const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
|
|
901
|
+
const head = raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
|
|
902
|
+
console.log(`${todoStatus(t, now, formatYmdHm)} ${stripTerminalControls(where)}`);
|
|
903
|
+
console.log(` ${stripTerminalControls(author + head)}`);
|
|
904
|
+
if (link) console.log(` ${link}`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
832
908
|
// --- drafts get ---
|
|
833
909
|
async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
|
|
834
910
|
const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
@@ -892,6 +968,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
|
|
|
892
968
|
"slackLists.items.update": "lists:write",
|
|
893
969
|
"slackLists.items.list": "lists:read",
|
|
894
970
|
"slackLists.access.set": "lists:write",
|
|
971
|
+
"conversations.rename": "channels:manage (or groups:write for a private channel)",
|
|
895
972
|
"conversations.replies": "channels:history (or groups:history)",
|
|
896
973
|
"conversations.history": "channels:history (or groups:history)",
|
|
897
974
|
};
|
|
@@ -1975,6 +2052,31 @@ async function isSelfDmChannel(
|
|
|
1975
2052
|
}
|
|
1976
2053
|
}
|
|
1977
2054
|
|
|
2055
|
+
/** Everyone a thread reply notifies: the thread's starter and its repliers.
|
|
2056
|
+
* From the fetched messages, plus the parent's `reply_users` (Slack's own
|
|
2057
|
+
* participant list), which still names a replier past the fetched window. */
|
|
2058
|
+
function threadParticipants(msgs: Record<string, Json>[]): Set<string> {
|
|
2059
|
+
const out = new Set<string>();
|
|
2060
|
+
for (const m of msgs) {
|
|
2061
|
+
if (typeof m.user === "string") out.add(m.user);
|
|
2062
|
+
if (typeof m.parent_user_id === "string") out.add(m.parent_user_id);
|
|
2063
|
+
for (const u of asArray(m.reply_users)) if (typeof u === "string") out.add(u);
|
|
2064
|
+
}
|
|
2065
|
+
return out;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** The other member of a 1:1 DM, or undefined for anything else (or an
|
|
2069
|
+
* unreadable channel). Only asked for `D…` ids, so channel sends pay nothing. */
|
|
2070
|
+
async function dmCounterparty(token: string, channelId: string, cookie?: string): Promise<string | undefined> {
|
|
2071
|
+
if (!channelId.startsWith("D")) return undefined;
|
|
2072
|
+
try {
|
|
2073
|
+
const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
|
|
2074
|
+
return ch.is_im === true && typeof ch.user === "string" ? ch.user : undefined;
|
|
2075
|
+
} catch {
|
|
2076
|
+
return undefined;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1978
2080
|
async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
1979
2081
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
1980
2082
|
const cookie = args.cookie;
|
|
@@ -2023,19 +2125,6 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2023
2125
|
for (const line of mentionWarnings(mentionReport.unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
|
|
2024
2126
|
}
|
|
2025
2127
|
|
|
2026
|
-
// Warn-only lint: names written as plain text (no leading @) that map to a
|
|
2027
|
-
// known workspace member but aren't <@USERID>-tagged — they won't be notified.
|
|
2028
|
-
// Never blocks; fail-soft (a missing users:read scope or API error must not
|
|
2029
|
-
// stall a send).
|
|
2030
|
-
try {
|
|
2031
|
-
for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
|
|
2032
|
-
// u.surface / u.display carry Slack-controlled display text — strip control chars.
|
|
2033
|
-
console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
|
|
2034
|
-
}
|
|
2035
|
-
} catch {
|
|
2036
|
-
// best-effort; ignore
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
2128
|
// Confirm-gate mention preview: exactly who resolves (→ notified) and who
|
|
2040
2129
|
// stays literal. Inserted into the dry-run block so the sender eyeballs the
|
|
2041
2130
|
// tagging before committing with --code.
|
|
@@ -2114,6 +2203,26 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
2114
2203
|
}
|
|
2115
2204
|
}
|
|
2116
2205
|
|
|
2206
|
+
// Warn-only lint: names written as plain text (no leading @) that map to a
|
|
2207
|
+
// known workspace member but aren't <@USERID>-tagged — they won't be notified.
|
|
2208
|
+
// Skipped for people Slack notifies anyway: a thread reply reaches everyone
|
|
2209
|
+
// who started or replied in that thread, and a 1:1 DM reaches its other
|
|
2210
|
+
// member — warning there is noise that makes agents stop for nothing.
|
|
2211
|
+
// Never blocks; fail-soft (a missing users:read scope or API error must not
|
|
2212
|
+
// stall a send).
|
|
2213
|
+
try {
|
|
2214
|
+
const notified = threadTs
|
|
2215
|
+
? threadParticipants(threadMsgs)
|
|
2216
|
+
: new Set([await dmCounterparty(token, channelId, cookie)].filter((u): u is string => !!u));
|
|
2217
|
+
for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
|
|
2218
|
+
if (notified.has(u.userId)) continue;
|
|
2219
|
+
// u.surface / u.display carry Slack-controlled display text — strip control chars.
|
|
2220
|
+
console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
|
|
2221
|
+
}
|
|
2222
|
+
} catch {
|
|
2223
|
+
// best-effort; ignore
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2117
2226
|
// Unreplied guard: the destination's last message is our own and no one has
|
|
2118
2227
|
// replied since (by definition, since it's still the most recent message) —
|
|
2119
2228
|
// sending another new message risks spamming the same person twice. Warn
|
|
@@ -3101,6 +3210,7 @@ interface PollArgs {
|
|
|
3101
3210
|
cookie?: string;
|
|
3102
3211
|
mentionToken?: string;
|
|
3103
3212
|
mentionCookie?: string;
|
|
3213
|
+
allowUrlAdjacent?: boolean;
|
|
3104
3214
|
}
|
|
3105
3215
|
|
|
3106
3216
|
/** The `slack poll --results=…` line handed back after posting. Printed as a
|
|
@@ -3164,6 +3274,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3164
3274
|
const multi = !!args.multi;
|
|
3165
3275
|
const until = args.until ?? "";
|
|
3166
3276
|
const message = pollBuildText(question, body, choices, { multi, deadline: until });
|
|
3277
|
+
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3167
3278
|
|
|
3168
3279
|
// Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
|
|
3169
3280
|
let lastText = "";
|
|
@@ -3448,8 +3559,10 @@ interface ScheduleSendArgs {
|
|
|
3448
3559
|
/** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
|
|
3449
3560
|
* when a FLAG supplied it — never when the command resolved the id itself. */
|
|
3450
3561
|
targetOverride?: string;
|
|
3562
|
+
allowUrlAdjacent?: boolean;
|
|
3451
3563
|
}
|
|
3452
3564
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
3565
|
+
guardUrlBoundaries(args.message, args.allowUrlAdjacent);
|
|
3453
3566
|
const getSelf = selfLookup(token, args.cookie);
|
|
3454
3567
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
3455
3568
|
|
|
@@ -3595,8 +3708,10 @@ interface UploadArgs {
|
|
|
3595
3708
|
channelId?: string;
|
|
3596
3709
|
userId?: string;
|
|
3597
3710
|
cookie?: string;
|
|
3711
|
+
allowUrlAdjacent?: boolean;
|
|
3598
3712
|
}
|
|
3599
3713
|
async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
3714
|
+
if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
|
|
3600
3715
|
const getSelf = selfLookup(token, args.cookie);
|
|
3601
3716
|
const { statSync, existsSync } = await import("node:fs");
|
|
3602
3717
|
const { basename } = await import("node:path");
|
|
@@ -3992,6 +4107,8 @@ async function cmdDownload(token: string, cookie: string | undefined, ref: strin
|
|
|
3992
4107
|
// --- dispatch ---
|
|
3993
4108
|
|
|
3994
4109
|
async function main(): Promise<void> {
|
|
4110
|
+
// Before any output: Bun drops console writes that meet a full pipe (see stdout.ts).
|
|
4111
|
+
installSyncConsole();
|
|
3995
4112
|
loadDotenvFiles();
|
|
3996
4113
|
|
|
3997
4114
|
type W = { workspace?: string };
|
|
@@ -4216,6 +4333,89 @@ async function main(): Promise<void> {
|
|
|
4216
4333
|
}
|
|
4217
4334
|
},
|
|
4218
4335
|
)
|
|
4336
|
+
.command(
|
|
4337
|
+
["rename <channel> <new-name>", "mv <channel> <new-name>"],
|
|
4338
|
+
"Rename a channel (confirm-hash safety gate)",
|
|
4339
|
+
(y2) => y2
|
|
4340
|
+
.positional("channel", { type: "string", demandOption: true, describe: "#name, name, or channel ID (C…/G…)" })
|
|
4341
|
+
.positional("new-name", { type: "string", demandOption: true, describe: "New channel name (Slack lowercases it; no spaces)" })
|
|
4342
|
+
.option("dry-run", { type: "boolean", default: false, describe: "Resolve the channel and print old -> new without renaming" })
|
|
4343
|
+
.option("code", { type: "string", describe: "Safety hash to confirm rename" }),
|
|
4344
|
+
async (argv) => {
|
|
4345
|
+
const token = tok(argv as W);
|
|
4346
|
+
const cookie = ck(argv as W);
|
|
4347
|
+
const ref = argv.channel!;
|
|
4348
|
+
if (ref.startsWith("@")) {
|
|
4349
|
+
console.error(`Error: ${ref} is a DM — only channels can be renamed.`);
|
|
4350
|
+
process.exit(1);
|
|
4351
|
+
}
|
|
4352
|
+
// A bare word is a channel name unless it is shaped like an ID.
|
|
4353
|
+
const channelRef = ref.startsWith("#") || /^[CG][A-Z0-9]{8,}$/.test(ref) ? ref : `#${ref}`;
|
|
4354
|
+
const newName = argv.newName!.replace(/^#/, "").toLowerCase();
|
|
4355
|
+
let channelId: string;
|
|
4356
|
+
let oldName: string;
|
|
4357
|
+
try {
|
|
4358
|
+
channelId = await resolveChannel(token, channelRef, cookie);
|
|
4359
|
+
} catch (e: unknown) {
|
|
4360
|
+
console.error(`Error: could not resolve channel ${ref}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4361
|
+
process.exit(1);
|
|
4362
|
+
}
|
|
4363
|
+
try {
|
|
4364
|
+
const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
|
|
4365
|
+
if (ch.is_im === true || ch.is_mpim === true) {
|
|
4366
|
+
console.error(`Error: ${channelId} is a DM — only channels can be renamed.`);
|
|
4367
|
+
process.exit(1);
|
|
4368
|
+
}
|
|
4369
|
+
oldName = typeof ch.name === "string" ? ch.name : channelRef.replace(/^#/, "");
|
|
4370
|
+
} catch (e: unknown) {
|
|
4371
|
+
// conversations.info is only for the preview label — a token without
|
|
4372
|
+
// channels:read can still rename. A channel that doesn't exist can't.
|
|
4373
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
4374
|
+
if (/channel_not_found/.test(raw)) {
|
|
4375
|
+
console.error(`Error: could not resolve channel ${ref}: channel not found (or not visible to this token).`);
|
|
4376
|
+
process.exit(1);
|
|
4377
|
+
}
|
|
4378
|
+
oldName = channelRef.replace(/^#/, "");
|
|
4379
|
+
}
|
|
4380
|
+
if (argv.dryRun === true) {
|
|
4381
|
+
console.log(`#${oldName} -> #${newName} (${channelId})`);
|
|
4382
|
+
console.log(`(dry run — conversations.rename not called)`);
|
|
4383
|
+
return;
|
|
4384
|
+
}
|
|
4385
|
+
// The renamer is the actor of record in the channel's history, so the
|
|
4386
|
+
// gate names them and the code is bound to them.
|
|
4387
|
+
const self = await selfIdentity(token, cookie);
|
|
4388
|
+
const code = safetyCode("rename", channelId, newName, self?.userId ?? "");
|
|
4389
|
+
if (argv.code !== code) requireCode(argv.code, code, [
|
|
4390
|
+
`--- Renaming channel -------------------------`,
|
|
4391
|
+
fromLine(self, { pad: 9 }),
|
|
4392
|
+
` channel: ${channelId}`,
|
|
4393
|
+
` from: #${oldName}`,
|
|
4394
|
+
` to: #${newName}`,
|
|
4395
|
+
`--------------------------------────────────`,
|
|
4396
|
+
]);
|
|
4397
|
+
let renamed: { id: string; name: string };
|
|
4398
|
+
try {
|
|
4399
|
+
renamed = await renameChannel(token, channelId, newName, cookie);
|
|
4400
|
+
} catch (e: unknown) {
|
|
4401
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
4402
|
+
const errCode = raw.match(/Slack error on conversations\.rename: (\w+)/)?.[1];
|
|
4403
|
+
const hint: Record<string, string> = {
|
|
4404
|
+
name_taken: `Error: a channel named #${newName} already exists.`,
|
|
4405
|
+
invalid_name: `Error: "${newName}" is not a valid channel name (lowercase letters, numbers, hyphens, underscores; max 80 chars).`,
|
|
4406
|
+
invalid_name_specials: `Error: "${newName}" contains characters Slack doesn't allow in channel names.`,
|
|
4407
|
+
invalid_name_maxlength: `Error: "${newName}" is too long (max 80 chars).`,
|
|
4408
|
+
invalid_name_required: `Error: the new channel name can't be empty.`,
|
|
4409
|
+
not_authorized: `Error: this token isn't allowed to rename #${oldName} (workspace settings may restrict renaming to admins or the channel creator).`,
|
|
4410
|
+
not_in_channel: `Error: this token isn't a member of #${oldName} — join it first.`,
|
|
4411
|
+
is_archived: `Error: #${oldName} is archived — unarchive it before renaming.`,
|
|
4412
|
+
};
|
|
4413
|
+
console.error((errCode && hint[errCode]) ?? friendlySlackError(e));
|
|
4414
|
+
process.exit(1);
|
|
4415
|
+
}
|
|
4416
|
+
console.log(`✓ Renamed #${oldName} -> #${renamed.name} (${renamed.id})`);
|
|
4417
|
+
},
|
|
4418
|
+
)
|
|
4219
4419
|
.demandCommand(1, "")
|
|
4220
4420
|
.showHelpOnFail(true),
|
|
4221
4421
|
)
|
|
@@ -4411,6 +4611,15 @@ async function main(): Promise<void> {
|
|
|
4411
4611
|
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
|
|
4412
4612
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
|
|
4413
4613
|
async (argv) => {
|
|
4614
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
4615
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
4616
|
+
// staged a confirm code and posting it produced a blank message.
|
|
4617
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
4618
|
+
// exact match, so refusing here is the only mechanism.
|
|
4619
|
+
if (!String(argv.message ?? "").trim()) {
|
|
4620
|
+
console.error("Error: message is empty");
|
|
4621
|
+
process.exit(1);
|
|
4622
|
+
}
|
|
4414
4623
|
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
4415
4624
|
if (argv.code) args.code = argv.code;
|
|
4416
4625
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -4626,6 +4835,7 @@ async function main(): Promise<void> {
|
|
|
4626
4835
|
.option("no-notify", { type: "boolean", default: false, describe: "With --results: do not write the invalid-ballot notice back into the message (keeps it strictly read-only)" })
|
|
4627
4836
|
.option("code", { type: "string", describe: "Safety hash to confirm the post" })
|
|
4628
4837
|
.option("body", { type: "string", describe: "Extra context shown under the question" })
|
|
4838
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4629
4839
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4630
4840
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4631
4841
|
.option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's" }),
|
|
@@ -4695,6 +4905,7 @@ async function main(): Promise<void> {
|
|
|
4695
4905
|
if (argv.until) args.until = String(argv.until);
|
|
4696
4906
|
if (argv.code) args.code = argv.code;
|
|
4697
4907
|
if (argv.body) args.body = argv.body;
|
|
4908
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4698
4909
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4699
4910
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4700
4911
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -4811,12 +5022,23 @@ async function main(): Promise<void> {
|
|
|
4811
5022
|
.positional("message", { type: "string", demandOption: true })
|
|
4812
5023
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
4813
5024
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
5025
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
4814
5026
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4815
5027
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4816
5028
|
.option("as-bot", { type: "boolean", default: false, describe: "Schedule via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. Without it a scheduled DM to yourself is posted by you, and Slack never notifies you of your own message — it fires into silence." }),
|
|
4817
5029
|
async (argv) => {
|
|
5030
|
+
// An empty body is never a meaningful send. `ask` already refuses one
|
|
5031
|
+
// ("Error: question is empty"); `send` did not, so an empty draft
|
|
5032
|
+
// staged a confirm code and posting it produced a blank message.
|
|
5033
|
+
// Verification cannot catch this: an empty publish reads back as an
|
|
5034
|
+
// exact match, so refusing here is the only mechanism.
|
|
5035
|
+
if (!String(argv.message ?? "").trim()) {
|
|
5036
|
+
console.error("Error: message is empty");
|
|
5037
|
+
process.exit(1);
|
|
5038
|
+
}
|
|
4818
5039
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
4819
5040
|
if (argv.code) args.code = argv.code;
|
|
5041
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4820
5042
|
// Recorded here, not in the command, because only a flag counts as an
|
|
4821
5043
|
// override — under --as-bot the command sets args.userId itself from
|
|
4822
5044
|
// the target, and claiming THAT was an override would be a new lie.
|
|
@@ -5306,6 +5528,7 @@ async function main(): Promise<void> {
|
|
|
5306
5528
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
5307
5529
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
5308
5530
|
.option("comment", { type: "string", describe: "Initial comment (first file)" })
|
|
5531
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
5309
5532
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
5310
5533
|
.option("channel-id", { type: "string" })
|
|
5311
5534
|
.option("user-id", { type: "string" }),
|
|
@@ -5313,6 +5536,7 @@ async function main(): Promise<void> {
|
|
|
5313
5536
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
5314
5537
|
if (argv.title) args.title = argv.title;
|
|
5315
5538
|
if (argv.comment) args.comment = argv.comment;
|
|
5539
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
5316
5540
|
if (argv.code) args.code = argv.code;
|
|
5317
5541
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5318
5542
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -5478,6 +5702,25 @@ async function main(): Promise<void> {
|
|
|
5478
5702
|
await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
|
|
5479
5703
|
},
|
|
5480
5704
|
)
|
|
5705
|
+
.command(
|
|
5706
|
+
"todos",
|
|
5707
|
+
"Slack's To-dos tab — your personal to-dos (read-only; requires xoxc desktop token). Not `slack todo` (reaction tasks)",
|
|
5708
|
+
(y) => y.command(
|
|
5709
|
+
["$0", "ls", "list"],
|
|
5710
|
+
"List your To-dos, soonest due first",
|
|
5711
|
+
(y2) => y2
|
|
5712
|
+
.option("filter", { type: "string", choices: [...TODO_FILTERS], default: "todo", describe: "todo = open to-dos; saved = everything saved; completed; archived" })
|
|
5713
|
+
.option("limit", { alias: "n", type: "number", default: 50, describe: "At most this many items" })
|
|
5714
|
+
.option("json", { type: "boolean", default: false, describe: "One JSON object per item (raw fields + permalink + message text)" }),
|
|
5715
|
+
async (argv) => {
|
|
5716
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1) {
|
|
5717
|
+
console.error(`Error: --limit must be a whole number of at least 1 (got ${argv.limit}).`);
|
|
5718
|
+
process.exit(2);
|
|
5719
|
+
}
|
|
5720
|
+
await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
|
|
5721
|
+
},
|
|
5722
|
+
),
|
|
5723
|
+
)
|
|
5481
5724
|
.command(
|
|
5482
5725
|
"drafts",
|
|
5483
5726
|
"Manage message drafts (requires xoxc desktop token)",
|
|
@@ -5495,11 +5738,15 @@ async function main(): Promise<void> {
|
|
|
5495
5738
|
"Create a draft",
|
|
5496
5739
|
(y2) => y2
|
|
5497
5740
|
.positional("channel", { type: "string", demandOption: true })
|
|
5498
|
-
.positional("text", { type: "string", array: true, default: [] })
|
|
5741
|
+
.positional("text", { type: "string", array: true, default: [] })
|
|
5742
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
|
|
5499
5743
|
async (argv) => {
|
|
5500
5744
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5501
5745
|
const text = (argv.text as string[]).join(" ");
|
|
5502
5746
|
if (!text) { console.error("Usage: slack drafts new <#channel|@user> <text>"); process.exit(2); }
|
|
5747
|
+
// Drafts emit explicit link elements, so a URL glued BEFORE is fine
|
|
5748
|
+
// here; one that runs into text AFTER would be cut at a guess.
|
|
5749
|
+
guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
|
|
5503
5750
|
const channelId = await resolveChannel(token, argv.channel!, cookie);
|
|
5504
5751
|
const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
|
|
5505
5752
|
console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
|
|
@@ -5519,11 +5766,13 @@ async function main(): Promise<void> {
|
|
|
5519
5766
|
(y2) => y2
|
|
5520
5767
|
.positional("id", { type: "string", demandOption: true })
|
|
5521
5768
|
.positional("text", { type: "string", array: true, default: [] })
|
|
5522
|
-
.option("code", { type: "string" })
|
|
5769
|
+
.option("code", { type: "string" })
|
|
5770
|
+
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
|
|
5523
5771
|
async (argv) => {
|
|
5524
5772
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5525
5773
|
const text = (argv.text as string[]).join(" ");
|
|
5526
5774
|
if (!text) { console.error("Usage: slack drafts edit <id> <new-text>"); process.exit(2); }
|
|
5775
|
+
guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
|
|
5527
5776
|
const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
5528
5777
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
5529
5778
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|