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/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 (`@柏原大空`), never a bare surname fragment (`@柏原`), which would be
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. "柏原大空さん"). A user resolves only when the token equals their full
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.matsuda19790127`) — matched whole against
108
+ * - ASCII handles (`@alice`, `@t.yamada19850101`) — matched whole against
109
109
  * name / real_name / display_name / email.
110
- * - CJK names (`@柏原大空`, `@柏原大空さん`) — matched against the directory by
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.matsuda19790127`, `@Alice-A`. It never ends on a separator,
133
- // so a trailing sentence dot ("@taku.") is left out. Capturing the FULL token —
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 松田. Never blocks a send.
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 "ください松田" still reports "松田", not the whole run).
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