slack-term 1.27.1 → 1.28.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/poll.ts ADDED
@@ -0,0 +1,244 @@
1
+ // The `poll` message format — builder and parser in one place, for the same
2
+ // reason `ask` keeps its own pair together (see ts/ask.ts).
3
+ //
4
+ // `poll` keeps no local record either: `--results` and `--close` recover the
5
+ // question and its choices by reading the posted message back, so this body is
6
+ // a wire format and `pollParseMessage` must be the exact inverse of
7
+ // `pollBuildText`. tests/poll-format.test.ts is what holds the two in step.
8
+ //
9
+ // The instruction strings below are DELIBERATELY different from every `ask`
10
+ // instruction. That difference is the whole separation between the two
11
+ // commands: `askParseMessage` identifies an ask by its trailing instruction
12
+ // line, so a poll sharing one would be collectable by `slack ask --waitFor`,
13
+ // which would edit a live poll into a resolved question.
14
+
15
+ import { ASK_KEYCAPS, applyInvalidNotice, isInvalidNotice, readInvalidNotice } from "./ask.ts";
16
+ export { applyInvalidNotice, readInvalidNotice };
17
+
18
+ /** Ballot pills. Shared with `ask` on purpose — the keycap emoji run out at ten
19
+ * either way, and a poll has no text-answer path to overflow into: a choice
20
+ * nobody can react to is a choice nobody can vote for. Ten is a hard cap here,
21
+ * not a soft one. */
22
+ export const POLL_KEYCAPS = ASK_KEYCAPS;
23
+ export const POLL_MAX_CHOICES = POLL_KEYCAPS.length;
24
+
25
+ /** The marker that says "this message is a `poll`" — a shortcode, identical in
26
+ * every language, and seeded as a reaction so `has::ballot_box_with_ballot:`
27
+ * lists them all. The name is `ballot_box_with_ballot` (🗳️), NOT `ballot_box`:
28
+ * the short form is not a Slack emoji at all and `reactions.add` rejects it
29
+ * with `invalid_name` — verified against a live workspace 2026-08-24.
30
+ * See ASK_MARKER in ts/ask.ts for why identity moved off the instruction line. */
31
+ export const POLL_MARKER = "ballot_box_with_ballot";
32
+ export const POLL_MARKER_PREFIX = `:${POLL_MARKER}: `;
33
+
34
+ // Ordinary copy now — translate freely. Matched verbatim only to read polls
35
+ // posted before the marker existed; deleting them strands those.
36
+ const POLL_INSTRUCTION_SINGLE =
37
+ "_下のリアクションで投票してください (1 人 1 票、押し直しで変更できます)。_";
38
+ const POLL_INSTRUCTION_MULTI =
39
+ "_下のリアクションで投票してください (当てはまるものを *いくつでも* 押せます)。_";
40
+ const POLL_DEADLINE_PREFIX = "_締切: ";
41
+ const POLL_DEADLINE_SUFFIX = "_";
42
+
43
+ /** Prefix `close` stamps on a finished poll. `--results` keys "this one is
44
+ * already closed, report the recorded tally rather than re-counting" off it. */
45
+ export const POLL_CLOSED_MARKER = "bar_chart";
46
+ export const POLL_CLOSED_PREFIX = `:${POLL_CLOSED_MARKER}: `;
47
+
48
+ /** How a keycap is WRITTEN into the body. The shortcode, not the glyph: Slack
49
+ * normalises a unicode emoji to `:one:` in the stored `text`, so building with
50
+ * the glyph would mean the message never reads back as what was sent.
51
+ * Verified against a real workspace 2026-08-20. */
52
+ function pollPill(i: number): string {
53
+ return `:${POLL_KEYCAPS[i]!.name}:`;
54
+ }
55
+
56
+ /** Strip the keycap prefix, or null if this line does not start with pill `i`.
57
+ * Both forms are accepted — the shortcode is what Slack stores today, the
58
+ * glyph is what a body hand-edited in the Slack UI can come back as. */
59
+ function pollStripPill(line: string, i: number): string | null {
60
+ for (const pre of [pollPill(i), POLL_KEYCAPS[i]!.glyph]) {
61
+ if (line.startsWith(`${pre} `)) return line.slice(pre.length + 1);
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** True if the line starts with ANY keycap — used only to find where the ballot
67
+ * block begins; which pill it is gets checked afterwards, in order. */
68
+ function pollIsPillLine(line: string): boolean {
69
+ return POLL_KEYCAPS.some((k, i) => line.startsWith(`${pollPill(i)} `) || line.startsWith(`${k.glyph} `));
70
+ }
71
+
72
+ /** One line, always: a newline inside a choice would split it into a line the
73
+ * parser cannot tell from body text. */
74
+ function pollFlatten(s: string): string {
75
+ return s.replace(/\s*\n\s*/g, " ");
76
+ }
77
+
78
+ export type PollTally = { choice: string; count: number };
79
+
80
+ /** Open-poll body: the question, the numbered choices matching the seeded
81
+ * pills, an optional deadline, and an instruction naming the ballot rule.
82
+ * `pollParseMessage` is its inverse — keep the two in step. */
83
+ export type PollOpts = {
84
+ multi?: boolean;
85
+ /** Display only — a deadline in the body does not close anything. */
86
+ deadline?: string;
87
+ /** User IDs whose ballot could not be counted (single mode, several pills). */
88
+ invalid?: string[];
89
+ };
90
+
91
+ export function pollBuildText(question: string, body: string, choices: string[], opts: PollOpts = {}): string {
92
+ const multi = !!opts.multi;
93
+ const deadline = opts.deadline ?? "";
94
+ const invalid = opts.invalid ?? [];
95
+ if (!choices.length || choices.length > POLL_MAX_CHOICES) {
96
+ throw new Error(`poll needs 1..${POLL_MAX_CHOICES} choices, got ${choices.length}`);
97
+ }
98
+ const lines: string[] = [`${POLL_MARKER_PREFIX}*${question}*`];
99
+ if (body) lines.push("", body);
100
+ lines.push("");
101
+ lines.push(...choices.map((s, i) => `${pollPill(i)} ${pollFlatten(s)}`));
102
+ lines.push("");
103
+ // The deadline sits between the choices and the instruction so the
104
+ // instruction stays the last line — that trailing line is what identifies a
105
+ // poll at all, and an optional line after it would break the anchor.
106
+ if (deadline) lines.push(`${POLL_DEADLINE_PREFIX}${pollFlatten(deadline)}${POLL_DEADLINE_SUFFIX}`);
107
+ lines.push(multi ? POLL_INSTRUCTION_MULTI : POLL_INSTRUCTION_SINGLE);
108
+ // Never in multi mode: there is no such thing as too many pills there.
109
+ return applyInvalidNotice(lines.join("\n"), multi ? [] : invalid);
110
+ }
111
+
112
+ /** Closed-poll body. The count goes BEFORE the label: a trailing `×3` could not
113
+ * be told apart from a choice whose text happens to end that way, and the
114
+ * tally is the one thing a closed poll exists to report. */
115
+ export function pollBuildClosedText(question: string, tallies: PollTally[]): string {
116
+ const total = tallies.reduce((n, t) => n + t.count, 0);
117
+ const lines: string[] = [`${POLL_CLOSED_PREFIX}*${question}*`, `_投票終了 (計 ${total} 票)_`, ""];
118
+ lines.push(...tallies.map((t, i) => `${pollPill(i)} ×${t.count} ${pollFlatten(t.choice)}`));
119
+ return lines.join("\n");
120
+ }
121
+
122
+ export type PollParsed =
123
+ | { kind: "open"; question: string; choices: string[]; multi: boolean; deadline: string; invalid: string[] }
124
+ | { kind: "closed"; question: string; tallies: PollTally[] }
125
+ | { kind: "other" };
126
+
127
+ /** Read a poll back out of Slack. "Is this even a poll?" is decided by the
128
+ * trailing instruction line (or the 📊 prefix), not by a loose heuristic —
129
+ * matching verbatim is what keeps an unrelated message, or an `ask`, from
130
+ * being counted as one. */
131
+ export function pollParseMessage(text: string): PollParsed {
132
+ const lines = text.split("\n");
133
+
134
+ if (text.startsWith(POLL_CLOSED_PREFIX)) {
135
+ const question = lines[0]!.slice(POLL_CLOSED_PREFIX.length).replace(/^\*/, "").replace(/\*$/, "");
136
+ const tallies: PollTally[] = [];
137
+ for (let k = 0; k < POLL_KEYCAPS.length; k++) {
138
+ const line = lines.map((l) => pollStripPill(l, k)).find((r) => r !== null && r.startsWith("×"));
139
+ if (line === undefined) break; // choices are contiguous from 1️⃣; the first gap ends the block
140
+ const m = /^×(\d+) (.*)$/.exec(line!);
141
+ if (!m) return { kind: "other" };
142
+ tallies.push({ choice: m[2]!, count: Number(m[1]) });
143
+ }
144
+ // A 📊-stamped body with no tally is not a result we can report.
145
+ if (!tallies.length) return { kind: "other" };
146
+ return { kind: "closed", question, tallies };
147
+ }
148
+
149
+ const last = lines[lines.length - 1] ?? "";
150
+ let multi: boolean;
151
+ if (last === POLL_INSTRUCTION_SINGLE) multi = false;
152
+ else if (last === POLL_INSTRUCTION_MULTI) multi = true;
153
+ else return { kind: "other" };
154
+
155
+ // The question is the leading bold run, taken up to the first line that closes
156
+ // the `*` so a multi-line question survives instead of being truncated.
157
+ // Strip the marker first, so a pre-marker body still parses (see ts/ask.ts).
158
+ if (lines[0]!.startsWith(POLL_MARKER_PREFIX)) lines[0] = lines[0]!.slice(POLL_MARKER_PREFIX.length);
159
+ if (!lines[0]!.startsWith("*")) return { kind: "other" };
160
+ let end = 0;
161
+ while (end < lines.length && !(lines[end]!.endsWith("*") && (end > 0 || lines[0]!.length > 1))) end++;
162
+ if (end >= lines.length) return { kind: "other" };
163
+ const question = lines.slice(0, end + 1).join("\n").replace(/^\*/, "").replace(/\*$/, "");
164
+
165
+ // Walk UP from the instruction rather than down from the question: the ballot
166
+ // is anchored to the bottom, and scanning downward would let a body line that
167
+ // merely starts with a keycap steal a slot and shift every number after it.
168
+ let i = lines.length - 2;
169
+ const invalid = readInvalidNotice(text);
170
+ if (isInvalidNotice(lines[i])) i--;
171
+ let deadline = "";
172
+ const dl = lines[i];
173
+ if (dl !== undefined && dl.startsWith(POLL_DEADLINE_PREFIX) && dl.endsWith(POLL_DEADLINE_SUFFIX) && dl.length > POLL_DEADLINE_PREFIX.length + POLL_DEADLINE_SUFFIX.length) {
174
+ deadline = dl.slice(POLL_DEADLINE_PREFIX.length, -POLL_DEADLINE_SUFFIX.length);
175
+ i--;
176
+ }
177
+ if (lines[i] !== "") return { kind: "other" };
178
+ i--;
179
+
180
+ const raw: string[] = [];
181
+ while (i >= 0) {
182
+ const line = lines[i]!;
183
+ if (!pollIsPillLine(line)) break;
184
+ raw.unshift(line);
185
+ i--;
186
+ }
187
+ // The block has to be keycaps 1..n IN ORDER, separated from the body by a
188
+ // blank line. Order is not cosmetic: each pill's position IS the ballot the
189
+ // counter reads, so a block starting at 2️⃣ would tally the wrong choice.
190
+ if (!raw.length || raw.length > POLL_MAX_CHOICES) return { kind: "other" };
191
+ const choices: string[] = [];
192
+ for (let k = 0; k < raw.length; k++) {
193
+ const rest = pollStripPill(raw[k]!, k);
194
+ if (rest === null) return { kind: "other" };
195
+ choices.push(rest);
196
+ }
197
+ if (lines[i] !== "") return { kind: "other" };
198
+ if (i < end + 1) return { kind: "other" };
199
+
200
+ return { kind: "open", question, choices, multi, deadline, invalid };
201
+ }
202
+
203
+ export type PollCount = {
204
+ tallies: PollTally[];
205
+ /** Who voted for each choice, index-aligned with `tallies`. */
206
+ voters: string[][];
207
+ /** Single-mode voters holding more than one pill, counted for nothing.
208
+ * Slack records no timestamp on a reaction — not in `reactions.get`, not
209
+ * anywhere — so there is no "latest vote" to fall back on. Guessing one
210
+ * would be inventing a ballot; these are reported instead. */
211
+ ambiguous: string[];
212
+ };
213
+
214
+ /** Count a ballot. The message author is excluded by the caller and always:
215
+ * the pills are SEEDED by whoever posted, so their reaction sits on every
216
+ * choice and cannot be told apart from a vote. Someone who wants their own
217
+ * preference counted posts the poll `--as-bot` and votes as themselves — or
218
+ * just says it in the thread. */
219
+ export function pollTally(
220
+ choices: string[],
221
+ reactions: { name: string; users: string[] }[],
222
+ exclude: Set<string>,
223
+ multi: boolean,
224
+ ): PollCount {
225
+ const byName = new Map(reactions.map((r) => [r.name, r.users]));
226
+ const picked = choices.map((_, i) =>
227
+ (byName.get(POLL_KEYCAPS[i]!.name) ?? []).filter((u) => !exclude.has(u)),
228
+ );
229
+
230
+ const ambiguous: string[] = [];
231
+ if (!multi) {
232
+ const seen = new Map<string, number>();
233
+ for (const users of picked) for (const u of users) seen.set(u, (seen.get(u) ?? 0) + 1);
234
+ for (const [u, n] of seen) if (n > 1) ambiguous.push(u);
235
+ }
236
+ const drop = new Set(ambiguous);
237
+
238
+ const voters = picked.map((users) => users.filter((u) => !drop.has(u)));
239
+ return {
240
+ tallies: choices.map((choice, i) => ({ choice, count: voters[i]!.length })),
241
+ voters,
242
+ ambiguous,
243
+ };
244
+ }
package/ts/slack.ts CHANGED
@@ -310,12 +310,15 @@ export async function send(
310
310
  threadTs?: string,
311
311
  replyBroadcast?: boolean,
312
312
  cookie?: string,
313
+ // Post the text as-is, with no `blocks`. Slack REWRITES the stored `text`
314
+ // when blocks are attached — newlines collapse to spaces and emoji become
315
+ // `:one:` — so a message whose body has to be read back verbatim (`ask`,
316
+ // `poll`) must not carry them. Verified against a real workspace 2026-08-20.
317
+ plain?: boolean,
313
318
  ): Promise<string> {
314
- const body: Record<string, Json> = {
315
- channel,
316
- text,
317
- blocks: [{ type: "markdown", text }],
318
- };
319
+ const body: Record<string, Json> = plain
320
+ ? { channel, text }
321
+ : { channel, text, blocks: [{ type: "markdown", text }] };
319
322
  if (threadTs !== undefined) body.thread_ts = threadTs;
320
323
  // "Also send to channel": broadcast a threaded reply back to the channel.
321
324
  // Only meaningful alongside thread_ts; Slack ignores it on top-level sends.
@@ -384,13 +387,13 @@ export async function editMessage(
384
387
  ts: string,
385
388
  text: string,
386
389
  cookie?: string,
390
+ /** As in `send`: no blocks, so the stored text survives verbatim. */
391
+ plain?: boolean,
387
392
  ): Promise<string> {
388
- const resp = (await post(token, "chat.update", {
389
- channel,
390
- ts,
391
- text,
392
- blocks: [{ type: "markdown", text }],
393
- }, cookie)) as { ts?: string };
393
+ const resp = (await post(token, "chat.update", plain
394
+ ? { channel, ts, text }
395
+ : { channel, ts, text, blocks: [{ type: "markdown", text }] },
396
+ cookie)) as { ts?: string };
394
397
  return resp.ts ?? ts;
395
398
  }
396
399
 
@@ -567,15 +570,27 @@ function parseSlackUrl(s: string): string | undefined {
567
570
  return parseSlackPermalink(s)?.channel;
568
571
  }
569
572
 
573
+ /** A Slack conversation ID: `C…` (channel), `D…` (DM), `G…` (group). Length is
574
+ * not fixed across workspaces, hence the range rather than an exact count. */
575
+ function isChannelId(s: string): boolean {
576
+ return /^[CDG][A-Z0-9]{8,}$/.test(s);
577
+ }
578
+
570
579
  export async function resolveChannel(token: string, ref: string, cookie?: string): Promise<string> {
571
580
  // Accept Slack permalinks directly
572
581
  const fromUrl = parseSlackUrl(ref);
573
582
  if (fromUrl) return fromUrl;
574
583
  // Accept raw IDs (C..., D..., G...) as-is
575
584
  if (!ref.startsWith("@") && !ref.startsWith("#")) {
576
- if (/^[A-Za-z0-9]{9,}$/.test(ref)) return ref;
585
+ if (isChannelId(ref)) return ref;
577
586
  throw new Error(`Target must start with # or @ (or be a Slack URL/ID), got: ${ref}`);
578
587
  }
588
+ // `#C0123456789` — an ID that was given the `#` every other target form takes.
589
+ // Slack has no channel NAMED that, so the name lookup below would walk every
590
+ // conversation and end in "channel not found", pointing at the wrong problem:
591
+ // the channel exists, only the `#` does not belong. Accept it instead of
592
+ // sending people to look for a channel that was never missing.
593
+ if (ref.startsWith("#") && isChannelId(ref.slice(1))) return ref.slice(1);
579
594
  const isIm = ref.startsWith("@");
580
595
  const rawName = ref.slice(1);
581
596
  const nameNorm = normName(rawName);
package/ts/todo.ts CHANGED
@@ -12,10 +12,18 @@
12
12
  //
13
13
  // reason flags (any number, independent of progress)
14
14
  // alert :exclamation:
15
- // needs-discussion :question:
15
+ // needs-discussion :thinking_face:
16
16
  // waiting :speech_balloon: ball is with the other party IN this thread
17
17
  // blocked :lock: stuck OUTSIDE this conversation
18
18
  //
19
+ // needs-discussion moved off :question: (2026-08-24) so `ask` can claim it as
20
+ // its marker. `has:` matches one emoji exactly, so sharing it would have made
21
+ // every `ask` question show up in `todo ls --state stuck` and vice versa.
22
+ // The cost is real and accepted: :question: reactions already sitting on old
23
+ // messages no longer register as needs-discussion — reactions cannot be
24
+ // migrated retroactively. Anyone relying on the old emoji can put it back via
25
+ // ~/.config/slack-cli/todo.json, which overrides every name here.
26
+ //
19
27
  // waiting vs blocked: "whose ball is it" has only three answers, and `pending`
20
28
  // already means "mine". So the remaining two are a flag each — waiting = I've
21
29
  // replied and am waiting on someone in this conversation; blocked = something
@@ -76,7 +84,7 @@ export const DEFAULT_TODO_CONFIG: TodoConfig = {
76
84
  },
77
85
  flags: {
78
86
  alert: "exclamation",
79
- "needs-discussion": "question",
87
+ "needs-discussion": "thinking_face",
80
88
  waiting: "speech_balloon",
81
89
  blocked: "lock",
82
90
  },