slack-term 1.24.0 → 1.25.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 +108 -7
- package/SKILL.md +76 -5
- package/dist/cli.js +92 -89
- package/package.json +3 -1
- package/ts/cache.ts +120 -0
- package/ts/cli.ts +508 -34
- package/ts/format.ts +13 -10
- package/ts/slack.ts +42 -2
- package/ts/tail.ts +11 -7
- package/ts/todo.ts +483 -0
package/ts/format.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type MentionEncodeResult = {
|
|
|
38
38
|
|
|
39
39
|
/** Full normalized name-forms of a member — the WHOLE display_name / real_name /
|
|
40
40
|
* name, never split into parts. An explicit `@name` mention must match a full
|
|
41
|
-
* name (
|
|
41
|
+
* name (`@鈴木陽菜`), never a bare surname fragment (`@鈴木`), which would be
|
|
42
42
|
* ambiguous. Length ≥ 2. */
|
|
43
43
|
function fullNameForms(u: Record<string, Json>): string[] {
|
|
44
44
|
const profile = asRecord(u.profile);
|
|
@@ -55,7 +55,7 @@ function fullNameForms(u: Record<string, Json>): string[] {
|
|
|
55
55
|
type CjkMatch = { userId: string; matchLen: number; display: string } | "ambiguous" | undefined;
|
|
56
56
|
|
|
57
57
|
/** Directory match for a CJK `@名前` token. `run` is the raw token after `@`
|
|
58
|
-
* (e.g. "
|
|
58
|
+
* (e.g. "鈴木陽菜さん"). A user resolves only when the token equals their full
|
|
59
59
|
* name exactly, or equals their full name followed by exactly one trailing
|
|
60
60
|
* honorific ("さん", "様", …) which is then left in the message text rather than
|
|
61
61
|
* swallowed into the tag. Deliberately NOT a partial-prefix match: it would
|
|
@@ -105,9 +105,9 @@ function matchCjkRun(run: string, dir: Record<string, Json>[]): CjkMatch {
|
|
|
105
105
|
* Rewrite `@handle` / `@名前` tokens in `text` to `<@USERID>` so Slack renders
|
|
106
106
|
* them as real mentions, returning a structured report of what did and did not
|
|
107
107
|
* resolve. Two kinds of token are recognized:
|
|
108
|
-
* - ASCII handles (`@alice`, `@t.
|
|
108
|
+
* - ASCII handles (`@alice`, `@t.yamada19850101`) — matched whole against
|
|
109
109
|
* name / real_name / display_name / email.
|
|
110
|
-
* - CJK names (
|
|
110
|
+
* - CJK names (`@鈴木陽菜`, `@鈴木陽菜さん`) — matched against the directory by
|
|
111
111
|
* exact full name, or full name + one trailing honorific (kept outside the
|
|
112
112
|
* tag). A display name written in kanji/kana (which the old ASCII-only regex
|
|
113
113
|
* could not even capture) resolves; anything only partially matching stays
|
|
@@ -129,8 +129,8 @@ export async function encodeMentionsDetailed(
|
|
|
129
129
|
const NAME_RUN = `[${NAME_CHARS}ー]+`;
|
|
130
130
|
// One mention token = runs of name characters (any script) joined by internal
|
|
131
131
|
// `. _ -`, so a mixed-script / dotted handle is captured WHOLE: `@田中Taro`,
|
|
132
|
-
// `@佐藤.dev`, `@t.
|
|
133
|
-
// so a trailing sentence dot ("@
|
|
132
|
+
// `@佐藤.dev`, `@t.yamada19850101`, `@Alice-A`. It never ends on a separator,
|
|
133
|
+
// so a trailing sentence dot ("@alice.") is left out. Capturing the FULL token —
|
|
134
134
|
// never a script-boundary prefix — is what lets the fail-closed exact match
|
|
135
135
|
// refuse to tag "田中" from "@田中Taro". Lookbehind guard skips emails (`a@b`),
|
|
136
136
|
// already-encoded `<@U…>`, and doubled `@@`.
|
|
@@ -280,8 +280,8 @@ export async function encodeMentions(
|
|
|
280
280
|
|
|
281
281
|
// --- untagged-mention lint (warn-only) -------------------------------------
|
|
282
282
|
// Flags names written as plain text that map to a known workspace member but
|
|
283
|
-
// carry no <@USERID> tag in the same message — the footgun where "
|
|
284
|
-
// reads fine to a human but never actually notifies
|
|
283
|
+
// carry no <@USERID> tag in the same message — the footgun where "山田さん"
|
|
284
|
+
// reads fine to a human but never actually notifies 山田. Never blocks a send.
|
|
285
285
|
|
|
286
286
|
// Person-reference cues. Honorific *suffixes* follow a name (JP + ZH); greeting
|
|
287
287
|
// *prefixes* precede one (EN, handled by regex below). Extend freely.
|
|
@@ -337,7 +337,7 @@ function matchMemberForm(ref: string, forms: string[]): string | undefined {
|
|
|
337
337
|
const nr = normHandle(ref);
|
|
338
338
|
if (nr.length < 2) return undefined;
|
|
339
339
|
// Exact first so the surface rebuilds to the tightest name (e.g. an
|
|
340
|
-
// over-captured "
|
|
340
|
+
// over-captured "ください山田" still reports "山田", not the whole run).
|
|
341
341
|
for (const f of forms) if (nr === f) return f;
|
|
342
342
|
if (isCjk(ref)) {
|
|
343
343
|
for (const f of forms) if (nr.includes(f) || f.includes(nr)) return f;
|
|
@@ -391,6 +391,9 @@ export async function resolveMentions(
|
|
|
391
391
|
token: string,
|
|
392
392
|
text: string,
|
|
393
393
|
cache: Map<string, string>,
|
|
394
|
+
// Session cookie (xoxd) for `token` — an xoxc- desktop token is rejected by
|
|
395
|
+
// users.info without it, which would leave every mention unresolved.
|
|
396
|
+
cookie?: string,
|
|
394
397
|
): Promise<string> {
|
|
395
398
|
let result = text;
|
|
396
399
|
const ids: string[] = [];
|
|
@@ -406,7 +409,7 @@ export async function resolveMentions(
|
|
|
406
409
|
searchOffset = pos + 1;
|
|
407
410
|
}
|
|
408
411
|
for (const uid of ids) {
|
|
409
|
-
if (!cache.has(uid)) cache.set(uid, await userName(token, uid));
|
|
412
|
+
if (!cache.has(uid)) cache.set(uid, await userName(token, uid, cookie));
|
|
410
413
|
const display = cache.get(uid) ?? uid;
|
|
411
414
|
result = result.replaceAll(`<@${uid}>`, `@${display}`);
|
|
412
415
|
// Handle <@UID|label> form — drop label.
|
package/ts/slack.ts
CHANGED
|
@@ -150,7 +150,7 @@ export async function authTest(
|
|
|
150
150
|
// scopes in the `X-OAuth-Scopes` response header on every Web API call, which is
|
|
151
151
|
// the only way to learn what a token can do without probing each endpoint.
|
|
152
152
|
export async function authScopes(token: string, cookie?: string): Promise<{
|
|
153
|
-
userId: string; botId: string; team: string; url: string; scopes: string[];
|
|
153
|
+
userId: string; user: string; botId: string; team: string; url: string; scopes: string[];
|
|
154
154
|
}> {
|
|
155
155
|
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
156
156
|
if (cookie) headers["Cookie"] = `d=${cookie}`;
|
|
@@ -161,11 +161,14 @@ export async function authScopes(token: string, cookie?: string): Promise<{
|
|
|
161
161
|
}
|
|
162
162
|
const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
|
|
163
163
|
const body = (await res.json()) as {
|
|
164
|
-
ok?: boolean; error?: string; user_id?: string; bot_id?: string; team?: string; url?: string;
|
|
164
|
+
ok?: boolean; error?: string; user_id?: string; user?: string; bot_id?: string; team?: string; url?: string;
|
|
165
165
|
};
|
|
166
166
|
if (body.ok !== true) throw new Error(`Slack error on auth.test: ${body.error ?? "unknown"}`);
|
|
167
167
|
return {
|
|
168
168
|
userId: body.user_id ?? "",
|
|
169
|
+
// The token owner's @handle (for a bot token, the bot user's name) — lets a
|
|
170
|
+
// caller name the sending identity without a second auth.test round-trip.
|
|
171
|
+
user: body.user ?? "",
|
|
169
172
|
botId: body.bot_id ?? "",
|
|
170
173
|
team: body.team ?? "",
|
|
171
174
|
url: body.url ?? "",
|
|
@@ -234,6 +237,25 @@ export async function filesList(
|
|
|
234
237
|
return get(token, "files.list", params, cookie);
|
|
235
238
|
}
|
|
236
239
|
|
|
240
|
+
// Slack Lists ("tasklist"). The public slackLists.* Web API rejects the CLI's
|
|
241
|
+
// xoxc desktop token (not_allowed_token_type) and needs a bot token with
|
|
242
|
+
// lists:read; the web client instead uses the internal lists.records.list, which
|
|
243
|
+
// DOES accept an xoxc token + the d cookie — so this reuses the CLI's existing
|
|
244
|
+
// session auth. `listId` is the list's file id (F…), taken from its URL; there
|
|
245
|
+
// is no list-enumeration API, so the caller must supply the id. Paginated by
|
|
246
|
+
// cursor via response_metadata.next_cursor.
|
|
247
|
+
export async function listRecords(
|
|
248
|
+
token: string,
|
|
249
|
+
listId: string,
|
|
250
|
+
opts: { limit?: number; cursor?: string } = {},
|
|
251
|
+
cookie?: string,
|
|
252
|
+
): Promise<Json> {
|
|
253
|
+
const params: Record<string, string> = { list_id: listId };
|
|
254
|
+
if (opts.limit) params.limit = String(opts.limit);
|
|
255
|
+
if (opts.cursor) params.cursor = opts.cursor;
|
|
256
|
+
return postSession(token, "lists.records.list", params, cookie);
|
|
257
|
+
}
|
|
258
|
+
|
|
237
259
|
export async function searchPage(
|
|
238
260
|
token: string,
|
|
239
261
|
query: string,
|
|
@@ -389,6 +411,24 @@ export async function reactionAdd(
|
|
|
389
411
|
await post(token, "reactions.add", { channel, timestamp: ts, name }, cookie);
|
|
390
412
|
}
|
|
391
413
|
|
|
414
|
+
// Read the reactions currently on a message. Returns one entry per emoji with
|
|
415
|
+
// the user IDs that reacted, so callers can tell "someone reacted" from "I
|
|
416
|
+
// reacted". Never cached — reaction state is exactly what todo commands mutate.
|
|
417
|
+
export async function reactionsGet(
|
|
418
|
+
token: string,
|
|
419
|
+
channel: string,
|
|
420
|
+
ts: string,
|
|
421
|
+
cookie?: string,
|
|
422
|
+
): Promise<{ name: string; users: string[] }[]> {
|
|
423
|
+
const resp = (await get(token, "reactions.get", { channel, timestamp: ts, full: "true" }, cookie)) as {
|
|
424
|
+
message?: { reactions?: Array<{ name?: string; users?: string[] }> };
|
|
425
|
+
};
|
|
426
|
+
return (resp.message?.reactions ?? []).map((r) => ({
|
|
427
|
+
name: String(r.name ?? ""),
|
|
428
|
+
users: Array.isArray(r.users) ? r.users.map(String) : [],
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
|
|
392
432
|
export async function reactionRemove(
|
|
393
433
|
token: string,
|
|
394
434
|
channel: string,
|
package/ts/tail.ts
CHANGED
|
@@ -62,6 +62,7 @@ async function formatTailLine(
|
|
|
62
62
|
m: Record<string, Json>,
|
|
63
63
|
cache: Map<string, string>,
|
|
64
64
|
chLabel?: string,
|
|
65
|
+
cookie?: string,
|
|
65
66
|
): Promise<string> {
|
|
66
67
|
const rawTs = typeof m.ts === "string" ? m.ts : "0.000000";
|
|
67
68
|
const stamp = slackTsToIso(rawTs);
|
|
@@ -70,7 +71,7 @@ async function formatTailLine(
|
|
|
70
71
|
const uid = m.user;
|
|
71
72
|
const handleKey = "@" + uid;
|
|
72
73
|
if (!cache.has(handleKey)) {
|
|
73
|
-
const [, h] = await userInfoPair(token, uid);
|
|
74
|
+
const [, h] = await userInfoPair(token, uid, cookie);
|
|
74
75
|
cache.set(handleKey, h);
|
|
75
76
|
}
|
|
76
77
|
handle = cache.get(handleKey) ?? uid;
|
|
@@ -78,7 +79,7 @@ async function formatTailLine(
|
|
|
78
79
|
handle = m.username;
|
|
79
80
|
}
|
|
80
81
|
const raw = typeof m.text === "string" ? m.text : "";
|
|
81
|
-
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
|
|
82
|
+
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache, cookie));
|
|
82
83
|
const lines = resolved.split("\n");
|
|
83
84
|
const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((l) => ` ${l}`).join("\n") : "");
|
|
84
85
|
// Annotate thread replies so a merged channel+thread stream stays readable.
|
|
@@ -95,6 +96,7 @@ type PollOpts = {
|
|
|
95
96
|
watchThread?: string;
|
|
96
97
|
me?: boolean;
|
|
97
98
|
myUserId?: string;
|
|
99
|
+
cookie?: string;
|
|
98
100
|
};
|
|
99
101
|
|
|
100
102
|
export async function pollCycle(
|
|
@@ -112,6 +114,7 @@ export async function pollCycle(
|
|
|
112
114
|
20,
|
|
113
115
|
pageCursor ? undefined : (cursor || undefined),
|
|
114
116
|
pageCursor,
|
|
117
|
+
opts.cookie,
|
|
115
118
|
)) as Json);
|
|
116
119
|
// history returns newest-first; reverse to emit oldest-first
|
|
117
120
|
const histMsgs = asArray(histResp.messages).map(asRecord).reverse();
|
|
@@ -128,7 +131,7 @@ export async function pollCycle(
|
|
|
128
131
|
// history — drop it to avoid double-emit.
|
|
129
132
|
let replyMsgs: Record<string, Json>[] = [];
|
|
130
133
|
if (opts.watchThread && !pageCursor) {
|
|
131
|
-
const repResp = asRecord((await replies(token, channelId, opts.watchThread, 50)) as Json);
|
|
134
|
+
const repResp = asRecord((await replies(token, channelId, opts.watchThread, 50, opts.cookie)) as Json);
|
|
132
135
|
replyMsgs = asArray(repResp.messages).map(asRecord)
|
|
133
136
|
.filter((m) => (typeof m.ts === "string" ? m.ts : "") !== opts.watchThread);
|
|
134
137
|
}
|
|
@@ -176,7 +179,7 @@ export async function pollCycle(
|
|
|
176
179
|
if (oldest !== undefined) seen.delete(oldest);
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
lines.push(await formatTailLine(token, m, cache));
|
|
182
|
+
lines.push(await formatTailLine(token, m, cache, undefined, opts.cookie));
|
|
180
183
|
// Advance the history cursor only on top-level messages — thread replies
|
|
181
184
|
// are never returned by history(), so their ts must not move `oldest`.
|
|
182
185
|
if (fromHistory) newCursor = ts;
|
|
@@ -244,7 +247,7 @@ export async function cmdTail(
|
|
|
244
247
|
|
|
245
248
|
// Preflight: verify channel access before entering the poll loop
|
|
246
249
|
try {
|
|
247
|
-
const info = asRecord(await conversationInfo(token, channelId) as Json);
|
|
250
|
+
const info = asRecord(await conversationInfo(token, channelId, opts.cookie) as Json);
|
|
248
251
|
const ch = asRecord(info.channel as Json);
|
|
249
252
|
if (ch.is_archived === true) {
|
|
250
253
|
console.error(`Warning: this channel is archived — no new messages will arrive.`);
|
|
@@ -273,7 +276,7 @@ export async function cmdTail(
|
|
|
273
276
|
cursor = (_internals.now() / 1000 - secondsBack).toFixed(6);
|
|
274
277
|
} else {
|
|
275
278
|
// Seed with most recent message so we only emit new messages going forward
|
|
276
|
-
const histResp = asRecord((await history(token, channelId, 1)) as Json);
|
|
279
|
+
const histResp = asRecord((await history(token, channelId, 1, undefined, undefined, opts.cookie)) as Json);
|
|
277
280
|
const msgs = asArray(histResp.messages).map(asRecord);
|
|
278
281
|
if (msgs.length > 0 && typeof msgs[0]?.ts === "string") {
|
|
279
282
|
cursor = msgs[0].ts;
|
|
@@ -285,7 +288,7 @@ export async function cmdTail(
|
|
|
285
288
|
|
|
286
289
|
let myUserId: string | undefined;
|
|
287
290
|
if (opts.me || opts.exitOnMessage) {
|
|
288
|
-
const info = await authTest(token);
|
|
291
|
+
const info = await authTest(token, opts.cookie);
|
|
289
292
|
myUserId = info.userId || undefined;
|
|
290
293
|
}
|
|
291
294
|
|
|
@@ -304,6 +307,7 @@ export async function cmdTail(
|
|
|
304
307
|
...(watchThreadTs !== undefined ? { watchThread: watchThreadTs } : {}),
|
|
305
308
|
...(opts.me !== undefined ? { me: opts.me } : {}),
|
|
306
309
|
...(myUserId !== undefined ? { myUserId } : {}),
|
|
310
|
+
...(opts.cookie !== undefined ? { cookie: opts.cookie } : {}),
|
|
307
311
|
};
|
|
308
312
|
|
|
309
313
|
// RTM path: xoxc token + cookie + no backfill requested. Skipped when a
|