slack-term 1.31.5 → 1.32.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 +128 -110
- package/package.json +1 -1
- package/ts/cli.ts +173 -26
- package/ts/slack.ts +213 -69
- package/ts/tail.ts +3 -1
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -2615,6 +2615,37 @@ async function cmdDoctor(token: string): Promise<void> {
|
|
|
2615
2615
|
}
|
|
2616
2616
|
|
|
2617
2617
|
// --- schedule ---
|
|
2618
|
+
|
|
2619
|
+
/** The xoxb bot token, or a clean exit. `--as-bot` is only ever a token switch,
|
|
2620
|
+
* and its failure is always the same one: the env var is not set. */
|
|
2621
|
+
function requireBotToken(): string {
|
|
2622
|
+
const botToken = resolveBotToken();
|
|
2623
|
+
if (!botToken) {
|
|
2624
|
+
console.error(
|
|
2625
|
+
"Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
|
|
2626
|
+
" Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
|
|
2627
|
+
);
|
|
2628
|
+
process.exit(1);
|
|
2629
|
+
}
|
|
2630
|
+
return botToken;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
/** The bot↔user DM for an `@handle`. The handle is resolved on the USER token
|
|
2634
|
+
* (which has users:read) and the DM is opened on the BOT token, so the channel
|
|
2635
|
+
* is the bot's IM with that person and not the person's own self-DM. That
|
|
2636
|
+
* distinction is the entire point of `--as-bot` here: Slack never notifies you
|
|
2637
|
+
* about your own message, so a digest scheduled into a self-DM is delivered
|
|
2638
|
+
* into silence at the one moment nobody is at the terminal to notice. */
|
|
2639
|
+
async function botDmForTarget(
|
|
2640
|
+
target: string,
|
|
2641
|
+
botToken: string,
|
|
2642
|
+
userToken: string,
|
|
2643
|
+
userCookie?: string,
|
|
2644
|
+
): Promise<string> {
|
|
2645
|
+
const userId = await resolveUserId(userToken, target, userCookie);
|
|
2646
|
+
return openDm(botToken, userId);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2618
2649
|
function parsePostAt(at: string): number {
|
|
2619
2650
|
if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
|
|
2620
2651
|
const d = new Date(at.replace(" ", "T"));
|
|
@@ -2650,6 +2681,10 @@ interface ScheduleSendArgs {
|
|
|
2650
2681
|
channelId?: string;
|
|
2651
2682
|
userId?: string;
|
|
2652
2683
|
cookie?: string;
|
|
2684
|
+
asBot?: boolean;
|
|
2685
|
+
/** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
|
|
2686
|
+
* when a FLAG supplied it — never when the command resolved the id itself. */
|
|
2687
|
+
targetOverride?: string;
|
|
2653
2688
|
}
|
|
2654
2689
|
async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
|
|
2655
2690
|
const getSelf = selfLookup(token, args.cookie);
|
|
@@ -2670,19 +2705,30 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
2670
2705
|
// Self-DM is worst here: the message fires later, unattended, and Slack never
|
|
2671
2706
|
// notifies you about your own post — so a scheduled reminder to yourself is
|
|
2672
2707
|
// delivered into silence at exactly the moment you were counting on it.
|
|
2673
|
-
|
|
2708
|
+
// --as-bot is exempt: that posts as the app, a different identity, which does
|
|
2709
|
+
// notify you. Being unable to say that is what made this warning a dead end —
|
|
2710
|
+
// it named the problem and offered no way out.
|
|
2711
|
+
if (!args.asBot && await isSelfDmChannel(token, channelId, self?.userId, args.cookie)) {
|
|
2674
2712
|
console.error(
|
|
2675
|
-
`Warning: "${args.target}" is a DM to yourself — Slack will NOT notify you when this fires
|
|
2713
|
+
`Warning: "${args.target}" is a DM to yourself — Slack will NOT notify you when this fires.\n` +
|
|
2714
|
+
` To be notified, schedule it as the bot: slack schedule send '${args.target}' '...' --at '${args.at}' --as-bot`,
|
|
2676
2715
|
);
|
|
2677
2716
|
}
|
|
2678
2717
|
|
|
2679
2718
|
const code = safetyCode(channelId, args.message, String(postAt), self?.userId ?? "");
|
|
2680
2719
|
|
|
2720
|
+
// `--channel-id` / `--user-id` win over the target text, so the target stops
|
|
2721
|
+
// being where this goes the moment the two disagree — and the gate printed
|
|
2722
|
+
// only the target. `schedule send @alice … --user-id <bob>` read "To: @alice"
|
|
2723
|
+
// and went to bob. A confirm gate that names the wrong recipient with no sign
|
|
2724
|
+
// it was overridden is worse than no gate: it gets read, and it gets passed.
|
|
2725
|
+
const destOverride = args.targetOverride ? ` [${args.targetOverride} → ${channelId}]` : "";
|
|
2726
|
+
|
|
2681
2727
|
if (args.code !== code) {
|
|
2682
2728
|
requireCode(args.code, code, [
|
|
2683
2729
|
`--- Scheduling message -----------------------`,
|
|
2684
|
-
fromLine(self, { pad: 9 }),
|
|
2685
|
-
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}`,
|
|
2730
|
+
fromLine(self, { asBot: args.asBot, pad: 9 }),
|
|
2731
|
+
` To: ${ref}${threadTs ? ` (thread ${threadTs})` : ""}${destOverride}`,
|
|
2686
2732
|
` At: ${fmtPostAt(postAt)}`,
|
|
2687
2733
|
` Message: ${args.message}`,
|
|
2688
2734
|
`---------------------------------------------`,
|
|
@@ -2690,6 +2736,37 @@ async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<v
|
|
|
2690
2736
|
}
|
|
2691
2737
|
const id = await scheduleMessage(token, channelId, args.message, postAt, threadTs, args.cookie);
|
|
2692
2738
|
console.log(`✓ Scheduled (id: ${id}, at: ${postAtDate})`);
|
|
2739
|
+
|
|
2740
|
+
if (args.asBot) {
|
|
2741
|
+
// The message now belongs to the BOT, so `schedule ls` on the user token
|
|
2742
|
+
// cannot see it and `schedule rm` cannot cancel it. Said at the only moment
|
|
2743
|
+
// the id is in front of the reader.
|
|
2744
|
+
console.error(
|
|
2745
|
+
` Scheduled as the bot — the user token cannot see or cancel it. Use the same flag:\n` +
|
|
2746
|
+
` slack schedule ls --as-bot\n` +
|
|
2747
|
+
` slack schedule rm '${args.target}' ${id} --as-bot`,
|
|
2748
|
+
);
|
|
2749
|
+
// A scheduled bot DM fires unattended, so an app that cannot actually carry
|
|
2750
|
+
// a DM fails at the one moment nobody is watching. Same check `send --as-bot`
|
|
2751
|
+
// runs after the fact; here it is the difference between finding out now and
|
|
2752
|
+
// finding out from an empty inbox.
|
|
2753
|
+
if (channelId.startsWith("D")) {
|
|
2754
|
+
try {
|
|
2755
|
+
const diag = await diagnoseBotMessaging(token);
|
|
2756
|
+
if (!diag.ok) {
|
|
2757
|
+
for (const line of formatDiagnosis(diag, true)) console.error(line);
|
|
2758
|
+
} else {
|
|
2759
|
+
const appUrl = diag.appId ? `https://api.slack.com/apps/${diag.appId}` : "https://api.slack.com/apps";
|
|
2760
|
+
console.error(
|
|
2761
|
+
`Note: if ${args.target} can't reply (app messaging may be off), ` +
|
|
2762
|
+
`enable Messages Tab: ${appUrl} → App Home.`,
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
} catch {
|
|
2766
|
+
// diagnosis is best-effort; never turn a successful schedule into a failure.
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2693
2770
|
}
|
|
2694
2771
|
|
|
2695
2772
|
async function cmdScheduleList(token: string, target?: string, channelId?: string, cookie?: string): Promise<void> {
|
|
@@ -2716,6 +2793,9 @@ interface ScheduleRmArgs {
|
|
|
2716
2793
|
code?: string;
|
|
2717
2794
|
channelId?: string;
|
|
2718
2795
|
cookie?: string;
|
|
2796
|
+
asBot?: boolean;
|
|
2797
|
+
/** How the caller overrode the target text, e.g. `--channel-id C0123`. */
|
|
2798
|
+
targetOverride?: string;
|
|
2719
2799
|
}
|
|
2720
2800
|
async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void> {
|
|
2721
2801
|
let channelId: string;
|
|
@@ -2727,8 +2807,8 @@ async function cmdScheduleRm(token: string, args: ScheduleRmArgs): Promise<void>
|
|
|
2727
2807
|
if (args.code !== code) {
|
|
2728
2808
|
requireCode(args.code, code, [
|
|
2729
2809
|
`--- Deleting scheduled message ---------------`,
|
|
2730
|
-
fromLine(self, { pad: 9 }),
|
|
2731
|
-
` Channel: ${args.target}`,
|
|
2810
|
+
fromLine(self, { asBot: args.asBot, pad: 9 }),
|
|
2811
|
+
` Channel: ${args.target}${args.targetOverride ? ` [${args.targetOverride} → ${channelId}]` : ""}`,
|
|
2732
2812
|
` ID: ${args.id}`,
|
|
2733
2813
|
`---------------------------------------------`,
|
|
2734
2814
|
]);
|
|
@@ -3317,17 +3397,23 @@ async function main(): Promise<void> {
|
|
|
3317
3397
|
(y2) => y2.positional("user", { type: "string", demandOption: true, describe: "@handle or user ID" }),
|
|
3318
3398
|
async (argv) => {
|
|
3319
3399
|
const token = tok(argv as W);
|
|
3320
|
-
const
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
userId =
|
|
3400
|
+
const cookie = ck(argv as W);
|
|
3401
|
+
const ref = argv.user!.startsWith("@") ? argv.user! : `@${argv.user!}`;
|
|
3402
|
+
// resolveUserId owns every population: the id form, self, the
|
|
3403
|
+
// workspace roster, and the Slack Connect partners users.list cannot
|
|
3404
|
+
// see. Matching `u.name` against this list by hand — as this used to
|
|
3405
|
+
// — missed display names, real names, and everyone from another team.
|
|
3406
|
+
let userId = "";
|
|
3407
|
+
try {
|
|
3408
|
+
userId = await resolveUserId(token, ref, cookie);
|
|
3409
|
+
} catch (e) {
|
|
3410
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
3411
|
+
process.exit(1);
|
|
3329
3412
|
}
|
|
3330
|
-
|
|
3413
|
+
// The cookie is not optional here: on a desktop (xoxc-) token the
|
|
3414
|
+
// public Web API answers invalid_auth without it, which read as
|
|
3415
|
+
// "your token is wrong" on a lookup that was about to succeed.
|
|
3416
|
+
const resp = asRecord((await userInfo(token, userId, cookie)) as Json);
|
|
3331
3417
|
const u = asRecord(resp.user);
|
|
3332
3418
|
const profile = asRecord(u.profile);
|
|
3333
3419
|
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -3723,15 +3809,52 @@ async function main(): Promise<void> {
|
|
|
3723
3809
|
.option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
|
|
3724
3810
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
3725
3811
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3726
|
-
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
3812
|
+
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
3813
|
+
.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." }),
|
|
3727
3814
|
async (argv) => {
|
|
3728
3815
|
const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
|
|
3729
3816
|
if (argv.code) args.code = argv.code;
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
if (
|
|
3734
|
-
|
|
3817
|
+
// Recorded here, not in the command, because only a flag counts as an
|
|
3818
|
+
// override — under --as-bot the command sets args.userId itself from
|
|
3819
|
+
// the target, and claiming THAT was an override would be a new lie.
|
|
3820
|
+
if (argv["channel-id"]) {
|
|
3821
|
+
args.channelId = argv["channel-id"];
|
|
3822
|
+
args.targetOverride = `--channel-id ${argv["channel-id"]}`;
|
|
3823
|
+
}
|
|
3824
|
+
if (argv["user-id"]) {
|
|
3825
|
+
args.userId = argv["user-id"];
|
|
3826
|
+
args.targetOverride = `--user-id ${argv["user-id"]}`;
|
|
3827
|
+
}
|
|
3828
|
+
let scheduleToken: string;
|
|
3829
|
+
if (argv["as-bot"]) {
|
|
3830
|
+
const botToken = requireBotToken();
|
|
3831
|
+
// Resolve @user → user_id with the *user* token (has users:read),
|
|
3832
|
+
// then let cmdScheduleSend open the DM with the bot token
|
|
3833
|
+
// (im:write). Same split as `send --as-bot`: the bot never needs
|
|
3834
|
+
// users:read, and the DM is bot↔user rather than a self-DM.
|
|
3835
|
+
if (!args.channelId && !args.userId && args.target.startsWith("@")) {
|
|
3836
|
+
try {
|
|
3837
|
+
args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
|
|
3838
|
+
} catch (e: unknown) {
|
|
3839
|
+
console.error(
|
|
3840
|
+
`Error: could not resolve ${args.target} to a user for the bot DM.\n` +
|
|
3841
|
+
` ${e instanceof Error ? e.message : String(e)}\n` +
|
|
3842
|
+
` Tip: pass --user-id <Uxxxx> to skip the lookup.`,
|
|
3843
|
+
);
|
|
3844
|
+
process.exit(1);
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
scheduleToken = botToken;
|
|
3848
|
+
args.asBot = true;
|
|
3849
|
+
} else {
|
|
3850
|
+
scheduleToken = tok(argv as W);
|
|
3851
|
+
// Session cookie for the token that will actually schedule — an
|
|
3852
|
+
// xoxc- desktop token is rejected by the public API without it.
|
|
3853
|
+
// Bot tokens need none.
|
|
3854
|
+
const cookie = ck(argv as W);
|
|
3855
|
+
if (cookie) args.cookie = cookie;
|
|
3856
|
+
}
|
|
3857
|
+
await cmdScheduleSend(scheduleToken, args);
|
|
3735
3858
|
},
|
|
3736
3859
|
)
|
|
3737
3860
|
.command(
|
|
@@ -3739,9 +3862,20 @@ async function main(): Promise<void> {
|
|
|
3739
3862
|
"List pending scheduled messages",
|
|
3740
3863
|
(y2) => y2
|
|
3741
3864
|
.positional("target", { type: "string", describe: "#channel to filter by" })
|
|
3742
|
-
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3865
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3866
|
+
.option("as-bot", { type: "boolean", default: false, describe: "List what the BOT token scheduled. A message from `schedule send --as-bot` belongs to the app, so the user token's listing does not contain it — and an invisible pending message cannot be cancelled." }),
|
|
3743
3867
|
async (argv) => {
|
|
3744
|
-
|
|
3868
|
+
const target = argv.target as string | undefined;
|
|
3869
|
+
if (argv["as-bot"]) {
|
|
3870
|
+
const botToken = requireBotToken();
|
|
3871
|
+
let channelId = argv["channel-id"] as string | undefined;
|
|
3872
|
+
if (!channelId && target?.startsWith("@")) {
|
|
3873
|
+
channelId = await botDmForTarget(target, botToken, tok(argv as W), ck(argv as W));
|
|
3874
|
+
}
|
|
3875
|
+
await cmdScheduleList(botToken, target, channelId);
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
3878
|
+
await cmdScheduleList(tok(argv as W), target, argv["channel-id"], ck(argv as W));
|
|
3745
3879
|
},
|
|
3746
3880
|
)
|
|
3747
3881
|
.command(
|
|
@@ -3751,13 +3885,26 @@ async function main(): Promise<void> {
|
|
|
3751
3885
|
.positional("target", { type: "string", demandOption: true, describe: "#channel or @user" })
|
|
3752
3886
|
.positional("id", { type: "string", demandOption: true, describe: "Scheduled message ID" })
|
|
3753
3887
|
.option("code", { type: "string", describe: "Safety hash to confirm" })
|
|
3754
|
-
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3888
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
3889
|
+
.option("as-bot", { type: "boolean", default: false, describe: "Cancel a message scheduled with `schedule send --as-bot`. It belongs to the bot token, so the user token cannot delete it." }),
|
|
3755
3890
|
async (argv) => {
|
|
3756
3891
|
const args: ScheduleRmArgs = { target: argv.target!, id: argv.id! };
|
|
3757
3892
|
if (argv.code) args.code = argv.code;
|
|
3893
|
+
if (argv["channel-id"]) {
|
|
3894
|
+
args.channelId = argv["channel-id"];
|
|
3895
|
+
args.targetOverride = `--channel-id ${argv["channel-id"]}`;
|
|
3896
|
+
}
|
|
3897
|
+
if (argv["as-bot"]) {
|
|
3898
|
+
const botToken = requireBotToken();
|
|
3899
|
+
if (!args.channelId && args.target.startsWith("@")) {
|
|
3900
|
+
args.channelId = await botDmForTarget(args.target, botToken, tok(argv as W), ck(argv as W));
|
|
3901
|
+
}
|
|
3902
|
+
args.asBot = true;
|
|
3903
|
+
await cmdScheduleRm(botToken, args);
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3758
3906
|
const cookie = ck(argv as W);
|
|
3759
3907
|
if (cookie) args.cookie = cookie;
|
|
3760
|
-
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
3761
3908
|
await cmdScheduleRm(tok(argv as W), args);
|
|
3762
3909
|
},
|
|
3763
3910
|
)
|
package/ts/slack.ts
CHANGED
|
@@ -501,46 +501,215 @@ export async function openDm(token: string, userId: string, cookie?: string): Pr
|
|
|
501
501
|
return id;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/** The existing IM with `userId`, or "" when there is none. Paginated: a
|
|
505
|
+
* workspace with many DMs pages its im list like any other conversation, and
|
|
506
|
+
* reading only the first page is how a DM that exists reads as missing. */
|
|
507
|
+
async function findExistingDm(token: string, userId: string, cookie?: string): Promise<string> {
|
|
508
|
+
let cursor = "";
|
|
509
|
+
while (true) {
|
|
510
|
+
const params: Record<string, string> = { types: "im", limit: "200" };
|
|
511
|
+
if (cursor) params.cursor = cursor;
|
|
512
|
+
const resp = (await get(token, "conversations.list", params, cookie)) as {
|
|
513
|
+
channels?: Array<{ id?: string; user?: string }>;
|
|
514
|
+
response_metadata?: { next_cursor?: string };
|
|
515
|
+
};
|
|
516
|
+
for (const ch of resp.channels ?? []) {
|
|
517
|
+
if (ch.user === userId) return String(ch.id ?? "");
|
|
518
|
+
}
|
|
519
|
+
cursor = resp.response_metadata?.next_cursor ?? "";
|
|
520
|
+
if (!cursor) break;
|
|
521
|
+
}
|
|
522
|
+
return "";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The DM channel with a known user id. Read-only on purpose: opening a DM
|
|
526
|
+
* needs im:write, which plenty of read tokens do not carry, so an absent DM is
|
|
527
|
+
* reported rather than created. */
|
|
528
|
+
async function dmWithUser(token: string, userId: string, cookie?: string, label?: string): Promise<string> {
|
|
529
|
+
const dm = await findExistingDm(token, userId, cookie);
|
|
530
|
+
if (dm) return dm;
|
|
531
|
+
const who = label ? `${label} (${userId})` : userId;
|
|
532
|
+
throw new Error(`No existing DM with ${who}. Open it once in Slack first.`);
|
|
533
|
+
}
|
|
534
|
+
|
|
504
535
|
/** Normalize for loose matching: lowercase + strip hyphens/underscores/whitespace.
|
|
505
536
|
* Lets `@deploy-bot` match handles like `deploybot` or display names like `Deploy-Bot`. */
|
|
506
537
|
function normName(s: string): string {
|
|
507
538
|
return s.toLowerCase().replace(/[-_\s]/g, "");
|
|
508
539
|
}
|
|
509
540
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
541
|
+
type Member = {
|
|
542
|
+
id?: string;
|
|
543
|
+
name?: string;
|
|
544
|
+
real_name?: string;
|
|
545
|
+
profile?: { display_name?: string; real_name?: string };
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
function memberMatches(u: Member, nameNorm: string): boolean {
|
|
549
|
+
const n = normName(u.name ?? "");
|
|
550
|
+
const rn = normName(u.real_name ?? u.profile?.real_name ?? "");
|
|
551
|
+
const dn = normName(u.profile?.display_name ?? "");
|
|
552
|
+
return n === nameNorm || rn === nameNorm || (!!dn && dn === nameNorm);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** What a `@name` lookup found, and — when it found nothing — which populations
|
|
556
|
+
* it actually searched. The counts are the whole point: "not found" out of 40
|
|
557
|
+
* people is a wrong handle, "not found" out of 0 is a broken token, and the
|
|
558
|
+
* caller cannot tell those apart from the sentence alone. */
|
|
559
|
+
type UserSearch = {
|
|
560
|
+
/** "" when nothing matched. */
|
|
561
|
+
id: string;
|
|
562
|
+
/** The existing IM with them, when the match came from the DM scan. */
|
|
563
|
+
dm?: string;
|
|
564
|
+
workspaceCount: number;
|
|
565
|
+
connectCount: number;
|
|
566
|
+
/** DM partners whose profile could not be read, so they were never compared. */
|
|
567
|
+
skipped: number;
|
|
568
|
+
/** True when the scan stopped at CONNECT_SCAN_LIMIT with partners left over. */
|
|
569
|
+
capped: boolean;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
/** How many non-roster DM partners a failed lookup will resolve one at a time.
|
|
573
|
+
* Each costs a `users.info`, so an account with thousands of Slack Connect DMs
|
|
574
|
+
* would otherwise turn a single typo into thousands of sequential calls and
|
|
575
|
+
* rate-limit the caller out of the API. Past the cap the scan stops and says it
|
|
576
|
+
* stopped — an incomplete answer that admits it beats a complete-looking one. */
|
|
577
|
+
const CONNECT_SCAN_LIMIT = 200;
|
|
578
|
+
|
|
579
|
+
/** Search every population this token can see for `@name`.
|
|
580
|
+
*
|
|
581
|
+
* `users.list` is scoped to YOUR workspace, so a Slack Connect counterpart —
|
|
582
|
+
* who belongs to another team entirely — is never in it, no matter what
|
|
583
|
+
* parameters the call is given. The only place they exist is the membership of
|
|
584
|
+
* a conversation you share, and for a person you DM the cheapest such place is
|
|
585
|
+
* your own IM list: it costs one `users.info` per external DM partner and hands
|
|
586
|
+
* back the channel id the caller was about to look up anyway. */
|
|
587
|
+
async function searchUsers(token: string, nameNorm: string, cookie?: string): Promise<UserSearch> {
|
|
588
|
+
let workspaceCount = 0;
|
|
589
|
+
const known = new Set<string>();
|
|
526
590
|
let cursor = "";
|
|
527
591
|
while (true) {
|
|
528
592
|
const params: Record<string, string> = { limit: "200" };
|
|
529
593
|
if (cursor) params.cursor = cursor;
|
|
530
594
|
const resp = (await get(token, "users.list", params, cookie)) as {
|
|
531
|
-
members?:
|
|
595
|
+
members?: Member[];
|
|
532
596
|
response_metadata?: { next_cursor?: string };
|
|
533
597
|
};
|
|
534
598
|
for (const u of resp.members ?? []) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
599
|
+
workspaceCount++;
|
|
600
|
+
if (u.id) known.add(u.id);
|
|
601
|
+
if (memberMatches(u, nameNorm)) {
|
|
602
|
+
return { id: u.id ?? "", workspaceCount, connectCount: 0, skipped: 0, capped: false };
|
|
603
|
+
}
|
|
539
604
|
}
|
|
540
605
|
cursor = resp.response_metadata?.next_cursor ?? "";
|
|
541
606
|
if (!cursor) break;
|
|
542
607
|
}
|
|
543
|
-
|
|
608
|
+
|
|
609
|
+
let connectCount = 0;
|
|
610
|
+
let skipped = 0;
|
|
611
|
+
let capped = false;
|
|
612
|
+
let dmCursor = "";
|
|
613
|
+
while (true) {
|
|
614
|
+
const params: Record<string, string> = { types: "im", limit: "200" };
|
|
615
|
+
if (dmCursor) params.cursor = dmCursor;
|
|
616
|
+
const resp = (await get(token, "conversations.list", params, cookie)) as {
|
|
617
|
+
channels?: Array<{ id?: string; user?: string }>;
|
|
618
|
+
response_metadata?: { next_cursor?: string };
|
|
619
|
+
};
|
|
620
|
+
for (const ch of resp.channels ?? []) {
|
|
621
|
+
const uid = ch.user;
|
|
622
|
+
if (!uid || known.has(uid)) continue;
|
|
623
|
+
if (connectCount >= CONNECT_SCAN_LIMIT) {
|
|
624
|
+
capped = true;
|
|
625
|
+
break;
|
|
626
|
+
}
|
|
627
|
+
known.add(uid);
|
|
628
|
+
connectCount++;
|
|
629
|
+
let u: Member | undefined;
|
|
630
|
+
try {
|
|
631
|
+
u = ((await get(token, "users.info", { user: uid }, cookie)) as { user?: Member }).user;
|
|
632
|
+
} catch (e) {
|
|
633
|
+
// A rate limit is not "this person does not match" — it is the scan
|
|
634
|
+
// being cut off mid-way, and swallowing it is precisely how a lookup
|
|
635
|
+
// that could not see its population ends up reporting absence. Let it
|
|
636
|
+
// out. Any other per-profile failure costs us that one person, and is
|
|
637
|
+
// counted so the error can admit the search was incomplete.
|
|
638
|
+
if (e instanceof RateLimitError) throw e;
|
|
639
|
+
skipped++;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (u && memberMatches(u, nameNorm)) {
|
|
643
|
+
return { id: uid, dm: String(ch.id ?? ""), workspaceCount, connectCount, skipped, capped };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (capped) break;
|
|
647
|
+
dmCursor = resp.response_metadata?.next_cursor ?? "";
|
|
648
|
+
if (!dmCursor) break;
|
|
649
|
+
}
|
|
650
|
+
return { id: "", workspaceCount, connectCount, skipped, capped };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The error for a lookup that came back empty. An empty roster and a wrong
|
|
654
|
+
* handle must not share a sentence: the first is a token or scope fault the
|
|
655
|
+
* caller can fix, the second is a typo, and "User not found" told both stories
|
|
656
|
+
* at once — so a lookup that could not see its population reported absence.
|
|
657
|
+
*
|
|
658
|
+
* "account(s)", not "member(s)", and the count will not match `user ls`: that
|
|
659
|
+
* command hides deleted accounts and bots, and this counts the raw users.list
|
|
660
|
+
* the search actually walked. The two differ by design — the number here is a
|
|
661
|
+
* report on the search, not a headcount of the workspace. */
|
|
662
|
+
function userNotFoundError(ref: string, found: UserSearch): Error {
|
|
663
|
+
if (found.workspaceCount === 0) {
|
|
664
|
+
return new Error(
|
|
665
|
+
`Cannot resolve ${ref}: users.list returned no members at all, so nothing could have matched.\n` +
|
|
666
|
+
`That is a token or scope fault, not a wrong handle — this says nothing about whether ${ref} exists.\n` +
|
|
667
|
+
` Check who you are: slack auth ls`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const searched = found.connectCount
|
|
671
|
+
? `${found.workspaceCount} workspace account(s) and ${found.connectCount} Slack Connect DM partner(s)`
|
|
672
|
+
: `${found.workspaceCount} workspace account(s)`;
|
|
673
|
+
// Anything the scan did not actually look at is said out loud. "None matched"
|
|
674
|
+
// is only true of what was compared, and the gap between that and "not there"
|
|
675
|
+
// is the whole bug this function exists to stop repeating.
|
|
676
|
+
const gaps: string[] = [];
|
|
677
|
+
if (found.capped) {
|
|
678
|
+
gaps.push(`stopped after ${CONNECT_SCAN_LIMIT} Slack Connect DM partners, so there may be more`);
|
|
679
|
+
}
|
|
680
|
+
if (found.skipped) {
|
|
681
|
+
gaps.push(`${found.skipped} DM partner profile(s) could not be read and were never compared`);
|
|
682
|
+
}
|
|
683
|
+
return new Error(
|
|
684
|
+
`User not found: ${ref} — searched ${searched}, none matched.\n` +
|
|
685
|
+
(gaps.length ? `This search was incomplete: ${gaps.join("; ")}.\n` : "") +
|
|
686
|
+
`Slack Connect people from another workspace are not in users.list, so if they never\n` +
|
|
687
|
+
`DMed you they cannot be found by name at all. Look them up and use their ID:\n` +
|
|
688
|
+
` slack user ls --external`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
|
|
694
|
+
* the lookup runs on a *user* token (which has users:read), then the caller
|
|
695
|
+
* opens the DM with the bot token — so the bot never needs users:read.
|
|
696
|
+
*/
|
|
697
|
+
export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
|
|
698
|
+
// `@U0123456789` — an id wearing the `@` that every other user target takes.
|
|
699
|
+
// Nobody is NAMED that, so the name walk below would end in "user not found"
|
|
700
|
+
// and point at the wrong problem: the user is there, only the `@` is not.
|
|
701
|
+
const bare = ref.startsWith("@") ? ref.slice(1) : ref;
|
|
702
|
+
if (isUserId(bare)) return bare;
|
|
703
|
+
if (!ref.startsWith("@")) throw new Error(`Expected @user or a user ID, got: ${ref}`);
|
|
704
|
+
const nameNorm = normName(bare);
|
|
705
|
+
const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
|
|
706
|
+
if (nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm) {
|
|
707
|
+
if (!selfInfo.user_id) throw new Error("auth.test did not return user_id");
|
|
708
|
+
return selfInfo.user_id;
|
|
709
|
+
}
|
|
710
|
+
const found = await searchUsers(token, nameNorm, cookie);
|
|
711
|
+
if (found.id) return found.id;
|
|
712
|
+
throw userNotFoundError(ref, found);
|
|
544
713
|
}
|
|
545
714
|
|
|
546
715
|
/** Parse a Slack permalink, returning channel ID, optional message ts, and optional thread_ts.
|
|
@@ -576,6 +745,13 @@ function isChannelId(s: string): boolean {
|
|
|
576
745
|
return /^[CDG][A-Z0-9]{8,}$/.test(s);
|
|
577
746
|
}
|
|
578
747
|
|
|
748
|
+
/** A Slack user ID: `U…` (member) or `W…` (Enterprise Grid member). Upper-case
|
|
749
|
+
* only — that is what keeps a lowercase handle like `@u1234567` reading as a
|
|
750
|
+
* name instead of being swallowed by the id branch. */
|
|
751
|
+
function isUserId(s: string): boolean {
|
|
752
|
+
return /^[UW][A-Z0-9]{6,}$/.test(s);
|
|
753
|
+
}
|
|
754
|
+
|
|
579
755
|
export async function resolveChannel(token: string, ref: string, cookie?: string): Promise<string> {
|
|
580
756
|
// Accept Slack permalinks directly
|
|
581
757
|
const fromUrl = parseSlackUrl(ref);
|
|
@@ -583,6 +759,10 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
|
|
|
583
759
|
// Accept raw IDs (C..., D..., G...) as-is
|
|
584
760
|
if (!ref.startsWith("@") && !ref.startsWith("#")) {
|
|
585
761
|
if (isChannelId(ref)) return ref;
|
|
762
|
+
// A bare `U…` names a person, and the conversation meant by a person is the
|
|
763
|
+
// DM with them. Refusing it as a malformed target sent callers hunting for a
|
|
764
|
+
// syntax error when what they had handed over was a perfectly good user.
|
|
765
|
+
if (isUserId(ref)) return dmWithUser(token, ref, cookie);
|
|
586
766
|
throw new Error(`Target must start with # or @ (or be a Slack URL/ID), got: ${ref}`);
|
|
587
767
|
}
|
|
588
768
|
// `#C0123456789` — an ID that was given the `#` every other target form takes.
|
|
@@ -596,6 +776,9 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
|
|
|
596
776
|
const nameNorm = normName(rawName);
|
|
597
777
|
|
|
598
778
|
if (isIm) {
|
|
779
|
+
// `@U0123456789` — the id form wearing the `@`, same reasoning as the bare
|
|
780
|
+
// id above: resolve it as a person, not as a name nobody has.
|
|
781
|
+
if (isUserId(rawName)) return dmWithUser(token, rawName, cookie);
|
|
599
782
|
// Use auth.test to check if @name refers to self (avoids users:read scope requirement).
|
|
600
783
|
// auth.test is always available; users.list requires users:read which xoxc- tokens lack.
|
|
601
784
|
const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
|
|
@@ -603,55 +786,16 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
|
|
|
603
786
|
if (isSelf) {
|
|
604
787
|
const userId = selfInfo.user_id;
|
|
605
788
|
if (!userId) throw new Error("auth.test did not return user_id");
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
};
|
|
609
|
-
const dm = (dmResp.channels ?? []).find((ch) => ch.user === userId);
|
|
610
|
-
if (dm?.id) return String(dm.id);
|
|
611
|
-
return openDm(token, userId, cookie);
|
|
789
|
+
const dm = await findExistingDm(token, userId, cookie);
|
|
790
|
+
return dm || openDm(token, userId, cookie);
|
|
612
791
|
}
|
|
613
792
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const resp = (await get(token, "users.list", params, cookie)) as {
|
|
621
|
-
members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
|
|
622
|
-
response_metadata?: { next_cursor?: string };
|
|
623
|
-
};
|
|
624
|
-
for (const u of resp.members ?? []) {
|
|
625
|
-
const n = normName(u.name ?? "");
|
|
626
|
-
const rn = normName(u.real_name ?? "");
|
|
627
|
-
const dn = normName(u.profile?.display_name ?? "");
|
|
628
|
-
if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) {
|
|
629
|
-
userId = u.id ?? "";
|
|
630
|
-
break;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
if (userId) break;
|
|
634
|
-
userCursor = resp.response_metadata?.next_cursor ?? "";
|
|
635
|
-
if (!userCursor) break;
|
|
636
|
-
}
|
|
637
|
-
if (!userId) throw new Error(`User not found: ${ref}`);
|
|
638
|
-
|
|
639
|
-
// Find an existing DM (avoids needing im:write scope)
|
|
640
|
-
let dmCursor = "";
|
|
641
|
-
while (true) {
|
|
642
|
-
const params: Record<string, string> = { types: "im", limit: "200" };
|
|
643
|
-
if (dmCursor) params.cursor = dmCursor;
|
|
644
|
-
const resp = (await get(token, "conversations.list", params, cookie)) as {
|
|
645
|
-
channels?: Array<{ id?: string; user?: string }>;
|
|
646
|
-
response_metadata?: { next_cursor?: string };
|
|
647
|
-
};
|
|
648
|
-
for (const ch of resp.channels ?? []) {
|
|
649
|
-
if (ch.user === userId) return String(ch.id ?? "");
|
|
650
|
-
}
|
|
651
|
-
dmCursor = resp.response_metadata?.next_cursor ?? "";
|
|
652
|
-
if (!dmCursor) break;
|
|
653
|
-
}
|
|
654
|
-
throw new Error(`No existing DM with ${ref} (${userId}). Open it once in Slack first.`);
|
|
793
|
+
const found = await searchUsers(token, nameNorm, cookie);
|
|
794
|
+
if (!found.id) throw userNotFoundError(ref, found);
|
|
795
|
+
// The Connect scan already walked the IM list to find them, so it knows the
|
|
796
|
+
// channel — asking again would only pay for the same pages twice.
|
|
797
|
+
if (found.dm) return found.dm;
|
|
798
|
+
return dmWithUser(token, found.id, cookie, ref);
|
|
655
799
|
}
|
|
656
800
|
|
|
657
801
|
// Channel lookup
|
package/ts/tail.ts
CHANGED
|
@@ -238,7 +238,9 @@ export async function cmdTail(
|
|
|
238
238
|
process.exit(1);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
// Cookie included: a desktop (xoxc-) token is rejected by the public Web API
|
|
242
|
+
// without it, so dropping it here failed the lookup before the tail started.
|
|
243
|
+
const channelId = await resolveChannel(token, target, opts.cookie);
|
|
242
244
|
const interval = opts.interval ?? 60000;
|
|
243
245
|
// Optional auto-stop deadline (e.g. wait at most 30m for a reply).
|
|
244
246
|
const deadline = opts.timeout ? _internals.now() + parseSince(opts.timeout) * 1000 : Infinity;
|