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 +17 -0
- package/README.md +27 -26
- package/bin/install.js +124 -11
- package/package.json +2 -2
- package/plugin/planflow-notify.mjs +3 -1
- package/plugin/planflow-providers.mjs +3 -1
- package/plugin/planflow-telegram.mjs +409 -0
- package/templates/command/plan.md +2 -0
- package/templates/skills/plan-workflow/SKILL.md +2 -0
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|