omo-slim-plan 0.1.0 → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - Interactive Telegram first-run setup: only a bot token is required; `chat_id` is captured after you message the bot (`getUpdates`), shown for confirmation, then saved
6
+ - New CLI flag: `npx omo-slim-plan --setup-telegram`
7
+ - `--telegram-token` without `--telegram-chat-id` now auto-enters interactive setup after install
8
+ - Event `test` bypasses webhook event filters so the setup test notification can always verify config
9
+ - New helper module `plugin/planflow-telegram.mjs` (getMe / getUpdates / confirm / save / test send)
10
+
11
+ ## 0.1.0
12
+
13
+ - Initial public release: plan-first workflow for OpenCode + oh-my-opencode-slim
14
+ - Commands: `/plan`, `/start-work`, `/plan-review`
15
+ - Skill `plan-workflow` (`.plans/` contract, human gates, checkbox progress)
16
+ - Plugin `planflow` + notify CLI + extensible providers (`telegram` / `generic` / `command`)
17
+ - npx installer (zero runtime deps)
package/README.md CHANGED
@@ -52,36 +52,36 @@ What the installer does:
52
52
 
53
53
  `OPencode_CONFIG` overrides the config root. `--config <path>` sets it too (directory, or a `planflow.json` path). Existing targets are backed up to `*.bak-<timestamp>` before overwrite. After install: **restart OpenCode**.
54
54
 
55
- ## Configure Telegram
55
+ ## Configure Telegram (first run)
56
56
 
57
- 1. Talk to [@BotFather](https://t.me/BotFather) on Telegram → `/newbot` → copy the **bot token**.
58
- 2. Add the bot to your target chat, then get the **chat id** (e.g. via `https://api.telegram.org/bot<TOKEN>/getUpdates`, or send a message and inspect the response).
59
- 3. Fill in `~/.config/opencode/planflow.json`:
57
+ You only need a **bot token** first. `chat_id` is captured interactively — no manual lookup.
60
58
 
61
- ```json
62
- {
63
- "version": 1,
64
- "plansDir": ".plans",
65
- "webhook": {
66
- "provider": "telegram",
67
- "telegram": {
68
- "botToken": "123456:ABC-your-token",
69
- "chatId": "-1001234567890"
70
- },
71
- "generic": { "url": "", "headers": {} },
72
- "command": { "cmd": "" },
73
- "events": {
74
- "plan-ready": true,
75
- "awaiting-review": true,
76
- "task-done": false,
77
- "awaiting-acceptance": true
78
- },
79
- "titlePrefix": "[omo-slim-plan]"
80
- }
81
- }
59
+ 1. Talk to [@BotFather](https://t.me/BotFather) → `/newbot` → copy the **bot token**.
60
+ 2. Install / run setup (chat_id optional on the command line):
61
+
62
+ ```bash
63
+ npx omo-slim-plan --telegram-token "123456:ABC-your-token"
64
+ # re-run anytime
65
+ npx omo-slim-plan --setup-telegram
82
66
  ```
83
67
 
84
- Or pass flags at install time:
68
+ 3. The installer calls Telegram `getMe`, prints your bot username, and asks you to **message the bot** (any text, e.g. `/start`).
69
+ 4. It polls `getUpdates` and shows the captured message:
70
+
71
+ ```text
72
+ 捕获到消息:
73
+ chat_id : 123456789
74
+ chat_type : private
75
+ from : lofibass (@lofibass)
76
+ text : hi
77
+ ```
78
+
79
+ 5. Confirm `[Y/n]` → `chat_id` is saved to `~/.config/opencode/planflow.json` (`provider=telegram`).
80
+ 6. A test message (`Telegram 配置成功`) is sent to verify the path.
81
+
82
+ If provider is `telegram` but `chatId` is empty, notifications report `telegram_not_configured` until setup completes — re-run `--setup-telegram`.
83
+
84
+ Manual config still works: set `webhook.telegram.botToken` + `chatId` in `planflow.json`, or pass both flags:
85
85
 
86
86
  ```bash
87
87
  node bin/install.js --telegram-token "123456:ABC..." --telegram-chat-id "-1001234567890"
@@ -95,6 +95,7 @@ node bin/install.js --telegram-token "123456:ABC..." --telegram-chat-id "-100123
95
95
  | `awaiting-review` | on | `/plan-review` starts (oracle review) |
96
96
  | `task-done` | off | each todo completed during `/start-work` |
97
97
  | `awaiting-acceptance` | on | all Todos + Final checks done |
98
+ | `test` | n/a | setup test notification (always allowed) |
98
99
 
99
100
  Set any event to `false` to silence it. **A missing/unconfigured provider never blocks the workflow** — notify failures are logged and work continues.
100
101
 
package/bin/install.js CHANGED
@@ -4,7 +4,11 @@
4
4
  // Usage:
5
5
  // node bin/install.js [--dry-run] [--force] [--uninstall]
6
6
  // [--webhook <url>] [--telegram-token <t>] [--telegram-chat-id <id>]
7
- // [--config <path>]
7
+ // [--setup-telegram] [--config <path>]
8
+ //
9
+ // Telegram first-run: pass --telegram-token (or --setup-telegram). After files
10
+ // are installed, the installer can capture chat_id interactively (message the
11
+ // bot → getUpdates → confirm) so users never need to look up chat_id manually.
8
12
  //
9
13
  // Defaults:
10
14
  // Config root : $OPencode_CONFIG || ~/.config/opencode
@@ -25,12 +29,17 @@ import {
25
29
  } from "node:fs";
26
30
  import os from "node:os";
27
31
  import path from "node:path";
28
- import { fileURLToPath } from "node:url";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
33
 
30
34
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
31
35
 
32
36
  const COMMAND_FILES = ["plan.md", "start-work.md", "plan-review.md"];
33
- const PLUGIN_FILES = ["planflow.js", "planflow-notify.mjs", "planflow-providers.mjs"];
37
+ const PLUGIN_FILES = [
38
+ "planflow.js",
39
+ "planflow-notify.mjs",
40
+ "planflow-providers.mjs",
41
+ "planflow-telegram.mjs",
42
+ ];
34
43
  const DEFAULT_PLANFLOW = {
35
44
  version: 1,
36
45
  plansDir: ".plans",
@@ -77,6 +86,7 @@ function parseArgs(argv) {
77
86
  webhook: "",
78
87
  telegramToken: "",
79
88
  telegramChatId: "",
89
+ setupTelegram: false,
80
90
  config: "",
81
91
  help: false,
82
92
  };
@@ -110,6 +120,10 @@ function parseArgs(argv) {
110
120
  args.uninstall = true;
111
121
  continue;
112
122
  }
123
+ if (a === "--setup-telegram") {
124
+ args.setupTelegram = true;
125
+ continue;
126
+ }
113
127
  if (a.startsWith("--") && a.includes("=")) {
114
128
  const eq = a.indexOf("=");
115
129
  const key = a.slice(0, eq);
@@ -148,7 +162,8 @@ function usage() {
148
162
  " --uninstall Remove installed files; unregister plugin; keep planflow.json unless --force",
149
163
  " --webhook <url> generic webhook provider (sets provider=generic, generic.url=<url>)",
150
164
  " --telegram-token <t> Telegram bot token (sets provider=telegram)",
151
- " --telegram-chat-id <id> Telegram chat id",
165
+ " --telegram-chat-id <id> Telegram chat id (optional; omit to capture interactively)",
166
+ " --setup-telegram Interactive Telegram setup: message the bot, confirm chat_id",
152
167
  " --config <path> OpenCode config root (dir) or planflow.json path",
153
168
  " -h, --help Show help",
154
169
  "",
@@ -339,12 +354,20 @@ function removeIfExists(target, dryRun, roots, label) {
339
354
  return { removed: true };
340
355
  }
341
356
 
342
- function printNextSteps(p) {
357
+ function readPlanflowJsonSafe(planflowPath) {
358
+ try {
359
+ return JSON.parse(readFileSync(planflowPath, "utf8"));
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ function printNextSteps(p, setupState) {
343
366
  log("");
344
367
  log(bold("Next steps:"));
345
368
  log(" 1. Restart OpenCode so it loads the planflow plugin.");
346
369
  log(` 2. Configure Telegram (or generic/command) in ${p.planflowPath}`);
347
- log(' - telegram: fill webhook.telegram.botToken + webhook.telegram.chatId');
370
+ log(" - telegram: npx omo-slim-plan --setup-telegram (message the bot; chat_id is captured)");
348
371
  log(" - generic: set webhook.provider=generic + webhook.generic.url");
349
372
  log(" - command: set webhook.provider=command + webhook.command.cmd");
350
373
  log(" 3. In a project, run /plan — AI writes .plans/<slug>.md, then you choose:");
@@ -352,12 +375,68 @@ function printNextSteps(p) {
352
375
  log("");
353
376
  log(` Commands installed: ${p.commandDir}/plan.md, start-work.md, plan-review.md`);
354
377
  log(` Skill installed: ${p.skillDest}`);
355
- log(` Plugin installed: ${p.pluginDir}/planflow.js (+ planflow-notify.mjs, planflow-providers.mjs)`);
378
+ log(` Plugin installed: ${p.pluginDir}/planflow.js (+ planflow-notify.mjs, planflow-providers.mjs, planflow-telegram.mjs)`);
356
379
  log(` Notify CLI: ${p.pluginDir}/planflow-notify.mjs`);
357
380
  log(` Plans convention: .plans/ per project (see templates/plans/README.md; commit plans, not boulder)`);
381
+ if (setupState) {
382
+ if (setupState.saved) {
383
+ log("");
384
+ log(green(` Telegram chat_id saved: ${setupState.chatId}`));
385
+ } else if (setupState.needed) {
386
+ log("");
387
+ warn(" Telegram chat_id is empty — run setup to capture it:");
388
+ log(" npx omo-slim-plan --setup-telegram");
389
+ log(" # or: node bin/install.js --setup-telegram");
390
+ }
391
+ }
358
392
  }
359
393
 
360
- function runInstall(args, p) {
394
+ /**
395
+ * Decide whether interactive Telegram setup should run after install.
396
+ * Returns { needed, reason }.
397
+ */
398
+ function evaluateTelegramSetup(args, cfg) {
399
+ if (args.uninstall) return { needed: false, reason: "skipped" };
400
+ if (args.setupTelegram) return { needed: true, reason: "flag" };
401
+ if (args.telegramChatId) return { needed: false, reason: "chat_id_already_set" };
402
+ const tg = cfg?.webhook?.telegram || {};
403
+ const provider = cfg?.webhook?.provider || "telegram";
404
+ const hasToken = Boolean(String(args.telegramToken || tg.botToken || "").trim());
405
+ const hasChat = Boolean(String(tg.chatId || "").trim());
406
+ if (provider === "telegram" && hasToken && !hasChat) {
407
+ return { needed: true, reason: "token_without_chat_id" };
408
+ }
409
+ return { needed: false, reason: hasChat ? "chat_id_already_set" : "telegram_not_requested" };
410
+ }
411
+
412
+ function shouldRunTelegramSetup(args, p, cfg) {
413
+ if (args.dryRun) return { needed: false, reason: "dry_run" };
414
+ return evaluateTelegramSetup(args, cfg);
415
+ }
416
+
417
+ async function runInteractiveTelegramSetup(args, p, plan) {
418
+ const { runTelegramSetup } = await import(
419
+ pathToFileURL(path.join(PKG_ROOT, "plugin", "planflow-telegram.mjs")).href
420
+ );
421
+ const cfg = readPlanflowJsonSafe(p.planflowPath) || {};
422
+ const token = String(args.telegramToken || cfg?.webhook?.telegram?.botToken || "").trim();
423
+ log("");
424
+ log(bold("Telegram setup:"));
425
+ if (!process.stdin.isTTY && !token) {
426
+ warn("stdin is not a TTY and no --telegram-token was provided — skipping interactive setup");
427
+ log(" re-run: npx omo-slim-plan --setup-telegram --telegram-token <token>");
428
+ return { needed: true, saved: false, chatId: null, tested: false };
429
+ }
430
+ const result = await runTelegramSetup({
431
+ token,
432
+ planflowPath: p.planflowPath,
433
+ log,
434
+ warn,
435
+ });
436
+ return { needed: true, saved: Boolean(result?.saved), chatId: result?.chatId ?? null, tested: Boolean(result?.tested) };
437
+ }
438
+
439
+ async function runInstall(args, p) {
361
440
  log(bold("omo-slim-plan installer"));
362
441
  log(cyan("Resolving paths..."));
363
442
  log(` config root : ${p.configRoot}`);
@@ -474,7 +553,16 @@ function runInstall(args, p) {
474
553
  }
475
554
 
476
555
  if (args.dryRun) {
556
+ const dryCfg = readPlanflowJsonSafe(p.planflowPath) || buildPlanflowConfig(args, null);
557
+ const setupPreview = evaluateTelegramSetup(args, dryCfg);
477
558
  log("");
559
+ log(cyan("Telegram setup:"));
560
+ if (setupPreview.needed) {
561
+ log(` [dry-run] would run interactive setup (${setupPreview.reason}) — message the bot, confirm chat_id`);
562
+ log(` (interactive prompts require a TTY; not executed in dry-run)`);
563
+ } else {
564
+ log(` skipped (${setupPreview.reason})`);
565
+ }
478
566
  log(green("Dry-run complete — nothing was written."));
479
567
  return 0;
480
568
  }
@@ -483,7 +571,26 @@ function runInstall(args, p) {
483
571
  log(green("Uninstall complete. Restart OpenCode to drop the plugin."));
484
572
  return 0;
485
573
  }
486
- printNextSteps(p);
574
+
575
+ // --- 10: optional interactive Telegram first-run setup ---
576
+ const cfgNow = readPlanflowJsonSafe(p.planflowPath) || {};
577
+ const setupPlan = shouldRunTelegramSetup(args, p, cfgNow);
578
+ let setupState = null;
579
+ if (setupPlan.needed) {
580
+ log(cyan("Telegram setup:"));
581
+ log(` reason: ${setupPlan.reason}`);
582
+ setupState = await runInteractiveTelegramSetup(args, p, setupPlan);
583
+ } else {
584
+ log(cyan("Telegram setup:"));
585
+ log(` skipped (${setupPlan.reason})`);
586
+ const tg = cfgNow?.webhook?.telegram || {};
587
+ const provider = cfgNow?.webhook?.provider || "telegram";
588
+ if (provider === "telegram" && !String(tg.chatId || "").trim()) {
589
+ setupState = { needed: true, saved: false, chatId: null, tested: false };
590
+ }
591
+ }
592
+
593
+ printNextSteps(p, setupState);
487
594
  return 0;
488
595
  }
489
596
 
@@ -546,8 +653,14 @@ function main() {
546
653
  }
547
654
  try {
548
655
  const p = resolvePaths(args);
549
- const code = runInstall(args, p);
550
- process.exit(code);
656
+ runInstall(args, p)
657
+ .then((code) => {
658
+ process.exit(code);
659
+ })
660
+ .catch((e) => {
661
+ err(e?.message || String(e));
662
+ process.exit(1);
663
+ });
551
664
  } catch (e) {
552
665
  err(e?.message || String(e));
553
666
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omo-slim-plan",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Lightweight plan-first workflow for OpenCode + oh-my-opencode-slim: plan → human gate → start-work → acceptance",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,7 @@
13
13
  "type": "module",
14
14
  "license": "MIT",
15
15
  "bin": { "omo-slim-plan": "bin/install.js" },
16
- "files": ["bin", "templates", "plugin", "LICENSE", "README.md"],
16
+ "files": ["bin", "templates", "plugin", "LICENSE", "README.md", "CHANGELOG.md"],
17
17
  "engines": { "node": ">=18" },
18
18
  "keywords": ["opencode", "oh-my-opencode-slim", "plan", "workflow", "omo-slim"]
19
19
  }
@@ -17,7 +17,8 @@ Usage:
17
17
  node planflow-notify.mjs --event <event> --plan <path> [options]
18
18
 
19
19
  Options:
20
- --event <event> plan-ready | awaiting-review | task-done | awaiting-acceptance | custom
20
+ --event <event> plan-ready | awaiting-review | task-done | awaiting-acceptance | test | custom
21
+ (note: --event test bypasses webhook.event filters — used by Telegram first-run setup)
21
22
  --plan <path> plan file path (e.g. .plans/foo.md)
22
23
  --title <text> optional notification title
23
24
  --message <text> optional notification message
@@ -126,6 +127,7 @@ async function main() {
126
127
  return 0;
127
128
  }
128
129
 
130
+ // event "test" (Telegram setup) bypasses webhook.event filters in providers.send
129
131
  const result = await send(cfg, payload);
130
132
  const reason = result?.reason || "";
131
133
  const notConfigured = !result?.ok && /_not_configured$/.test(reason);
@@ -283,7 +283,9 @@ export async function send(config, { event, plan, title, message, remaining, ses
283
283
  const p = payloadFor(cfg, { event, plan, title, message, remaining, session });
284
284
 
285
285
  const events = hook.events || {};
286
- if (events[p.event] === false) {
286
+ // event "test" (Telegram first-run setup) always bypasses webhook.event filters —
287
+ // it must never be skipped so sendTestNotification() can verify the saved config.
288
+ if (p.event !== "test" && events[p.event] === false) {
287
289
  return { ok: true, skipped: true, provider: providerName };
288
290
  }
289
291
  if (!provider) {
@@ -0,0 +1,409 @@
1
+ // omo-slim-plan — Telegram first-run setup helpers (zero deps, Node 18+ builtins only)
2
+ // Used by bin/install.js for interactive chat_id capture. Never logs the full bot token.
3
+ //
4
+ // Exports:
5
+ // getMe(token) / getUpdates(token, offset) / sendMessage(token, chatId, text)
6
+ // waitForMessage(token, { timeoutMs, pollIntervalMs, onStatus })
7
+ // pickLatestMessage(resultArray) — pure, testable
8
+ // maskToken(token) — safe display form
9
+ // promptConfirm(question) / promptLine(question) — readline/promises
10
+ // saveChatIdToConfig(planflowPath, token, chatId)
11
+ // runTelegramSetup({ token, planflowPath, log, warn }) — full interactive flow
12
+
13
+ import { createInterface } from "node:readline/promises";
14
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import path from "node:path";
16
+
17
+ const API_TIMEOUT_MS = 10_000;
18
+ const DEFAULT_WAIT_MS = 120_000;
19
+ const DEFAULT_POLL_MS = 2_000;
20
+
21
+ function apiBase(token) {
22
+ return `https://api.telegram.org/bot${token}`;
23
+ }
24
+
25
+ /** Safe display form — never the full token. */
26
+ export function maskToken(token) {
27
+ const t = String(token || "");
28
+ if (!t) return "(empty)";
29
+ if (t.length <= 8) return "***";
30
+ return `${t.slice(0, 6)}…${t.slice(-2)} (${t.length} chars)`;
31
+ }
32
+
33
+ /** Strip any accidental token leakage from error text. */
34
+ function redact(text, token) {
35
+ if (!text || !token) return text;
36
+ return String(text).split(token).join("<redacted>");
37
+ }
38
+
39
+ function describeTelegramError(json, token) {
40
+ const desc = json?.description || "";
41
+ const code = json?.error_code;
42
+ const d = desc.toLowerCase();
43
+ let hint = "";
44
+ if (d.includes("unauthorized") || d.includes("not found") || d.includes("invalid")) {
45
+ hint = "invalid bot token — re-copy it from @BotFather (/token)";
46
+ } else if (d.includes("bot was blocked")) {
47
+ hint = "bot was blocked by the user — unblock the bot in Telegram";
48
+ } else if (d.includes("chat not found")) {
49
+ hint = "chat not found — message the bot first, then retry";
50
+ } else if (d.includes("bot can't initiate") || d.includes("bot can not initiate")) {
51
+ hint = "bot cannot start the chat — open the bot and send /start";
52
+ }
53
+ const safe = redact(desc, token);
54
+ return `telegram error ${code ?? ""}: ${safe}${hint ? ` (${hint})` : ""}`.replace(/\s+$/, "");
55
+ }
56
+
57
+ /**
58
+ * Pure: pick the latest update entry that carries a chat.
59
+ * Accepts the raw `result` array from getUpdates (message / channel_post / edited_*).
60
+ */
61
+ export function pickLatestMessage(resultArray) {
62
+ if (!Array.isArray(resultArray) || resultArray.length === 0) return null;
63
+ let best = null;
64
+ let bestId = -Infinity;
65
+ for (const item of resultArray) {
66
+ if (!item || typeof item !== "object") continue;
67
+ const candidates = [item.message, item.channel_post, item.edited_message, item.edited_channel_post];
68
+ for (const m of candidates) {
69
+ if (!m || typeof m !== "object" || !m.chat) continue;
70
+ const uid = Number.isFinite(item.update_id) ? item.update_id : 0;
71
+ if (uid >= bestId) {
72
+ bestId = uid;
73
+ best = m;
74
+ }
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ async function telegramGet(token, method, params) {
81
+ const url = new URL(`${apiBase(token)}/${method}`);
82
+ if (params) {
83
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
84
+ }
85
+ const ctrl = new AbortController();
86
+ const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
87
+ try {
88
+ const res = await fetch(url, { method: "GET", signal: ctrl.signal });
89
+ let json = null;
90
+ try {
91
+ json = await res.json();
92
+ } catch {
93
+ /* non-JSON */
94
+ }
95
+ if (!json) return { ok: false, error: `HTTP ${res.status} (non-JSON response)`, token };
96
+ if (json.ok === false) {
97
+ return {
98
+ ok: false,
99
+ error: describeTelegramError(json, token),
100
+ error_code: json.error_code,
101
+ description: redact(json.description, token),
102
+ token,
103
+ };
104
+ }
105
+ return { ok: true, data: json.result, raw: json };
106
+ } catch (e) {
107
+ const aborted = e?.name === "AbortError" || e?.name === "TimeoutError";
108
+ return { ok: false, error: aborted ? "timeout" : e?.message || String(e), token };
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ export async function getMe(token) {
115
+ return telegramGet(token, "getMe");
116
+ }
117
+
118
+ export async function getUpdates(token, offset) {
119
+ const params = { timeout: 0 };
120
+ if (offset !== undefined && offset !== null && offset !== "") {
121
+ params.offset = String(offset);
122
+ }
123
+ return telegramGet(token, "getUpdates", params);
124
+ }
125
+
126
+ export async function sendMessage(token, chatId, text) {
127
+ const ctrl = new AbortController();
128
+ const timer = setTimeout(() => ctrl.abort(), API_TIMEOUT_MS);
129
+ try {
130
+ const res = await fetch(`${apiBase(token)}/sendMessage`, {
131
+ method: "POST",
132
+ headers: { "content-type": "application/json" },
133
+ body: JSON.stringify({ chat_id: chatId, text }),
134
+ signal: ctrl.signal,
135
+ });
136
+ let json = null;
137
+ try {
138
+ json = await res.json();
139
+ } catch {
140
+ /* non-JSON */
141
+ }
142
+ if (json && json.ok === false) return { ok: false, error: describeTelegramError(json, token) };
143
+ if (!json) return { ok: false, error: `HTTP ${res.status}` };
144
+ return { ok: true, data: json.result };
145
+ } catch (e) {
146
+ return {
147
+ ok: false,
148
+ error: e?.name === "AbortError" || e?.name === "TimeoutError" ? "timeout" : e?.message || String(e),
149
+ };
150
+ } finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
154
+
155
+ function sleep(ms) {
156
+ return new Promise((r) => setTimeout(r, ms));
157
+ }
158
+
159
+ /**
160
+ * Poll getUpdates until a message with a chat arrives.
161
+ * onStatus({ elapsedMs, timeoutMs }) is called each poll (installer prints progress).
162
+ * Supports Ctrl+C cleanly via process SIGINT handler set by runTelegramSetup.
163
+ */
164
+ export async function waitForMessage(token, { timeoutMs = DEFAULT_WAIT_MS, pollIntervalMs = DEFAULT_POLL_MS, onStatus } = {}) {
165
+ const started = Date.now();
166
+ let offset; // undefined → read backlog first, then advance past seen updates
167
+ let lastStatusLog = 0;
168
+ const isTTY = Boolean(process.stdout.isTTY);
169
+
170
+ while (true) {
171
+ const elapsed = Date.now() - started;
172
+ const remaining = timeoutMs - elapsed;
173
+ if (remaining <= 0) {
174
+ return { ok: false, reason: "timeout", elapsedMs: elapsed };
175
+ }
176
+
177
+ if (typeof onStatus === "function") {
178
+ try {
179
+ onStatus({ elapsedMs: elapsed, timeoutMs });
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ } else if (isTTY) {
184
+ process.stdout.write(`\r polling getUpdates… ${Math.round(elapsed / 1000)}s / ${Math.round(timeoutMs / 1000)}s `);
185
+ } else if (elapsed - lastStatusLog >= 10_000) {
186
+ lastStatusLog = elapsed;
187
+ process.stdout.write(`polling getUpdates… ${Math.round(elapsed / 1000)}s / ${Math.round(timeoutMs / 1000)}s\n`);
188
+ }
189
+
190
+ const res = await getUpdates(token, offset);
191
+ if (!res.ok) {
192
+ const errText = String(res.error || "");
193
+ const hard = /unauthorized|not found|bot can't|invalid/i.test(errText);
194
+ if (hard) {
195
+ return { ok: false, reason: "api_error", error: errText, elapsedMs: Date.now() - started };
196
+ }
197
+ // transient network error → retry until timeout
198
+ } else if (Array.isArray(res.data) && res.data.length > 0) {
199
+ const msg = pickLatestMessage(res.data);
200
+ const last = res.data[res.data.length - 1];
201
+ if (last && Number.isFinite(last.update_id)) offset = last.update_id + 1;
202
+ if (msg) {
203
+ return { ok: true, message: msg, offset };
204
+ }
205
+ // updates without a chat (e.g. inline queries) — already advanced offset
206
+ }
207
+
208
+ await sleep(Math.max(200, Math.min(pollIntervalMs, remaining)));
209
+ }
210
+ }
211
+
212
+ export function promptConfirm(question) {
213
+ return promptLine(question);
214
+ }
215
+
216
+ export function promptLine(question) {
217
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
218
+ return rl
219
+ .question(question)
220
+ .then((ans) => {
221
+ rl.close();
222
+ return ans;
223
+ })
224
+ .catch((e) => {
225
+ rl.close();
226
+ throw e;
227
+ });
228
+ }
229
+
230
+ function formatCaptured(msg) {
231
+ const chat = msg?.chat || {};
232
+ const from = msg?.from || {};
233
+ const name = from.first_name
234
+ ? [from.first_name, from.last_name].filter(Boolean).join(" ")
235
+ : "";
236
+ const handle = from.username ? ` (@${from.username})` : "";
237
+ const text = msg?.text ?? msg?.caption ?? "";
238
+ return [
239
+ "捕获到消息:",
240
+ ` chat_id : ${chat.id ?? "(none)"}`,
241
+ ` chat_type : ${chat.type ?? "(unknown)"}`,
242
+ ` from : ${name || "(unknown)"}${handle}`,
243
+ ` text : ${text === "" ? "(empty)" : text}`,
244
+ ].join("\n");
245
+ }
246
+
247
+ function readJsonSafe(file) {
248
+ try {
249
+ return JSON.parse(readFileSync(file, "utf8"));
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ /** Persist botToken + chatId into planflow.json (creates shape if missing). */
256
+ export function saveChatIdToConfig(planflowPath, token, chatId) {
257
+ const cfg = readJsonSafe(planflowPath) || {};
258
+ if (!cfg.webhook || typeof cfg.webhook !== "object") cfg.webhook = {};
259
+ if (!cfg.webhook.telegram || typeof cfg.webhook.telegram !== "object") cfg.webhook.telegram = {};
260
+ if (token) cfg.webhook.telegram.botToken = token;
261
+ cfg.webhook.telegram.chatId = String(chatId);
262
+ cfg.webhook.provider = "telegram";
263
+ if (cfg.version === undefined) cfg.version = 1;
264
+ if (cfg.plansDir === undefined) cfg.plansDir = ".plans";
265
+ mkdirSync(path.dirname(planflowPath), { recursive: true });
266
+ writeFileSync(planflowPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
267
+ return cfg;
268
+ }
269
+
270
+ /**
271
+ * Send a test notification through the normal provider path (event "test").
272
+ * Lives here so installer and notify CLI share one implementation; providers.mjs
273
+ * special-cases event==="test" to bypass event filters.
274
+ */
275
+ export async function sendTestNotification(planflowPath, { log = console.log, warn = console.warn } = {}) {
276
+ try {
277
+ // This module sits next to planflow-providers.mjs after install and in the package.
278
+ const providers = await import("./planflow-providers.mjs");
279
+ const cfg = providers.loadConfig(planflowPath, process.cwd());
280
+ const result = await providers.send(cfg, {
281
+ event: "test",
282
+ plan: "setup",
283
+ title: "omo-slim-plan",
284
+ message: "Telegram 配置成功",
285
+ });
286
+ if (result?.ok) {
287
+ log("Test notification sent ✓ — check your Telegram.");
288
+ return { ok: true, result };
289
+ }
290
+ if (result?.skipped) {
291
+ warn("test event was skipped (unexpected) — check webhook.events in planflow.json");
292
+ return { ok: false, result };
293
+ }
294
+ warn(`test notification failed: ${result?.reason || result?.error || "unknown"}`);
295
+ return { ok: false, result };
296
+ } catch (e) {
297
+ warn(`test notification error: ${e?.message || e}`);
298
+ return { ok: false, error: e?.message || String(e) };
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Full interactive Telegram first-run setup.
304
+ * Returns { saved, chatId, token, tested }.
305
+ */
306
+ export async function runTelegramSetup({
307
+ token,
308
+ planflowPath,
309
+ log = console.log,
310
+ warn = console.warn,
311
+ } = {}) {
312
+ log("");
313
+ log("=== Telegram first-run setup ===");
314
+
315
+ // Ctrl+C → clean exit, no stack trace
316
+ const onSigint = () => {
317
+ process.stdout.write("\n");
318
+ log("Cancelled. Re-run setup anytime: npx omo-slim-plan --setup-telegram");
319
+ process.exit(130);
320
+ };
321
+ process.on("SIGINT", onSigint);
322
+
323
+ try {
324
+ // 1. Token: flag/config value, or prompt
325
+ let tok = String(token || "").trim();
326
+ if (!tok) {
327
+ const ans = (await promptLine("Bot token from @BotFather (stored only in planflow.json): ")).trim();
328
+ tok = ans;
329
+ }
330
+ if (!tok) {
331
+ log("No token provided — skipping Telegram setup.");
332
+ log("Re-run: npx omo-slim-plan --setup-telegram (or pass --telegram-token)");
333
+ return { saved: false, chatId: null, token: null, tested: false };
334
+ }
335
+ log(` token: ${maskToken(tok)}`);
336
+
337
+ // 2. getMe → bot identity + open hint
338
+ const me = await getMe(tok);
339
+ if (!me.ok) {
340
+ log(`getMe failed: ${me.error}`);
341
+ log("Check the token (@BotFather → /token) and that the bot is not blocked.");
342
+ return { saved: false, chatId: null, token: tok, tested: false };
343
+ }
344
+ const username = me.data?.username ? `@${me.data.username}` : "(no username)";
345
+ log(` bot: ${username}${me.data?.first_name ? ` (${me.data.first_name})` : ""}`);
346
+ log("");
347
+ log("Please message this bot in Telegram now (any text, e.g. /start or hi).");
348
+ log("Waiting for your message… (timeout 120s, Ctrl+C to cancel)");
349
+
350
+ // 3. Poll getUpdates with status
351
+ const waitRes = await waitForMessage(tok, {
352
+ timeoutMs: DEFAULT_WAIT_MS,
353
+ pollIntervalMs: DEFAULT_POLL_MS,
354
+ onStatus: ({ elapsedMs }) => {
355
+ const total = Math.round(DEFAULT_WAIT_MS / 1000);
356
+ const cur = Math.round(elapsedMs / 1000);
357
+ if (process.stdout.isTTY) {
358
+ process.stdout.write(`\r polling getUpdates… ${cur}s / ${total}s `);
359
+ } else if (elapsedMs % 10_000 < DEFAULT_POLL_MS) {
360
+ process.stdout.write(`polling getUpdates… ${cur}s / ${total}s\n`);
361
+ }
362
+ },
363
+ });
364
+ if (process.stdout.isTTY) process.stdout.write("\n");
365
+
366
+ if (!waitRes.ok) {
367
+ if (waitRes.reason === "timeout") {
368
+ log("Timed out waiting for a message. Make sure you actually messaged the bot, then retry:");
369
+ log(" npx omo-slim-plan --setup-telegram");
370
+ } else {
371
+ log(`getUpdates failed: ${waitRes.error}`);
372
+ }
373
+ return { saved: false, chatId: null, token: tok, tested: false };
374
+ }
375
+
376
+ const msg = waitRes.message;
377
+ const chatId = msg?.chat?.id;
378
+
379
+ // 4. Confirm capture
380
+ log("");
381
+ log(formatCaptured(msg));
382
+ log("");
383
+ const ans = (await promptConfirm("是否将此 chat_id 保存到 planflow.json? [Y/n] "))
384
+ .trim()
385
+ .toLowerCase();
386
+ if (ans === "n" || ans === "no") {
387
+ log("Discarded — chatId left empty. Re-run setup when ready.");
388
+ return { saved: false, chatId: null, token: tok, tested: false };
389
+ }
390
+
391
+ // 5. Save + optionally acknowledge updates with offset
392
+ saveChatIdToConfig(planflowPath, tok, String(chatId));
393
+ log(`Saved chatId ${chatId} → ${planflowPath}`);
394
+ if (waitRes.offset !== undefined && waitRes.offset !== null) {
395
+ // Best-effort acknowledge so the captured message is not re-offered later
396
+ try {
397
+ await getUpdates(tok, waitRes.offset);
398
+ } catch {
399
+ /* ignore */
400
+ }
401
+ }
402
+
403
+ // 6. Test notification through providers (event "test" bypasses filters)
404
+ const test = await sendTestNotification(planflowPath, { log, warn });
405
+ return { saved: true, chatId: String(chatId), token: tok, tested: Boolean(test?.ok) };
406
+ } finally {
407
+ process.removeListener("SIGINT", onSigint);
408
+ }
409
+ }
@@ -43,6 +43,8 @@ description: Explore the request and write a decision-complete plan to .plans/<s
43
43
 
44
44
  - 若 notify 脚本不在该路径,用实际安装路径(`<configRoot>/plugin/planflow-notify.mjs`)。
45
45
  - 若未安装 omo-slim-plan 或未配置 webhook:跳过通知,继续第 7 步。
46
+ - 若 provider=telegram 且 chatId 为空:提示用户运行 `npx omo-slim-plan --setup-telegram`(首次只需 bot token,chat_id 交互捕获)。
47
+ - 若 provider=telegram 且 chatId 为空:提示用户运行 `npx omo-slim-plan --setup-telegram`(首次只需 bot token,chat_id 交互捕获)。
46
48
 
47
49
  7. **停在人工门禁**:用 question 工具向人类提供选项(一次只问这一题):
48
50
  - `start-work <slug>` — 按计划开始执行
@@ -104,6 +104,8 @@ node ~/.config/opencode/plugin/planflow-notify.mjs \
104
104
 
105
105
  - **notify 失败或 provider 未配置:继续工作流,绝不阻塞**。
106
106
  - 也可直接编辑 `~/.config/opencode/planflow.json` 配置 Telegram / generic webhook / command provider。
107
+ - Telegram 首次配置:只需 bot token;chat_id 由用户给 bot 发消息后交互捕获——提示运行 `npx omo-slim-plan --setup-telegram`。chatId 为空时通知会报 `telegram_not_configured`。
108
+ - Telegram 首次配置:只需 bot token;chat_id 由用户给 bot 发消息后交互捕获——提示运行 `npx omo-slim-plan --setup-telegram`。chatId 为空时通知会报 `telegram_not_configured`。
107
109
 
108
110
  ## Delegation(委派)
109
111