slack-term 1.39.2 → 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 +17 -1
- package/dist/cli.js +165 -159
- package/package.json +1 -1
- package/ts/cli.ts +205 -2
- package/ts/slack.ts +56 -11
- package/ts/slackTodos.ts +85 -0
- package/ts/urlGuard.ts +162 -10
package/package.json
CHANGED
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,
|
|
@@ -759,6 +762,8 @@ function blocksToText(blocks: Json[]): string {
|
|
|
759
762
|
if (node && typeof node === "object") {
|
|
760
763
|
const obj = node as Record<string, Json>;
|
|
761
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; }
|
|
762
767
|
if (Array.isArray(obj.elements)) walk(obj.elements);
|
|
763
768
|
else if (Array.isArray(obj.content)) walk(obj.content);
|
|
764
769
|
}
|
|
@@ -841,6 +846,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
|
|
|
841
846
|
}
|
|
842
847
|
}
|
|
843
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
|
+
|
|
844
908
|
// --- drafts get ---
|
|
845
909
|
async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
|
|
846
910
|
const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
@@ -904,6 +968,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
|
|
|
904
968
|
"slackLists.items.update": "lists:write",
|
|
905
969
|
"slackLists.items.list": "lists:read",
|
|
906
970
|
"slackLists.access.set": "lists:write",
|
|
971
|
+
"conversations.rename": "channels:manage (or groups:write for a private channel)",
|
|
907
972
|
"conversations.replies": "channels:history (or groups:history)",
|
|
908
973
|
"conversations.history": "channels:history (or groups:history)",
|
|
909
974
|
};
|
|
@@ -3145,6 +3210,7 @@ interface PollArgs {
|
|
|
3145
3210
|
cookie?: string;
|
|
3146
3211
|
mentionToken?: string;
|
|
3147
3212
|
mentionCookie?: string;
|
|
3213
|
+
allowUrlAdjacent?: boolean;
|
|
3148
3214
|
}
|
|
3149
3215
|
|
|
3150
3216
|
/** The `slack poll --results=…` line handed back after posting. Printed as a
|
|
@@ -3208,6 +3274,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
|
|
|
3208
3274
|
const multi = !!args.multi;
|
|
3209
3275
|
const until = args.until ?? "";
|
|
3210
3276
|
const message = pollBuildText(question, body, choices, { multi, deadline: until });
|
|
3277
|
+
guardUrlBoundaries(message, args.allowUrlAdjacent);
|
|
3211
3278
|
|
|
3212
3279
|
// Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
|
|
3213
3280
|
let lastText = "";
|
|
@@ -3492,8 +3559,10 @@ interface ScheduleSendArgs {
|
|
|
3492
3559
|
/** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
|
|
3493
3560
|
* when a FLAG supplied it — never when the command resolved the id itself. */
|
|
3494
3561
|
targetOverride?: string;
|
|
3562
|
+
allowUrlAdjacent?: boolean;
|
|
3495
3563
|
}
|
|
3496
3564
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
3565
|
+
guardUrlBoundaries(args.message, args.allowUrlAdjacent);
|
|
3497
3566
|
const getSelf = selfLookup(token, args.cookie);
|
|
3498
3567
|
const { ref, threadTs } = parseTargetThread(args.target);
|
|
3499
3568
|
|
|
@@ -3639,8 +3708,10 @@ interface UploadArgs {
|
|
|
3639
3708
|
channelId?: string;
|
|
3640
3709
|
userId?: string;
|
|
3641
3710
|
cookie?: string;
|
|
3711
|
+
allowUrlAdjacent?: boolean;
|
|
3642
3712
|
}
|
|
3643
3713
|
async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
|
|
3714
|
+
if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
|
|
3644
3715
|
const getSelf = selfLookup(token, args.cookie);
|
|
3645
3716
|
const { statSync, existsSync } = await import("node:fs");
|
|
3646
3717
|
const { basename } = await import("node:path");
|
|
@@ -4262,6 +4333,89 @@ async function main(): Promise<void> {
|
|
|
4262
4333
|
}
|
|
4263
4334
|
},
|
|
4264
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
|
+
)
|
|
4265
4419
|
.demandCommand(1, "")
|
|
4266
4420
|
.showHelpOnFail(true),
|
|
4267
4421
|
)
|
|
@@ -4457,6 +4611,15 @@ async function main(): Promise<void> {
|
|
|
4457
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." })
|
|
4458
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." }),
|
|
4459
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
|
+
}
|
|
4460
4623
|
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
|
|
4461
4624
|
if (argv.code) args.code = argv.code;
|
|
4462
4625
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
@@ -4672,6 +4835,7 @@ async function main(): Promise<void> {
|
|
|
4672
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)" })
|
|
4673
4836
|
.option("code", { type: "string", describe: "Safety hash to confirm the post" })
|
|
4674
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" })
|
|
4675
4839
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4676
4840
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4677
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" }),
|
|
@@ -4741,6 +4905,7 @@ async function main(): Promise<void> {
|
|
|
4741
4905
|
if (argv.until) args.until = String(argv.until);
|
|
4742
4906
|
if (argv.code) args.code = argv.code;
|
|
4743
4907
|
if (argv.body) args.body = argv.body;
|
|
4908
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4744
4909
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
4745
4910
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
4746
4911
|
// Always resolve @tags with the user token (it has users:read), even
|
|
@@ -4857,12 +5022,23 @@ async function main(): Promise<void> {
|
|
|
4857
5022
|
.positional("message", { type: "string", demandOption: true })
|
|
4858
5023
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
4859
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" })
|
|
4860
5026
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
4861
5027
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
4862
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." }),
|
|
4863
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
|
+
}
|
|
4864
5039
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
4865
5040
|
if (argv.code) args.code = argv.code;
|
|
5041
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
4866
5042
|
// Recorded here, not in the command, because only a flag counts as an
|
|
4867
5043
|
// override — under --as-bot the command sets args.userId itself from
|
|
4868
5044
|
// the target, and claiming THAT was an override would be a new lie.
|
|
@@ -5352,6 +5528,7 @@ async function main(): Promise<void> {
|
|
|
5352
5528
|
.positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
|
|
5353
5529
|
.option("title", { type: "string", describe: "Title (single file only)" })
|
|
5354
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" })
|
|
5355
5532
|
.option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
|
|
5356
5533
|
.option("channel-id", { type: "string" })
|
|
5357
5534
|
.option("user-id", { type: "string" }),
|
|
@@ -5359,6 +5536,7 @@ async function main(): Promise<void> {
|
|
|
5359
5536
|
const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
|
|
5360
5537
|
if (argv.title) args.title = argv.title;
|
|
5361
5538
|
if (argv.comment) args.comment = argv.comment;
|
|
5539
|
+
if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
|
|
5362
5540
|
if (argv.code) args.code = argv.code;
|
|
5363
5541
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
5364
5542
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
@@ -5524,6 +5702,25 @@ async function main(): Promise<void> {
|
|
|
5524
5702
|
await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
|
|
5525
5703
|
},
|
|
5526
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
|
+
)
|
|
5527
5724
|
.command(
|
|
5528
5725
|
"drafts",
|
|
5529
5726
|
"Manage message drafts (requires xoxc desktop token)",
|
|
@@ -5541,11 +5738,15 @@ async function main(): Promise<void> {
|
|
|
5541
5738
|
"Create a draft",
|
|
5542
5739
|
(y2) => y2
|
|
5543
5740
|
.positional("channel", { type: "string", demandOption: true })
|
|
5544
|
-
.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)" }),
|
|
5545
5743
|
async (argv) => {
|
|
5546
5744
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5547
5745
|
const text = (argv.text as string[]).join(" ");
|
|
5548
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 });
|
|
5549
5750
|
const channelId = await resolveChannel(token, argv.channel!, cookie);
|
|
5550
5751
|
const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
|
|
5551
5752
|
console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
|
|
@@ -5565,11 +5766,13 @@ async function main(): Promise<void> {
|
|
|
5565
5766
|
(y2) => y2
|
|
5566
5767
|
.positional("id", { type: "string", demandOption: true })
|
|
5567
5768
|
.positional("text", { type: "string", array: true, default: [] })
|
|
5568
|
-
.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)" }),
|
|
5569
5771
|
async (argv) => {
|
|
5570
5772
|
const token = tok(argv as W); const cookie = ck(argv as W);
|
|
5571
5773
|
const text = (argv.text as string[]).join(" ");
|
|
5572
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 });
|
|
5573
5776
|
const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
5574
5777
|
const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
|
|
5575
5778
|
if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
|
package/ts/slack.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
// Slack Web API client (user token, Authorization: Bearer)
|
|
2
2
|
import { resolveCookie } from "./profiles.ts";
|
|
3
|
+
import { linkifyRichText } from "./urlGuard.ts";
|
|
3
4
|
|
|
4
5
|
export class RateLimitError extends Error {
|
|
5
6
|
retryAfter: number;
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The Web API method that was throttled ("conversations.history", ...).
|
|
9
|
+
*
|
|
10
|
+
* Slack's limits are PER METHOD, so "retry after 30s" alone cannot say whether
|
|
11
|
+
* a caller's own polling caused the 429 or something else sharing the token
|
|
12
|
+
* did. The method name is already in scope at every throw site below; not
|
|
13
|
+
* passing it was the only reason the question was unanswerable.
|
|
14
|
+
*
|
|
15
|
+
* Optional because callers (and tests) constructed this with one argument
|
|
16
|
+
* before, and a required field would break them for no diagnostic gain.
|
|
17
|
+
*/
|
|
18
|
+
method?: string;
|
|
19
|
+
constructor(retryAfter: number, method?: string) {
|
|
20
|
+
super(
|
|
21
|
+
method
|
|
22
|
+
? `Slack rate limited on ${method} — retry after ${retryAfter}s`
|
|
23
|
+
: `Slack rate limited — retry after ${retryAfter}s`,
|
|
24
|
+
);
|
|
8
25
|
this.name = "RateLimitError";
|
|
9
26
|
this.retryAfter = retryAfter;
|
|
27
|
+
if (method) this.method = method;
|
|
10
28
|
}
|
|
11
29
|
}
|
|
12
30
|
|
|
@@ -36,12 +54,12 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
36
54
|
});
|
|
37
55
|
if (res.status === 429) {
|
|
38
56
|
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
|
|
39
|
-
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
|
|
57
|
+
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
|
|
40
58
|
}
|
|
41
59
|
const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
|
|
42
60
|
if (body.ok !== true) {
|
|
43
61
|
const err = body.error ?? "unknown";
|
|
44
|
-
if (err === "ratelimited") throw new RateLimitError(60);
|
|
62
|
+
if (err === "ratelimited") throw new RateLimitError(60, method);
|
|
45
63
|
// A desktop session token (xoxc-) is only accepted by the public Web API when
|
|
46
64
|
// paired with its xoxd session cookie — without one, point at the xoxp fallback.
|
|
47
65
|
if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
|
|
@@ -89,21 +107,21 @@ async function callSession(token: string, method: string, init: RequestInit, coo
|
|
|
89
107
|
});
|
|
90
108
|
if (res.status === 429) {
|
|
91
109
|
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
|
|
92
|
-
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
|
|
110
|
+
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
|
|
93
111
|
}
|
|
94
112
|
const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
|
|
95
113
|
if (body.ok !== true) {
|
|
96
114
|
const err = body.error ?? "unknown";
|
|
97
|
-
if (err === "ratelimited") throw new RateLimitError(60);
|
|
115
|
+
if (err === "ratelimited") throw new RateLimitError(60, method);
|
|
98
116
|
if ((err === "invalid_auth" || err === "not_authed") && !token.startsWith("xoxc-")) {
|
|
99
117
|
throw new Error(
|
|
100
|
-
|
|
118
|
+
`${method} requires a desktop app session token (xoxc-).\n` +
|
|
101
119
|
`Import from Slack desktop: slack auth login`,
|
|
102
120
|
);
|
|
103
121
|
}
|
|
104
122
|
if ((err === "invalid_auth" || err === "not_authed") && !cookie) {
|
|
105
123
|
throw new Error(
|
|
106
|
-
|
|
124
|
+
`${method} also requires the xoxd session cookie.\n` +
|
|
107
125
|
`Attach it with: slack auth firefox (Linux/macOS/Windows) or slack auth chrome (macOS/Linux)`,
|
|
108
126
|
);
|
|
109
127
|
}
|
|
@@ -185,7 +203,7 @@ export async function authScopes(token: string, cookie?: string): Promise<{
|
|
|
185
203
|
const res = await fetch(`${base()}/auth.test`, { method: "GET", headers });
|
|
186
204
|
if (res.status === 429) {
|
|
187
205
|
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
|
|
188
|
-
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
|
|
206
|
+
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, "auth.test");
|
|
189
207
|
}
|
|
190
208
|
const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
|
|
191
209
|
const body = (await res.json()) as {
|
|
@@ -594,6 +612,20 @@ export async function createChannel(
|
|
|
594
612
|
return { id: resp.channel?.id ?? "", name: resp.channel?.name ?? name };
|
|
595
613
|
}
|
|
596
614
|
|
|
615
|
+
// Rename a channel via conversations.rename. Needs channels:manage (public) or
|
|
616
|
+
// groups:write (private) — and, for a user token, rights to rename it.
|
|
617
|
+
export async function renameChannel(
|
|
618
|
+
token: string,
|
|
619
|
+
channel: string,
|
|
620
|
+
name: string,
|
|
621
|
+
cookie?: string,
|
|
622
|
+
): Promise<{ id: string; name: string }> {
|
|
623
|
+
const resp = (await post(token, "conversations.rename", { channel, name }, cookie)) as {
|
|
624
|
+
channel?: { id?: string; name?: string };
|
|
625
|
+
};
|
|
626
|
+
return { id: resp.channel?.id ?? channel, name: resp.channel?.name ?? name };
|
|
627
|
+
}
|
|
628
|
+
|
|
597
629
|
// Invite users to a channel via conversations.invite (up to ~1000 ids).
|
|
598
630
|
export async function inviteToChannel(
|
|
599
631
|
token: string,
|
|
@@ -1025,6 +1057,19 @@ export async function listConversationMembers(token: string, channel: string, co
|
|
|
1025
1057
|
return all;
|
|
1026
1058
|
}
|
|
1027
1059
|
|
|
1060
|
+
// To-dos tab (internal — requires xoxc session token + xoxd cookie). The
|
|
1061
|
+
// desktop client's own read of Slack's personal To-dos; see slackTodos.ts.
|
|
1062
|
+
export async function savedList(
|
|
1063
|
+
token: string,
|
|
1064
|
+
opts: { filter?: string; limit?: number; cursor?: string | undefined } = {},
|
|
1065
|
+
cookie?: string,
|
|
1066
|
+
): Promise<Json> {
|
|
1067
|
+
const params: Record<string, string> = { limit: String(opts.limit ?? 50) };
|
|
1068
|
+
if (opts.filter) params.filter = opts.filter;
|
|
1069
|
+
if (opts.cursor) params.cursor = opts.cursor;
|
|
1070
|
+
return postSession(token, "saved.list", params, cookie);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1028
1073
|
// Draft API (internal — requires xoxc session token + xoxd cookie)
|
|
1029
1074
|
export async function listDrafts(token: string, cookie?: string): Promise<Json> {
|
|
1030
1075
|
return postSession(token, "drafts.list", {}, cookie);
|
|
@@ -1039,7 +1084,7 @@ export async function createDraft(
|
|
|
1039
1084
|
const { randomUUID } = await import("node:crypto");
|
|
1040
1085
|
const blocks = JSON.stringify([{
|
|
1041
1086
|
type: "rich_text",
|
|
1042
|
-
elements: [{ type: "rich_text_section", elements:
|
|
1087
|
+
elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
|
|
1043
1088
|
}]);
|
|
1044
1089
|
return postSession(token, "drafts.create", {
|
|
1045
1090
|
client_msg_id: randomUUID(),
|
|
@@ -1059,7 +1104,7 @@ export async function updateDraft(
|
|
|
1059
1104
|
): Promise<Json> {
|
|
1060
1105
|
const blocks = JSON.stringify([{
|
|
1061
1106
|
type: "rich_text",
|
|
1062
|
-
elements: [{ type: "rich_text_section", elements:
|
|
1107
|
+
elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
|
|
1063
1108
|
}]);
|
|
1064
1109
|
// client_last_updated_ts must be the current time (server uses it as new stored ts)
|
|
1065
1110
|
const nowTs = (Date.now() / 1000).toFixed(6);
|
package/ts/slackTodos.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — read-only helpers.
|
|
2
|
+
//
|
|
3
|
+
// Not to be confused with `slack todo` (todo.ts), which tracks tasks as
|
|
4
|
+
// reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
|
|
5
|
+
// "Later" in Oct 2026, and there is no public API for it. The desktop client
|
|
6
|
+
// reads it through the internal `saved.list` (xoxc session token + d cookie),
|
|
7
|
+
// which is what `slack todos` calls — so it is per-user, and bot tokens cannot
|
|
8
|
+
// see it at all.
|
|
9
|
+
//
|
|
10
|
+
// A saved item points at a message: `item_id` is the channel id, `ts` the
|
|
11
|
+
// message ts. `todo_state` is "to_do" for a to-do and "saved" for a plain
|
|
12
|
+
// saved item; `state` is "in_progress" until it is checked off. Dates are
|
|
13
|
+
// epoch seconds, 0 when unset. The shape is undocumented, so every field is
|
|
14
|
+
// read defensively.
|
|
15
|
+
|
|
16
|
+
import type { Json } from "./slack.ts";
|
|
17
|
+
|
|
18
|
+
/** `--filter` values `saved.list` accepts (verified against the live API). */
|
|
19
|
+
export const TODO_FILTERS = ["todo", "saved", "completed", "archived"] as const;
|
|
20
|
+
export type TodoFilter = (typeof TODO_FILTERS)[number];
|
|
21
|
+
|
|
22
|
+
export type TodoItem = {
|
|
23
|
+
channel: string;
|
|
24
|
+
ts: string;
|
|
25
|
+
/** Epoch seconds; 0 = no due date. */
|
|
26
|
+
due: number;
|
|
27
|
+
/** Epoch seconds; 0 = not completed. */
|
|
28
|
+
completed: number;
|
|
29
|
+
/** "to_do" | "saved" (as Slack reports it). */
|
|
30
|
+
todoState: string;
|
|
31
|
+
state: string;
|
|
32
|
+
archived: boolean;
|
|
33
|
+
itemType: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
|
|
37
|
+
const num = (v: Json | undefined): number => {
|
|
38
|
+
const n = typeof v === "number" || typeof v === "string" ? Number(v) : 0;
|
|
39
|
+
return Number.isFinite(n) ? n : 0;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The items of one `saved.list` page, normalized. Non-message items are kept
|
|
43
|
+
* (their `itemType` says what they are) but carry no channel/ts to read. */
|
|
44
|
+
export function parseSavedItems(resp: Json): TodoItem[] {
|
|
45
|
+
const r = (resp && typeof resp === "object" && !Array.isArray(resp) ? resp : {}) as Record<string, Json>;
|
|
46
|
+
const items = Array.isArray(r.saved_items) ? r.saved_items : [];
|
|
47
|
+
return items
|
|
48
|
+
.filter((x): x is Record<string, Json> => !!x && typeof x === "object" && !Array.isArray(x))
|
|
49
|
+
.map((x) => ({
|
|
50
|
+
channel: str(x.item_id),
|
|
51
|
+
ts: str(x.ts),
|
|
52
|
+
due: num(x.date_due),
|
|
53
|
+
completed: num(x.date_completed),
|
|
54
|
+
todoState: str(x.todo_state),
|
|
55
|
+
state: str(x.state),
|
|
56
|
+
archived: x.is_archived === true,
|
|
57
|
+
itemType: str(x.item_type),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Soonest due first; items without a due date last, newest message first among them. */
|
|
62
|
+
export function sortTodos(items: TodoItem[]): TodoItem[] {
|
|
63
|
+
return [...items].sort((a, b) => {
|
|
64
|
+
if (a.due && b.due) return a.due - b.due;
|
|
65
|
+
if (a.due !== b.due) return a.due ? -1 : 1;
|
|
66
|
+
return Number(b.ts) - Number(a.ts);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isDone(t: TodoItem): boolean {
|
|
71
|
+
return t.completed > 0 || t.state === "completed";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The status column: a checkbox, plus the due date (and OVERDUE when past it). */
|
|
75
|
+
export function todoStatus(t: TodoItem, nowSec: number, fmt: (epochSec: number) => string): string {
|
|
76
|
+
const box = isDone(t) ? "☑" : t.todoState === "to_do" ? "☐" : "·";
|
|
77
|
+
if (!t.due) return `${box} no due date`;
|
|
78
|
+
const overdue = !isDone(t) && t.due < nowSec ? " OVERDUE" : "";
|
|
79
|
+
return `${box} due ${fmt(t.due)}${overdue}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A message permalink built from the workspace URL — no API call per item. */
|
|
83
|
+
export function messagePermalink(teamUrl: string, channel: string, ts: string): string {
|
|
84
|
+
return `${teamUrl.replace(/\/+$/, "")}/archives/${channel}/p${ts.replace(".", "")}`;
|
|
85
|
+
}
|