slack-term 1.15.0 → 1.17.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 +5 -0
- package/dist/cli.js +73 -72
- package/package.json +1 -1
- package/ts/cli.ts +78 -25
- 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;
|
|
@@ -188,31 +188,52 @@ async function formatMsgLine(
|
|
|
188
188
|
return `${stamp} ${who}: ${body}${tail}`;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Slim a raw message down to the fields a script actually needs. Keeps the
|
|
192
|
+
// author's user ID (incl. external / Slack-Connect guests that never appear in
|
|
193
|
+
// users.list) so callers can resolve mentions without scraping history by hand.
|
|
194
|
+
function slimMsg(m: Record<string, Json>): Record<string, Json> {
|
|
195
|
+
return {
|
|
196
|
+
ts: m.ts ?? null,
|
|
197
|
+
user: m.user ?? null,
|
|
198
|
+
username: m.username ?? null,
|
|
199
|
+
bot_id: m.bot_id ?? null,
|
|
200
|
+
thread_ts: m.thread_ts ?? null,
|
|
201
|
+
text: typeof m.text === "string" ? m.text : "",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
// --- msgs <target> — channel/DM history with timestamps ---
|
|
192
|
-
async function cmdMsgsTarget(token: string, target: string, limit: number): Promise<void> {
|
|
206
|
+
async function cmdMsgsTarget(token: string, target: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
193
207
|
const parsed = parseSlackPermalink(target);
|
|
194
208
|
const channelId = await resolveChannel(token, target);
|
|
195
209
|
const cache = new Map<string, string>();
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
console.log(await formatMsgLine(token, m, cache));
|
|
210
|
+
const fetchMsgs = async (): Promise<Record<string, Json>[]> => {
|
|
211
|
+
if (parsed?.threadTs) {
|
|
212
|
+
const resp = (await replies(token, channelId, parsed.threadTs, limit)) as Record<string, Json>;
|
|
213
|
+
return asArray(resp.messages).map(asRecord);
|
|
201
214
|
}
|
|
202
|
-
} else {
|
|
203
215
|
const hist = (await history(token, channelId, limit)) as Record<string, Json>;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
return asArray(hist.messages).map(asRecord).reverse();
|
|
217
|
+
};
|
|
218
|
+
const msgs = await fetchMsgs();
|
|
219
|
+
if (format === "jsonl") {
|
|
220
|
+
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
for (const m of msgs) {
|
|
224
|
+
console.log(await formatMsgLine(token, m, cache));
|
|
208
225
|
}
|
|
209
226
|
}
|
|
210
227
|
|
|
211
228
|
// --- thread ---
|
|
212
|
-
async function cmdThread(token: string, target: string, ts: string, limit: number): Promise<void> {
|
|
229
|
+
async function cmdThread(token: string, target: string, ts: string, limit: number, format: "text" | "jsonl" = "text"): Promise<void> {
|
|
213
230
|
const channelId = await resolveChannel(token, target);
|
|
214
231
|
const resp = (await replies(token, channelId, parseInputTs(ts), limit)) as Record<string, Json>;
|
|
215
232
|
const msgs = asArray(resp.messages).map(asRecord);
|
|
233
|
+
if (format === "jsonl") {
|
|
234
|
+
for (const m of msgs) console.log(JSON.stringify(slimMsg(m)));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
216
237
|
const cache = new Map<string, string>();
|
|
217
238
|
for (const m of msgs) {
|
|
218
239
|
console.log(await formatMsgLine(token, m, cache));
|
|
@@ -632,6 +653,7 @@ interface EditArgs {
|
|
|
632
653
|
newText: string;
|
|
633
654
|
code?: string;
|
|
634
655
|
channelId?: string;
|
|
656
|
+
mentions?: boolean;
|
|
635
657
|
}
|
|
636
658
|
async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
637
659
|
const { ref, ts } = splitRefTs(args.target);
|
|
@@ -654,18 +676,23 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
|
|
|
654
676
|
}
|
|
655
677
|
const originalText = typeof original.text === "string" ? original.text : "";
|
|
656
678
|
|
|
657
|
-
|
|
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);
|
|
658
685
|
if (args.code !== code) {
|
|
659
686
|
requireCode(args.code, code, [
|
|
660
687
|
`--- Original message -------------------------`,
|
|
661
688
|
...originalText.split("\n").map((l) => ` ${l}`),
|
|
662
689
|
`--- Replacing with ---------------------------`,
|
|
663
|
-
...
|
|
690
|
+
...newText.split("\n").map((l) => ` ${l}`),
|
|
664
691
|
`--------------------------------────────────`,
|
|
665
692
|
]);
|
|
666
693
|
}
|
|
667
694
|
|
|
668
|
-
const newTs = await editMessage(token, channelId, ts,
|
|
695
|
+
const newTs = await editMessage(token, channelId, ts, newText);
|
|
669
696
|
console.log(`✓ Edited (ts: ${newTs})`);
|
|
670
697
|
}
|
|
671
698
|
|
|
@@ -720,6 +747,11 @@ interface SendArgs {
|
|
|
720
747
|
userId?: string;
|
|
721
748
|
asBot?: boolean;
|
|
722
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;
|
|
723
755
|
}
|
|
724
756
|
|
|
725
757
|
// Detect the silent-failure footgun: DMing yourself with your own user token.
|
|
@@ -756,6 +788,12 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
756
788
|
else if (args.userId) channelId = await openDm(token, args.userId);
|
|
757
789
|
else channelId = await resolveChannel(token, ref);
|
|
758
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
|
+
|
|
759
797
|
// Fetch last 1 message for context hash. Fail-soft: a bot token without
|
|
760
798
|
// im:history (or channels:history) can still send — only the preview of the
|
|
761
799
|
// prior message is lost, so default to empty rather than blocking the send.
|
|
@@ -779,7 +817,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
779
817
|
|
|
780
818
|
// Hash covers the destination too — a code minted for one channel/thread
|
|
781
819
|
// cannot confirm a send to another.
|
|
782
|
-
const code = safetyCode(channelId, threadTs ?? "", lastText,
|
|
820
|
+
const code = safetyCode(channelId, threadTs ?? "", lastText, message);
|
|
783
821
|
|
|
784
822
|
if (args.code !== code) {
|
|
785
823
|
const dest = await destLabel(token, channelId, ref);
|
|
@@ -791,11 +829,11 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
|
|
|
791
829
|
` ${lastUser}: ${lastText.split("\n")[0]?.slice(0, 100) ?? "(empty)"}`,
|
|
792
830
|
`--- Sending ----------------------------------`,
|
|
793
831
|
destLine,
|
|
794
|
-
` Message: ${
|
|
832
|
+
` Message: ${message}`,
|
|
795
833
|
`--------------------------------────────────`,
|
|
796
834
|
]);
|
|
797
835
|
}
|
|
798
|
-
const ts = await slackSend(token, channelId,
|
|
836
|
+
const ts = await slackSend(token, channelId, message, threadTs, args.broadcast);
|
|
799
837
|
let permalink = "";
|
|
800
838
|
try {
|
|
801
839
|
permalink = await getPermalink(token, channelId, ts);
|
|
@@ -1025,10 +1063,13 @@ async function main(): Promise<void> {
|
|
|
1025
1063
|
"Browse messages",
|
|
1026
1064
|
(y) => y
|
|
1027
1065
|
.positional("target", { type: "string", describe: "#channel, @user, or URL" })
|
|
1028
|
-
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1066
|
+
.option("limit", { alias: "n", type: "number", default: 20, describe: "Number of messages" })
|
|
1067
|
+
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1068
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
1029
1069
|
async (argv) => {
|
|
1030
1070
|
const token = tok(argv as W);
|
|
1031
|
-
|
|
1071
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1072
|
+
if (argv.target) await cmdMsgsTarget(token, argv.target, argv.limit, format);
|
|
1032
1073
|
else await cmdMsgs(token);
|
|
1033
1074
|
},
|
|
1034
1075
|
)
|
|
@@ -1038,9 +1079,12 @@ async function main(): Promise<void> {
|
|
|
1038
1079
|
(y) => y
|
|
1039
1080
|
.positional("target", { type: "string", demandOption: true })
|
|
1040
1081
|
.positional("ts", { type: "string", demandOption: true })
|
|
1041
|
-
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
1082
|
+
.option("limit", { alias: "n", type: "number", default: 100 })
|
|
1083
|
+
.option("format", { type: "string", choices: ["text", "jsonl"] as const, default: "text", describe: "jsonl exposes raw ts/user/text (incl. external-guest user IDs)" })
|
|
1084
|
+
.option("json", { type: "boolean", default: false, describe: "Alias for --format=jsonl" }),
|
|
1042
1085
|
async (argv) => {
|
|
1043
|
-
|
|
1086
|
+
const format = argv.json ? "jsonl" : (argv.format as "text" | "jsonl");
|
|
1087
|
+
await cmdThread(tok(argv as W), argv.target!, argv.ts!, argv.limit, format);
|
|
1044
1088
|
},
|
|
1045
1089
|
)
|
|
1046
1090
|
.command(
|
|
@@ -1219,13 +1263,20 @@ async function main(): Promise<void> {
|
|
|
1219
1263
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1220
1264
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
1221
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" })
|
|
1222
|
-
.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: false, describe: "Convert @handle tokens in the message to real <@USERID> mentions (resolves via users.list, then channel members for Slack Connect guests; unresolved handles stay as plain text)" }),
|
|
1223
1268
|
async (argv) => {
|
|
1224
1269
|
const args: SendArgs = { target: argv.target!, message: argv.message! };
|
|
1225
1270
|
if (argv.code) args.code = argv.code;
|
|
1226
1271
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1227
1272
|
if (argv["user-id"]) args.userId = argv["user-id"];
|
|
1228
1273
|
if (argv.broadcast) args.broadcast = true;
|
|
1274
|
+
if (argv.mentions) {
|
|
1275
|
+
args.mentions = true;
|
|
1276
|
+
// Resolve mentions with the user token (has users:read) even when the
|
|
1277
|
+
// message itself is sent via the bot token.
|
|
1278
|
+
args.mentionToken = tok(argv as W);
|
|
1279
|
+
}
|
|
1229
1280
|
let sendToken: string;
|
|
1230
1281
|
if (argv["as-bot"]) {
|
|
1231
1282
|
const botToken = resolveBotToken();
|
|
@@ -1342,11 +1393,13 @@ async function main(): Promise<void> {
|
|
|
1342
1393
|
.positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
|
|
1343
1394
|
.positional("newText", { type: "string", demandOption: true })
|
|
1344
1395
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
1345
|
-
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1396
|
+
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
1397
|
+
.option("mentions", { type: "boolean", default: false, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (unresolved handles stay as plain text)" }),
|
|
1346
1398
|
async (argv) => {
|
|
1347
1399
|
const args: EditArgs = { target: argv.target!, newText: argv.newText! };
|
|
1348
1400
|
if (argv.code) args.code = argv.code;
|
|
1349
1401
|
if (argv["channel-id"]) args.channelId = argv["channel-id"];
|
|
1402
|
+
if (argv.mentions) args.mentions = true;
|
|
1350
1403
|
await cmdEdit(tok(argv as W), args);
|
|
1351
1404
|
},
|
|
1352
1405
|
)
|
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);
|