slack-term 1.44.0 → 1.46.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 +14 -7
- package/SKILL.md +28 -10
- package/dist/cli.js +201 -199
- package/package.json +1 -1
- package/ts/ask.ts +79 -24
- package/ts/botIntent.ts +1 -0
- package/ts/cli.ts +280 -37
- package/ts/slack.ts +32 -0
- package/ts/slackTodos.ts +4 -3
package/ts/cli.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { agentCommands } from "./agent.ts";
|
|
|
20
20
|
import {
|
|
21
21
|
ASK_KEYCAPS,
|
|
22
22
|
ASK_MARKER,
|
|
23
|
+
ASK_OTHER_MARKER,
|
|
24
|
+
ASK_OTHER_MARKERS,
|
|
23
25
|
ASK_EXPLAIN_MARKER,
|
|
24
26
|
ASK_RESOLVED_MARKER,
|
|
25
27
|
ASK_MAX_REACTION_CHOICES,
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
askIsExplainNote,
|
|
28
30
|
askBuildResolvedText,
|
|
29
31
|
askParseMessage,
|
|
32
|
+
askSeedMarker,
|
|
30
33
|
askExplainReject,
|
|
31
34
|
askFlatten,
|
|
32
35
|
askMatchChoice,
|
|
@@ -114,7 +117,10 @@ import {
|
|
|
114
117
|
userName,
|
|
115
118
|
getPath,
|
|
116
119
|
type Json,
|
|
120
|
+
savedAdd,
|
|
121
|
+
savedDelete,
|
|
117
122
|
savedList,
|
|
123
|
+
savedUpdate,
|
|
118
124
|
} from "./slack.ts";
|
|
119
125
|
import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
|
|
120
126
|
import {
|
|
@@ -880,8 +886,13 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
|
|
|
880
886
|
}
|
|
881
887
|
|
|
882
888
|
// --- todos (Slack's To-dos tab) ---
|
|
883
|
-
// Read-only.
|
|
884
|
-
//
|
|
889
|
+
// Read-only. Reads the whole view, sorts it by due date, then shows the first
|
|
890
|
+
// `limit` with where each lives and the message it points at. saved.list does
|
|
891
|
+
// not return items in due order, so cutting before sorting could hide an
|
|
892
|
+
// overdue to-do behind a later one; the read is capped at TODOS_MAX_PAGES.
|
|
893
|
+
const TODOS_PAGE = 50;
|
|
894
|
+
const TODOS_MAX_PAGES = 20;
|
|
895
|
+
|
|
885
896
|
async function cmdTodos(
|
|
886
897
|
token: string,
|
|
887
898
|
cookie: string | undefined,
|
|
@@ -889,12 +900,14 @@ async function cmdTodos(
|
|
|
889
900
|
): Promise<void> {
|
|
890
901
|
const items: TodoItem[] = [];
|
|
891
902
|
let cursor: string | undefined;
|
|
903
|
+
let pages = 0;
|
|
892
904
|
do {
|
|
893
|
-
const resp = asRecord((await savedList(token, { filter: opts.filter, limit:
|
|
905
|
+
const resp = asRecord((await savedList(token, { filter: opts.filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
|
|
894
906
|
items.push(...parseSavedItems(resp as Json));
|
|
895
907
|
const next = asRecord(resp.response_metadata).next_cursor;
|
|
896
908
|
cursor = typeof next === "string" && next ? next : undefined;
|
|
897
|
-
} while (cursor &&
|
|
909
|
+
} while (cursor && ++pages < TODOS_MAX_PAGES);
|
|
910
|
+
if (cursor) console.error(`Note: read the first ${items.length} items only (${TODOS_MAX_PAGES} pages); the order covers those.`);
|
|
898
911
|
const shown = sortTodos(items).slice(0, opts.limit);
|
|
899
912
|
|
|
900
913
|
if (shown.length === 0) {
|
|
@@ -938,6 +951,139 @@ async function cmdTodos(
|
|
|
938
951
|
}
|
|
939
952
|
}
|
|
940
953
|
|
|
954
|
+
// --- todos add / done / edit / rm — gated writes to your To-dos ---
|
|
955
|
+
// Every To-do points at a message, so the target is a message (permalink or
|
|
956
|
+
// #chan:<ts>). Same two-step as send: the first run previews and prints a code,
|
|
957
|
+
// the rerun with --code=<code> writes. Your own To-dos only — the session token
|
|
958
|
+
// can touch no one else's.
|
|
959
|
+
type TodoOp =
|
|
960
|
+
| { kind: "add"; due?: number; saved?: boolean }
|
|
961
|
+
| { kind: "done"; undo: boolean }
|
|
962
|
+
| { kind: "edit"; due?: number; todo?: boolean }
|
|
963
|
+
| { kind: "rm" };
|
|
964
|
+
|
|
965
|
+
// The saved item for one message (saved.delete answers ok even when there is
|
|
966
|
+
// nothing to delete, so writes check first). Views are searched in the order
|
|
967
|
+
// the op most likely needs, stopping at the first match, and the whole search
|
|
968
|
+
// is capped at TODOS_MAX_PAGES pages so a long saved history can't turn one
|
|
969
|
+
// command into a burst of saved.list calls. "unknown" = cap hit, not found.
|
|
970
|
+
function findOrder(op: TodoOp): TodoFilter[] {
|
|
971
|
+
if (op.kind === "done") return op.undo ? ["completed", "todo", "saved", "archived"] : ["todo", "saved", "completed", "archived"];
|
|
972
|
+
return ["todo", "saved", "completed", "archived"];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function findSavedItem(
|
|
976
|
+
token: string,
|
|
977
|
+
cookie: string | undefined,
|
|
978
|
+
channel: string,
|
|
979
|
+
ts: string,
|
|
980
|
+
order: TodoFilter[],
|
|
981
|
+
): Promise<TodoItem | "unknown" | undefined> {
|
|
982
|
+
let pages = 0;
|
|
983
|
+
for (const filter of order) {
|
|
984
|
+
let cursor: string | undefined;
|
|
985
|
+
do {
|
|
986
|
+
if (pages++ >= TODOS_MAX_PAGES) return "unknown";
|
|
987
|
+
const resp = asRecord((await savedList(token, { filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
|
|
988
|
+
const hit = parseSavedItems(resp as Json).find((t) => t.channel === channel && t.ts === ts);
|
|
989
|
+
if (hit) return hit;
|
|
990
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
991
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
992
|
+
} while (cursor);
|
|
993
|
+
}
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function todoOpLines(op: TodoOp, fmtDue: (sec: number) => string): string[] {
|
|
998
|
+
const due = (d: number | undefined) => (d === undefined ? [] : [` Due: ${d ? fmtDue(d) : "(clear the due date)"}`]);
|
|
999
|
+
switch (op.kind) {
|
|
1000
|
+
case "add": return [` Action: add as ${op.saved ? "a saved item (no checkbox)" : "a to-do"}`, ...due(op.due)];
|
|
1001
|
+
case "done": return [` Action: mark ${op.undo ? "NOT done (reopen)" : "done"}`];
|
|
1002
|
+
case "edit": return [
|
|
1003
|
+
" Action: edit",
|
|
1004
|
+
...(op.todo === undefined ? [] : [` Kind: ${op.todo ? "to-do" : "saved item (no checkbox)"}`]),
|
|
1005
|
+
...due(op.due),
|
|
1006
|
+
];
|
|
1007
|
+
case "rm": return [" Action: REMOVE from your To-dos (the message itself is untouched)"];
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function cmdTodosWrite(token: string, cookie: string | undefined, target: string, op: TodoOp, code?: string): Promise<void> {
|
|
1012
|
+
const { ref, ts } = splitRefTs(target);
|
|
1013
|
+
if (!ts) {
|
|
1014
|
+
console.error("Error: a To-do points at a message — give its permalink or #chan:<ts> (see `slack todos ls`).");
|
|
1015
|
+
process.exit(2);
|
|
1016
|
+
}
|
|
1017
|
+
if (op.kind === "edit" && op.due === undefined && op.todo === undefined) {
|
|
1018
|
+
console.error("Error: nothing to edit — pass --due <time|none>, --todo or --saved.");
|
|
1019
|
+
process.exit(2);
|
|
1020
|
+
}
|
|
1021
|
+
const channel = await resolveChannel(token, ref, cookie);
|
|
1022
|
+
const found = await findSavedItem(token, cookie, channel, ts, findOrder(op));
|
|
1023
|
+
if (found === "unknown" && op.kind !== "add") {
|
|
1024
|
+
console.error(`Error: not found in the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — stopped there to stay under Slack's rate limits. Nothing was changed.`);
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
}
|
|
1027
|
+
const existing = found === "unknown" ? undefined : found;
|
|
1028
|
+
if (op.kind === "add" && existing) {
|
|
1029
|
+
console.error(`Error: that message is already in your To-dos (${isDone(existing) ? "done" : existing.todoState || "saved"}) — use \`slack todos edit\` or \`done\`.`);
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
if (op.kind !== "add" && !existing) {
|
|
1033
|
+
console.error("Error: that message is not in your To-dos — nothing to change. `slack todos ls --filter saved` lists everything saved.");
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
let teamUrl = "";
|
|
1038
|
+
let me = "";
|
|
1039
|
+
try { ({ url: teamUrl, userId: me } = await authScopes(token, cookie)); } catch { /* preview degrades */ }
|
|
1040
|
+
const label = await (await buildChLabelResolver(token, cookie, me))(channel);
|
|
1041
|
+
let msg: Record<string, Json> | undefined;
|
|
1042
|
+
try {
|
|
1043
|
+
const r = asRecord((await replies(token, channel, ts, 1, cookie)) as Json);
|
|
1044
|
+
msg = asArray(r.messages).map(asRecord).find((m) => m.ts === ts);
|
|
1045
|
+
} catch { /* not readable */ }
|
|
1046
|
+
const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
|
|
1047
|
+
const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
|
|
1048
|
+
const head = maskSecrets(raw.length > 100 ? `${raw.slice(0, 100)}…` : raw);
|
|
1049
|
+
const now = Date.now() / 1000;
|
|
1050
|
+
|
|
1051
|
+
const expected = safetyCode("todos", JSON.stringify(op), channel, ts, me);
|
|
1052
|
+
if (code !== expected) {
|
|
1053
|
+
requireCode(code, expected, [
|
|
1054
|
+
"--- To-do ------------------------------------",
|
|
1055
|
+
` For: ${me ? `@${await userName(token, me, cookie)} (your own To-dos)` : "(your own To-dos)"}`,
|
|
1056
|
+
` Where: ${stripTerminalControls(label)}`,
|
|
1057
|
+
` Msg: ${stripTerminalControls(author + head)}`,
|
|
1058
|
+
...(teamUrl ? [` Link: ${messagePermalink(teamUrl, channel, ts)}`] : []),
|
|
1059
|
+
...(existing ? [` Now: ${todoStatus(existing, now, formatYmdHm)}`] : []),
|
|
1060
|
+
...(found === "unknown" ? [` Note: not checked beyond the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — it may already be saved`] : []),
|
|
1061
|
+
...todoOpLines(op, fmtPostAt),
|
|
1062
|
+
"----------------------------------------------",
|
|
1063
|
+
]);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const at = { channel, ts };
|
|
1067
|
+
switch (op.kind) {
|
|
1068
|
+
case "add":
|
|
1069
|
+
await savedAdd(token, at, { todo: !op.saved, ...(op.due ? { due: op.due } : {}) }, cookie);
|
|
1070
|
+
console.log(`✓ Added to your To-dos${op.due ? ` (due ${formatYmdHm(op.due)})` : ""}`);
|
|
1071
|
+
break;
|
|
1072
|
+
case "done":
|
|
1073
|
+
await savedUpdate(token, at, { mark: op.undo ? "uncompleted" : "completed" }, cookie);
|
|
1074
|
+
console.log(op.undo ? "✓ Reopened" : "✓ Marked done");
|
|
1075
|
+
break;
|
|
1076
|
+
case "edit":
|
|
1077
|
+
await savedUpdate(token, at, { ...(op.todo === undefined ? {} : { todo: op.todo }), ...(op.due === undefined ? {} : { due: op.due }) }, cookie);
|
|
1078
|
+
console.log("✓ Updated");
|
|
1079
|
+
break;
|
|
1080
|
+
case "rm":
|
|
1081
|
+
await savedDelete(token, at, cookie);
|
|
1082
|
+
console.log("✓ Removed from your To-dos");
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
941
1087
|
// --- drafts get ---
|
|
942
1088
|
async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
|
|
943
1089
|
const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
@@ -2612,6 +2758,9 @@ interface AskWaitCtx {
|
|
|
2612
2758
|
* nothing". */
|
|
2613
2759
|
overflow: string[];
|
|
2614
2760
|
threadOnly: boolean;
|
|
2761
|
+
/** The marker reaction this question was seeded with (➕, or ❓ on a
|
|
2762
|
+
* free-text question or one posted before ➕) — what the ✅ swap takes off. */
|
|
2763
|
+
seedMarker: string;
|
|
2615
2764
|
/** The language the question's copy is in — the ✅ stamp and the
|
|
2616
2765
|
* invalid-ballot line are written in the same one. */
|
|
2617
2766
|
lang: AskLang;
|
|
@@ -2869,17 +3018,21 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
|
|
|
2869
3018
|
}
|
|
2870
3019
|
}
|
|
2871
3020
|
|
|
2872
|
-
/** Who has pressed
|
|
2873
|
-
*
|
|
2874
|
-
*
|
|
2875
|
-
*
|
|
3021
|
+
/** Who has pressed ➕ ("other") — or ❓, the other pill on questions posted
|
|
3022
|
+
* before ➕, which is still pressed out of habit. Not an answer — neither
|
|
3023
|
+
* carries text — so it never resolves anything; the reply that should
|
|
3024
|
+
* follow it does. Reported once per person so the operator knows a reply is
|
|
3025
|
+
* on its way rather than reading the silence as "nobody looked". */
|
|
2876
3026
|
const otherReported = new Set<string>();
|
|
2877
3027
|
function noteOther(msg: Record<string, Json>): void {
|
|
2878
|
-
const r
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
3028
|
+
for (const r of asArray(msg.reactions).map(asRecord)) {
|
|
3029
|
+
if (typeof r.name !== "string" || !ASK_OTHER_MARKERS.includes(r.name)) continue;
|
|
3030
|
+
const glyph = r.name === ASK_OTHER_MARKER ? "➕" : "❓";
|
|
3031
|
+
for (const u of asArray(r.users).filter(isAnswerer)) {
|
|
3032
|
+
if (otherReported.has(u)) continue;
|
|
3033
|
+
otherReported.add(u);
|
|
3034
|
+
if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
|
|
3035
|
+
}
|
|
2883
3036
|
}
|
|
2884
3037
|
}
|
|
2885
3038
|
|
|
@@ -3158,12 +3311,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
|
|
|
3158
3311
|
const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
|
|
3159
3312
|
await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
|
|
3160
3313
|
// Swap the marker for the resolved one so search reflects reality:
|
|
3161
|
-
// `has::question:` should list what still needs
|
|
3162
|
-
// ever asked. Removal last — a crash between the two leaves the question
|
|
3314
|
+
// `has::question: OR has::heavy_plus_sign:` should list what still needs
|
|
3315
|
+
// answering, not everything ever asked. Removal last — a crash between the two leaves the question
|
|
3163
3316
|
// findable under BOTH, which is recoverable; the reverse loses it.
|
|
3164
3317
|
try {
|
|
3165
3318
|
await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
|
|
3166
|
-
await reactionRemove(token, channelId, ts,
|
|
3319
|
+
await reactionRemove(token, channelId, ts, ctx.seedMarker, cookie);
|
|
3167
3320
|
} catch {
|
|
3168
3321
|
// Cosmetic: the ✅ in the body is what actually marks it resolved.
|
|
3169
3322
|
}
|
|
@@ -3513,10 +3666,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3513
3666
|
const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
|
|
3514
3667
|
|
|
3515
3668
|
// The marker goes on LAST, after 1,2,3, because it doubles as the "other"
|
|
3516
|
-
// choice the body lists under the numbered ones (
|
|
3517
|
-
//
|
|
3518
|
-
// `has::question:` lists every question the
|
|
3519
|
-
//
|
|
3669
|
+
// choice the body lists under the numbered ones: ➕ (ASK_OTHER_MARKER). A
|
|
3670
|
+
// free-text question has no other choice and seeds ❓. It is a reaction so
|
|
3671
|
+
// `has::question: OR has::heavy_plus_sign:` lists every open question the
|
|
3672
|
+
// way `has::pushpin:` lists every todo — though ➕ is a common emoji, so that
|
|
3673
|
+
// search can also match unrelated messages (`slack ask --ls` reads the local
|
|
3674
|
+
// sent-log and is unaffected). Body text cannot do that job: Slack's index splits on punctuation, so
|
|
3520
3675
|
// a `:question:` written in the text is indexed as the word "question" and
|
|
3521
3676
|
// matches unrelated messages (measured). Only a real reaction is searchable.
|
|
3522
3677
|
//
|
|
@@ -3527,14 +3682,15 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3527
3682
|
// enough: measured on two real questions from the same build, one came back
|
|
3528
3683
|
// `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
|
|
3529
3684
|
// SLACK_REACTION_SEED_GAP_MS.
|
|
3685
|
+
const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
|
|
3530
3686
|
await seedReactionsInOrder(
|
|
3531
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
3687
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), seedMarker, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
|
|
3532
3688
|
(name) => reactionAdd(token, channelId, ts, name, cookie),
|
|
3533
3689
|
(name, e) => {
|
|
3534
3690
|
const why = e instanceof Error ? e.message : String(e);
|
|
3535
3691
|
// The marker is cosmetic + searchability; a keycap only costs the
|
|
3536
3692
|
// answerer a trip to the emoji picker. Neither abandons a posted question.
|
|
3537
|
-
if (name ===
|
|
3693
|
+
if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
|
|
3538
3694
|
else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
|
|
3539
3695
|
},
|
|
3540
3696
|
);
|
|
@@ -3575,6 +3731,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
|
|
|
3575
3731
|
reactable,
|
|
3576
3732
|
overflow,
|
|
3577
3733
|
threadOnly,
|
|
3734
|
+
seedMarker,
|
|
3578
3735
|
lang,
|
|
3579
3736
|
audience,
|
|
3580
3737
|
broadcast,
|
|
@@ -3667,7 +3824,7 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
|
|
|
3667
3824
|
if (parsed.kind === "void") return "void";
|
|
3668
3825
|
if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
|
|
3669
3826
|
if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
|
|
3670
|
-
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
|
|
3827
|
+
const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
|
|
3671
3828
|
if (got.threadTs) out.threadTs = got.threadTs;
|
|
3672
3829
|
return out;
|
|
3673
3830
|
}
|
|
@@ -3695,7 +3852,7 @@ async function askHasResponses(token: string, t: { channelId: string; ts: string
|
|
|
3695
3852
|
}
|
|
3696
3853
|
|
|
3697
3854
|
/** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
|
|
3698
|
-
* open question THROUGH the ask builder, so pills,
|
|
3855
|
+
* open question THROUGH the ask builder, so pills, ➕, the instructions and the
|
|
3699
3856
|
* body stay in the layout collect reads back (a free-hand `slack edit` is what
|
|
3700
3857
|
* this replaces; `edit` now refuses ask messages without --force).
|
|
3701
3858
|
*
|
|
@@ -3778,17 +3935,32 @@ async function cmdAskEdit(token: string, args: {
|
|
|
3778
3935
|
}
|
|
3779
3936
|
|
|
3780
3937
|
await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
|
|
3938
|
+
// The body was rebuilt, so its other line now says ➕ (or there is none, on
|
|
3939
|
+
// a free-text question) — the marker seed follows it.
|
|
3940
|
+
const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
|
|
3941
|
+
const oldSeed = askSeedMarker(oldText) ?? ASK_MARKER;
|
|
3781
3942
|
if (choicesChanged) {
|
|
3782
|
-
// Re-seed in order: 1..n then
|
|
3783
|
-
|
|
3784
|
-
|
|
3943
|
+
// Re-seed in order: 1..n then ➕ last, as a fresh question has them. The
|
|
3944
|
+
// old marker comes off as whatever it was seeded as (❓ on an old pill).
|
|
3945
|
+
const tail = (m: string) => old.explain ? [m, ASK_EXPLAIN_MARKER] : [m];
|
|
3946
|
+
for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(oldSeed)]) {
|
|
3785
3947
|
try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
|
|
3786
3948
|
}
|
|
3787
3949
|
await seedReactionsInOrder(
|
|
3788
|
-
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
|
|
3950
|
+
[...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
|
|
3789
3951
|
(name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
|
|
3790
3952
|
(name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
|
|
3791
3953
|
);
|
|
3954
|
+
} else if (oldSeed !== seedMarker) {
|
|
3955
|
+
// An old ❓ pill edited without touching its choices: the body now lists
|
|
3956
|
+
// ➕, so swap the seed to match. Add before remove, as the ✅ swap does —
|
|
3957
|
+
// a failure in between leaves both, which still collects.
|
|
3958
|
+
try {
|
|
3959
|
+
await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
|
|
3960
|
+
await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
|
|
3961
|
+
} catch (e: unknown) {
|
|
3962
|
+
console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
|
|
3963
|
+
}
|
|
3792
3964
|
}
|
|
3793
3965
|
console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
|
|
3794
3966
|
}
|
|
@@ -4031,9 +4203,12 @@ interface AskVoidTarget {
|
|
|
4031
4203
|
channelId: string;
|
|
4032
4204
|
ts: string;
|
|
4033
4205
|
parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
|
|
4206
|
+
/** The marker reaction it was seeded with (➕, or ❓ on an old pill / free
|
|
4207
|
+
* text) — what the void takes off. */
|
|
4208
|
+
seedMarker: string;
|
|
4034
4209
|
}
|
|
4035
4210
|
|
|
4036
|
-
/** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
|
|
4211
|
+
/** Void one question: the 🚫 text (body kept), our seeds and marker (➕, or ❓) off, 🚫 on.
|
|
4037
4212
|
* The text is what marks it void; the reactions are cleanup + searchability,
|
|
4038
4213
|
* so a failure there is reported and does not undo the void. */
|
|
4039
4214
|
async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
|
|
@@ -4045,7 +4220,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4045
4220
|
console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
|
|
4046
4221
|
return false;
|
|
4047
4222
|
}
|
|
4048
|
-
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name),
|
|
4223
|
+
for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
|
|
4049
4224
|
try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
|
|
4050
4225
|
}
|
|
4051
4226
|
try {
|
|
@@ -4068,7 +4243,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
|
|
|
4068
4243
|
*
|
|
4069
4244
|
* What voiding does, per question: the text becomes the 🚫 form (head, void
|
|
4070
4245
|
* stamp with the reason, optional pointer to the replacement, the body); our
|
|
4071
|
-
* seeded pills and the ❓
|
|
4246
|
+
* seeded pills and the marker (➕, or ❓) come off; a 🚫 reaction goes on. Pills someone
|
|
4072
4247
|
* already pressed stay — Slack lets only their owner remove them — and are
|
|
4073
4248
|
* never collected, because a void is never polled again.
|
|
4074
4249
|
*
|
|
@@ -4101,7 +4276,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4101
4276
|
process.exit(ASK_EXIT_ERROR);
|
|
4102
4277
|
}
|
|
4103
4278
|
}
|
|
4104
|
-
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
|
|
4279
|
+
type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; seedMarker: string; pressed: string[]; unreadable?: boolean };
|
|
4105
4280
|
const targets: Target[] = [];
|
|
4106
4281
|
let refused = 0;
|
|
4107
4282
|
for (const link of args.links) {
|
|
@@ -4144,11 +4319,16 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4144
4319
|
};
|
|
4145
4320
|
unreadable = true;
|
|
4146
4321
|
}
|
|
4322
|
+
// Our marker: what the body names, or — for an unreadable body — ➕ if we
|
|
4323
|
+
// seeded one, else ❓ (an old pill, or free text).
|
|
4324
|
+
const seedMarker = unreadable
|
|
4325
|
+
? (asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_OTHER_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")) ? ASK_OTHER_MARKER : ASK_MARKER)
|
|
4326
|
+
: askSeedMarker(text) ?? ASK_MARKER;
|
|
4147
4327
|
const pressed = asArray(msg.reactions).map(asRecord)
|
|
4148
4328
|
.filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
|
|
4149
4329
|
.filter((r) => asArray(r.users).some((u) => u !== self?.userId))
|
|
4150
4330
|
.map((r) => `:${String(r.name)}:`);
|
|
4151
|
-
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
4331
|
+
targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
|
|
4152
4332
|
}
|
|
4153
4333
|
if (!targets.length) {
|
|
4154
4334
|
console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
|
|
@@ -4166,7 +4346,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
|
|
|
4166
4346
|
...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
|
|
4167
4347
|
(t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
|
|
4168
4348
|
(t.unreadable ? `\n ⚠ its layout no longer reads as an ask (edited by hand?) — voided from a best-effort read: question and the rest of the text kept as they are` : "")),
|
|
4169
|
-
` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
|
|
4349
|
+
` Each becomes 🚫 (body kept, options and instructions removed); our pills and marker (➕, or ❓) come off; a 🚫 reaction is added.`,
|
|
4170
4350
|
...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
|
|
4171
4351
|
`----------------------------------------------`,
|
|
4172
4352
|
]);
|
|
@@ -4350,6 +4530,7 @@ function askWaitCtxFrom(
|
|
|
4350
4530
|
reactable: parsed.reactable,
|
|
4351
4531
|
overflow: parsed.overflow,
|
|
4352
4532
|
threadOnly: parsed.threadOnly,
|
|
4533
|
+
seedMarker: askSeedMarker(q.text) ?? ASK_MARKER,
|
|
4353
4534
|
lang: parsed.lang,
|
|
4354
4535
|
audience: q.audience,
|
|
4355
4536
|
broadcast: q.broadcast,
|
|
@@ -4714,6 +4895,16 @@ async function botDmForTarget(
|
|
|
4714
4895
|
return openDm(botToken, userId);
|
|
4715
4896
|
}
|
|
4716
4897
|
|
|
4898
|
+
/** A `--due` value as epoch seconds; exits 2 on an unparseable one. */
|
|
4899
|
+
function todoDue(v: string): number {
|
|
4900
|
+
try {
|
|
4901
|
+
return parsePostAt(v);
|
|
4902
|
+
} catch {
|
|
4903
|
+
console.error(`Error: cannot parse --due "${v}" — use an ISO datetime (2026-11-01T18:00) or a Unix ts.`);
|
|
4904
|
+
process.exit(2);
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4717
4908
|
function parsePostAt(at: string): number {
|
|
4718
4909
|
if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
|
|
4719
4910
|
const d = new Date(at.replace(" ", "T"));
|
|
@@ -5919,7 +6110,7 @@ async function main(): Promise<void> {
|
|
|
5919
6110
|
.option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
|
|
5920
6111
|
.option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
|
|
5921
6112
|
.option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
|
|
5922
|
-
.option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after
|
|
6113
|
+
.option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ➕): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
|
|
5923
6114
|
.option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
|
|
5924
6115
|
.option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
|
|
5925
6116
|
.option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
|
|
@@ -7109,7 +7300,7 @@ async function main(): Promise<void> {
|
|
|
7109
7300
|
)
|
|
7110
7301
|
.command(
|
|
7111
7302
|
"todos",
|
|
7112
|
-
"Slack's To-dos tab — your personal to-dos (
|
|
7303
|
+
"Slack's To-dos tab — your personal to-dos (requires xoxc desktop token). Not `slack todo` (reaction tasks)",
|
|
7113
7304
|
(y) => y.command(
|
|
7114
7305
|
["$0", "ls", "list"],
|
|
7115
7306
|
"List your To-dos, soonest due first",
|
|
@@ -7124,7 +7315,59 @@ async function main(): Promise<void> {
|
|
|
7124
7315
|
}
|
|
7125
7316
|
await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
|
|
7126
7317
|
},
|
|
7127
|
-
)
|
|
7318
|
+
)
|
|
7319
|
+
.command(
|
|
7320
|
+
"add <target>",
|
|
7321
|
+
"Add a message to your To-dos (two-step: preview, then --code)",
|
|
7322
|
+
(y2) => y2
|
|
7323
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7324
|
+
.option("due", { type: "string", describe: "Due time (ISO datetime or Unix ts)" })
|
|
7325
|
+
.option("saved", { type: "boolean", default: false, describe: "Save it without a checkbox instead of as a to-do" })
|
|
7326
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7327
|
+
async (argv) => {
|
|
7328
|
+
const due = argv.due === undefined ? undefined : todoDue(argv.due);
|
|
7329
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "add", ...(due ? { due } : {}), saved: argv.saved }, argv.code);
|
|
7330
|
+
},
|
|
7331
|
+
)
|
|
7332
|
+
.command(
|
|
7333
|
+
["done <target>", "complete <target>"],
|
|
7334
|
+
"Check a to-do off (--undo reopens it)",
|
|
7335
|
+
(y2) => y2
|
|
7336
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7337
|
+
.option("undo", { type: "boolean", default: false, describe: "Mark it not done again" })
|
|
7338
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7339
|
+
async (argv) => {
|
|
7340
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "done", undo: argv.undo }, argv.code);
|
|
7341
|
+
},
|
|
7342
|
+
)
|
|
7343
|
+
.command(
|
|
7344
|
+
"edit <target>",
|
|
7345
|
+
"Change a to-do's due date, or switch it between to-do and saved item",
|
|
7346
|
+
(y2) => y2
|
|
7347
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7348
|
+
.option("due", { type: "string", describe: "New due time (ISO datetime or Unix ts), or `none` to clear it" })
|
|
7349
|
+
.option("todo", { type: "boolean", describe: "Make it a to-do (with a checkbox)" })
|
|
7350
|
+
.option("saved", { type: "boolean", describe: "Make it a plain saved item (no checkbox)" })
|
|
7351
|
+
.conflicts("todo", "saved")
|
|
7352
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7353
|
+
async (argv) => {
|
|
7354
|
+
const due = argv.due === undefined ? undefined : argv.due === "none" ? 0 : todoDue(argv.due);
|
|
7355
|
+
const todo = argv.todo ? true : argv.saved ? false : undefined;
|
|
7356
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, {
|
|
7357
|
+
kind: "edit", ...(due === undefined ? {} : { due }), ...(todo === undefined ? {} : { todo }),
|
|
7358
|
+
}, argv.code);
|
|
7359
|
+
},
|
|
7360
|
+
)
|
|
7361
|
+
.command(
|
|
7362
|
+
["rm <target>", "remove <target>", "delete <target>"],
|
|
7363
|
+
"Remove a message from your To-dos (the message stays)",
|
|
7364
|
+
(y2) => y2
|
|
7365
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7366
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7367
|
+
async (argv) => {
|
|
7368
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "rm" }, argv.code);
|
|
7369
|
+
},
|
|
7370
|
+
),
|
|
7128
7371
|
)
|
|
7129
7372
|
.command(
|
|
7130
7373
|
"drafts",
|
package/ts/slack.ts
CHANGED
|
@@ -1119,6 +1119,38 @@ export async function savedList(
|
|
|
1119
1119
|
return postSession(token, "saved.list", params, cookie);
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
+
// To-dos writes (internal, session token). Verified live 2026-10-26: add with
|
|
1123
|
+
// todo_state=to_do (+ date_due) creates a to-do; update mark=completed checks it
|
|
1124
|
+
// off (todo_state becomes "completed"); delete removes it. A To-do always
|
|
1125
|
+
// points at a message: item_type=message, item_id=channel, ts=message ts.
|
|
1126
|
+
export type SavedRef = { channel: string; ts: string };
|
|
1127
|
+
const savedIds = (r: SavedRef) => ({ item_type: "message", item_id: r.channel, ts: r.ts });
|
|
1128
|
+
|
|
1129
|
+
export async function savedAdd(token: string, ref: SavedRef, opts: { todo?: boolean; due?: number } = {}, cookie?: string): Promise<Json> {
|
|
1130
|
+
const params: Record<string, string> = { ...savedIds(ref), todo_state: opts.todo === false ? "saved" : "to_do" };
|
|
1131
|
+
if (opts.due) params.date_due = String(opts.due);
|
|
1132
|
+
return postSession(token, "saved.add", params, cookie);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** `mark`: completed | uncompleted | archived | unarchived. `due`: epoch seconds, 0 clears it. */
|
|
1136
|
+
export async function savedUpdate(
|
|
1137
|
+
token: string,
|
|
1138
|
+
ref: SavedRef,
|
|
1139
|
+
opts: { mark?: "completed" | "uncompleted" | "archived" | "unarchived"; todo?: boolean; due?: number },
|
|
1140
|
+
cookie?: string,
|
|
1141
|
+
): Promise<Json> {
|
|
1142
|
+
const params: Record<string, string> = savedIds(ref);
|
|
1143
|
+
if (opts.mark) params.mark = opts.mark;
|
|
1144
|
+
if (opts.todo !== undefined) params.todo_state = opts.todo ? "to_do" : "saved";
|
|
1145
|
+
if (opts.due !== undefined) params.date_due = String(opts.due);
|
|
1146
|
+
return postSession(token, "saved.update", params, cookie);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Slack answers ok even when nothing was saved — callers check first. */
|
|
1150
|
+
export async function savedDelete(token: string, ref: SavedRef, cookie?: string): Promise<Json> {
|
|
1151
|
+
return postSession(token, "saved.delete", savedIds(ref), cookie);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1122
1154
|
// Draft API (internal — requires xoxc session token + xoxd cookie)
|
|
1123
1155
|
export async function listDrafts(token: string, cookie?: string): Promise<Json> {
|
|
1124
1156
|
return postSession(token, "drafts.list", {}, cookie);
|
package/ts/slackTodos.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Slack's To-dos tab (app.slack.com/client/<team>/to-dos) —
|
|
1
|
+
// Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — parsing helpers.
|
|
2
2
|
//
|
|
3
3
|
// Not to be confused with `slack todo` (todo.ts), which tracks tasks as
|
|
4
4
|
// reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
|
|
@@ -26,7 +26,7 @@ export type TodoItem = {
|
|
|
26
26
|
due: number;
|
|
27
27
|
/** Epoch seconds; 0 = not completed. */
|
|
28
28
|
completed: number;
|
|
29
|
-
/** "to_do" | "saved" (as Slack reports it). */
|
|
29
|
+
/** "to_do" | "saved" | "completed" (as Slack reports it). */
|
|
30
30
|
todoState: string;
|
|
31
31
|
state: string;
|
|
32
32
|
archived: boolean;
|
|
@@ -68,7 +68,8 @@ export function sortTodos(items: TodoItem[]): TodoItem[] {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function isDone(t: TodoItem): boolean {
|
|
71
|
-
|
|
71
|
+
// Checking one off sets state AND todo_state to "completed" (seen live).
|
|
72
|
+
return t.completed > 0 || t.state === "completed" || t.todoState === "completed";
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** The status column: a checkbox, plus the due date (and OVERDUE when past it). */
|