slack-term 1.16.0 → 1.18.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 +7 -0
- package/dist/cli.js +67 -66
- package/package.json +1 -1
- package/ts/cli.ts +38 -9
- package/ts/format.ts +90 -1
- package/ts/slack.ts +20 -0
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
getPath,
|
|
47
47
|
type Json,
|
|
48
48
|
} from "./slack.ts";
|
|
49
|
-
import { dayLabel, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
49
|
+
import { dayLabel, encodeMentions, formatYmdHm, resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
50
50
|
|
|
51
51
|
function loadDotenv(path: string): void {
|
|
52
52
|
if (!existsSync(path)) return;
|
|
@@ -653,6 +653,7 @@ interface EditArgs {
|
|
|
653
653
|
newText: string;
|
|
654
654
|
code?: string;
|
|
655
655
|
channelId?: string;
|
|
656
|
+
mentions?: boolean;
|
|
656
657
|
}
|
|
657
658
|
async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
658
659
|
const { ref, ts } = splitRefTs(args.target);
|
|
@@ -675,18 +676,23 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
675
676
|
}
|
|
676
677
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
677
678
|
|
|
678
|
-
|
|
679
|
+
// Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
|
|
680
|
+
const newText = args.mentions
|
|
681
|
+
? await encodeMentions(token, args.newText, channelId)
|
|
682
|
+
: args.newText;
|
|
683
|
+
|
|
684
|
+
const code = safetyCode(originalText, newText);
|
|
679
685
|
if (args.code !== code) {
|
|
680
686
|
requireCode(args.code, code, [
|
|
681
687
|
`--- Original message -------------------------`,
|
|
682
688
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
683
689
|
`--- Replacing with ---------------------------`,
|
|
684
|
-
...
|
|
690
|
+
...newText.split("\n").map((l) => ` ${l}`),
|
|
685
691
|
`--------------------------------────────────`,
|
|
686
692
|
]);
|
|
687
693
|
}
|
|
688
694
|
|
|
689
|
-
const newTs = await editMessage(token, channelId, ts,
|
|
695
|
+
const newTs = await editMessage(token, channelId, ts, newText);
|
|
690
696
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
691
697
|
}
|
|
692
698
|
|
|
@@ -741,6 +747,11 @@ interface SendArgs {
|
|
|
741
747
|
userId?: string;
|
|
742
748
|
asBot?: boolean;
|
|
743
749
|
broadcast?: boolean;
|
|
750
|
+
mentions?: boolean;
|
|
751
|
+
// Token used to resolve @handle mentions (needs users:read). Defaults to the
|
|
752
|
+
// send token; set to the user token when sending --as-bot so the bot token
|
|
753
|
+
// need not carry users:read.
|
|
754
|
+
mentionToken?: string;
|
|
744
755
|
}
|
|
745
756
|
|
|
746
757
|
// Detect the silent-failure footgun: DMing yourself with your own user token.
|
|
@@ -777,6 +788,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
777
788
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
778
789
|
else channelId = await resolveChannel(token, ref);
|
|
779
790
|
|
|
791
|
+
// Convert @handle tokens to <@USERID> before hashing/sending so the safety
|
|
792
|
+
// gate covers exactly what will be posted. Unresolved handles stay as text.
|
|
793
|
+
const message = args.mentions
|
|
794
|
+
? await encodeMentions(args.mentionToken ?? token, args.message, channelId)
|
|
795
|
+
: args.message;
|
|
796
|
+
|
|
780
797
|
// Fetch last 1 message for context hash. Fail-soft: a bot token without
|
|
781
798
|
// im:history (or channels:history) can still send — only the preview of the
|
|
782
799
|
// prior message is lost, so default to empty rather than blocking the send.
|
|
@@ -800,7 +817,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
800
817
|
|
|
801
818
|
// Hash covers the destination too — a code minted for one channel/thread
|
|
802
819
|
// cannot confirm a send to another.
|
|
803
|
-
const code = safetyCode(channelId, threadTs ?? "", lastText,
|
|
820
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message);
|
|
804
821
|
|
|
805
822
|
if (args.code !== code) {
|
|
806
823
|
const dest = await destLabel(token, channelId, ref);
|
|
@@ -812,11 +829,11 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
812
829
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
813
830
|
`--- Sending ----------------------------------`,
|
|
814
831
|
destLine,
|
|
815
|
-
` Message: ${
|
|
832
|
+
` Message: ${message}`,
|
|
816
833
|
`--------------------------------────────────`,
|
|
817
834
|
]);
|
|
818
835
|
}
|
|
819
|
-
const ts = await slackSend(token, channelId,
|
|
836
|
+
const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
|
|
820
837
|
let permalink = "";
|
|
821
838
|
try {
|
|
822
839
|
permalink = await getPermalink(token, channelId, ts);
|
|
@@ -1246,13 +1263,23 @@ async function main(): Promise<void> {
|
|
|
1246
1263
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1247
1264
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1248
1265
|
.option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
|
|
1249
|
-
.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." })
|
|
1266
|
+
.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." })
|
|
1267
|
+
.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." }),
|
|
1250
1268
|
async (argv) => {
|
|
1251
1269
|
const args: SendArgs = { target: argv.target!, message: argv.message! };
|
|
1252
1270
|
if (argv.code) args.code = argv.code;
|
|
1253
1271
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1254
1272
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
1255
1273
|
if (argv.broadcast) args.broadcast = true;
|
|
1274
|
+
// Mentions on by default; `--no-mentions` (yargs negation) sets it false.
|
|
1275
|
+
// A message with no `@token` short-circuits inside encodeMentions, so the
|
|
1276
|
+
// common case pays no extra API call.
|
|
1277
|
+
if (argv.mentions !== false) {
|
|
1278
|
+
args.mentions = true;
|
|
1279
|
+
// Resolve mentions with the user token (has users:read) even when the
|
|
1280
|
+
// message itself is sent via the bot token.
|
|
1281
|
+
args.mentionToken = tok(argv as W);
|
|
1282
|
+
}
|
|
1256
1283
|
let sendToken: string;
|
|
1257
1284
|
if (argv["as-bot"]) {
|
|
1258
1285
|
const botToken = resolveBotToken();
|
|
@@ -1369,11 +1396,13 @@ async function main(): Promise<void> {
|
|
|
1369
1396
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1370
1397
|
.positional("newText", { type: "string", demandOption: true })
|
|
1371
1398
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
1372
|
-
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1399
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1400
|
+
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." }),
|
|
1373
1401
|
async (argv) => {
|
|
1374
1402
|
const args: EditArgs = { target: argv.target!, newText: argv.newText! };
|
|
1375
1403
|
if (argv.code) args.code = argv.code;
|
|
1376
1404
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1405
|
+
if (argv.mentions !== false) args.mentions = true;
|
|
1377
1406
|
await cmdEdit(tok(argv as W), args);
|
|
1378
1407
|
},
|
|
1379
1408
|
)
|
package/ts/format.ts
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
// Text-formatting helpers: mention/date-markup resolution, day grouping.
|
|
2
2
|
|
|
3
|
-
import { userName } from "./slack.ts";
|
|
3
|
+
import { listConversationMembers, listUsers, userInfo, userName, type Json } from "./slack.ts";
|
|
4
|
+
|
|
5
|
+
/** Match an `@handle` token at a word boundary, capturing the handle.
|
|
6
|
+
* - The negative lookbehind keeps `@` inside emails (`a@b.com`) from matching.
|
|
7
|
+
* - Each `.` must be followed by more handle chars, so a trailing sentence dot
|
|
8
|
+
* (`thanks @taku.`) is left out of the capture. */
|
|
9
|
+
function mentionRe(): RegExp {
|
|
10
|
+
return /(?<![A-Za-z0-9._@-])@([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)/g;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Lowercase + strip hyphens/underscores/whitespace for loose handle matching. */
|
|
14
|
+
function normHandle(s: string): string {
|
|
15
|
+
return s.toLowerCase().replace(/[-_\s]/g, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asRecord(v: Json | undefined): Record<string, Json> {
|
|
19
|
+
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True when a user record matches `@handle` by name / real_name / display_name / email. */
|
|
23
|
+
function userMatchesHandle(u: Record<string, Json>, handle: string): boolean {
|
|
24
|
+
const nh = normHandle(handle);
|
|
25
|
+
const profile = asRecord(u.profile);
|
|
26
|
+
for (const v of [u.name, u.real_name, profile.display_name, profile.real_name]) {
|
|
27
|
+
if (typeof v === "string" && v && normHandle(v) === nh) return true;
|
|
28
|
+
}
|
|
29
|
+
const email = typeof profile.email === "string" ? profile.email.toLowerCase() : "";
|
|
30
|
+
return email !== "" && email === handle.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rewrite `@handle` tokens in `text` to `<@USERID>` so Slack renders them as
|
|
35
|
+
* real mentions. Resolution order per handle:
|
|
36
|
+
* 1. workspace users.list (name / real_name / display_name / email)
|
|
37
|
+
* 2. the target channel's members (conversations.members → users.info) — this
|
|
38
|
+
* reaches Slack Connect guests absent from the workspace users.list
|
|
39
|
+
* Handles that resolve to nothing are left as plain text (never destroyed) and
|
|
40
|
+
* reported via `warn`. Lookups are cached so each API call runs at most once.
|
|
41
|
+
*/
|
|
42
|
+
export async function encodeMentions(
|
|
43
|
+
token: string,
|
|
44
|
+
text: string,
|
|
45
|
+
channelId: string | undefined,
|
|
46
|
+
opts: { warn?: (msg: string) => void } = {},
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const warn = opts.warn ?? ((m: string) => process.stderr.write(`${m}\n`));
|
|
49
|
+
|
|
50
|
+
const handles = new Set<string>();
|
|
51
|
+
for (const m of text.matchAll(mentionRe())) handles.add(m[1]!);
|
|
52
|
+
if (handles.size === 0) return text;
|
|
53
|
+
|
|
54
|
+
const resolved = new Map<string, string>(); // handle -> user ID
|
|
55
|
+
|
|
56
|
+
// Step 1: workspace users.list (fetched once).
|
|
57
|
+
const wsResp = (await listUsers(token)) as { members?: Json[] };
|
|
58
|
+
const wsUsers = (wsResp.members ?? []).map(asRecord);
|
|
59
|
+
const unresolved: string[] = [];
|
|
60
|
+
for (const handle of handles) {
|
|
61
|
+
const hit = wsUsers.find((u) => userMatchesHandle(u, handle));
|
|
62
|
+
const id = hit && typeof hit.id === "string" ? hit.id : "";
|
|
63
|
+
if (id) resolved.set(handle, id);
|
|
64
|
+
else unresolved.push(handle);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Step 2: channel members (fetched once) — covers external/Connect guests.
|
|
68
|
+
if (unresolved.length > 0 && channelId) {
|
|
69
|
+
const memberIds = await listConversationMembers(token, channelId);
|
|
70
|
+
const infos: Record<string, Json>[] = [];
|
|
71
|
+
for (const uid of memberIds) {
|
|
72
|
+
const info = asRecord((await userInfo(token, uid)) as Json);
|
|
73
|
+
const u = asRecord(info.user);
|
|
74
|
+
if (typeof u.id !== "string") u.id = uid;
|
|
75
|
+
infos.push(u);
|
|
76
|
+
}
|
|
77
|
+
for (const handle of unresolved) {
|
|
78
|
+
const hit = infos.find((u) => userMatchesHandle(u, handle));
|
|
79
|
+
const id = hit && typeof hit.id === "string" ? hit.id : "";
|
|
80
|
+
if (id) resolved.set(handle, id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const handle of handles) {
|
|
85
|
+
if (!resolved.has(handle)) warn(`warn: unresolved mention @${handle} (left as text)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return text.replace(mentionRe(), (full, handle: string) => {
|
|
89
|
+
const id = resolved.get(handle);
|
|
90
|
+
return id ? `<@${id}>` : full;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
4
93
|
|
|
5
94
|
export async function resolveMentions(
|
|
6
95
|
token: string,
|
package/ts/slack.ts
CHANGED
|
@@ -558,6 +558,26 @@ export async function listUsers(token: string): Promise<Json> {
|
|
|
558
558
|
return { members: allMembers };
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
+
/** List the member user IDs of a channel/DM (paginated).
|
|
562
|
+
* Used by mention encoding to reach members — including Slack Connect guests —
|
|
563
|
+
* who do not appear in the workspace-wide users.list. */
|
|
564
|
+
export async function listConversationMembers(token: string, channel: string): Promise<string[]> {
|
|
565
|
+
const all: string[] = [];
|
|
566
|
+
let cursor = "";
|
|
567
|
+
while (true) {
|
|
568
|
+
const params: Record<string, string> = { channel, limit: "200" };
|
|
569
|
+
if (cursor) params.cursor = cursor;
|
|
570
|
+
const resp = (await get(token, "conversations.members", params)) as {
|
|
571
|
+
members?: string[];
|
|
572
|
+
response_metadata?: { next_cursor?: string };
|
|
573
|
+
};
|
|
574
|
+
all.push(...(resp.members ?? []));
|
|
575
|
+
cursor = resp.response_metadata?.next_cursor ?? "";
|
|
576
|
+
if (!cursor) break;
|
|
577
|
+
}
|
|
578
|
+
return all;
|
|
579
|
+
}
|
|
580
|
+
|
|
561
581
|
// Draft API (internal — requires xoxc session token + xoxd cookie)
|
|
562
582
|
export async function listDrafts(token: string, cookie?: string): Promise<Json> {
|
|
563
583
|
return postSession(token, "drafts.list", {}, cookie);
|