slack-term 1.27.0 → 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/dist/cli.js +130 -113
- package/package.json +2 -2
- package/ts/ask.ts +78 -7
- package/ts/cli.ts +574 -22
- package/ts/poll.ts +244 -0
- package/ts/slack.ts +27 -12
- package/ts/todo.ts +10 -2
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
|
-
|
|
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 (
|
|
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 :
|
|
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": "
|
|
87
|
+
"needs-discussion": "thinking_face",
|
|
80
88
|
waiting: "speech_balloon",
|
|
81
89
|
blocked: "lock",
|
|
82
90
|
},
|