claude-telegram-bot 0.2.6 → 0.3.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.ko.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  **한국어** · [English](./README.md)
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
5
9
  텔레그램으로 메시지를 보내면, 집이나 서버에 켜둔 Claude Code가 작업하고 결과를 다시 텔레그램으로 돌려주는 봇입니다.
6
10
 
7
11
  ```
@@ -64,9 +68,11 @@ OpenClaw처럼 웹 UI까지 갖춘 구성을 써봤다면, 이 프로젝트는
64
68
  **npx로 바로 실행**
65
69
 
66
70
  ```sh
67
- npx claude-telegram-bot init # 현재 폴더에 config.json 생성
68
- # config.json 편집 (token, projectDir 등)
69
- npx claude-telegram-bot # config.json 으로 실행
71
+ npx claude-telegram-bot init # 현재 폴더에 config.json 생성
72
+ npx claude-telegram-bot init mybot.json # 파일명 직접 지정도 가능
73
+ # 설정 편집 (token, projectDir 등)
74
+ npx claude-telegram-bot # config.json 으로 실행
75
+ npx claude-telegram-bot mybot.json # 또는 경로를 직접 전달
70
76
  ```
71
77
 
72
78
  **전역 설치 (상시 가동에 권장)**
@@ -74,9 +80,10 @@ npx claude-telegram-bot # config.json 으로 실행
74
80
  ```sh
75
81
  npm i -g claude-telegram-bot
76
82
 
77
- claude-telegram-bot init ~/botconfigs/myproj # 해당 경로에 config.json 생성
78
- # config.json 편집
79
- claude-telegram-bot ~/botconfigs/myproj/config.json
83
+ claude-telegram-bot init ~/botconfigs/myproj # config.json 생성
84
+ claude-telegram-bot init ~/botconfigs/myproj/mybot.json # 또는 파일명 지정
85
+ # 설정 편집
86
+ claude-telegram-bot ~/botconfigs/myproj/mybot.json
80
87
  ```
81
88
 
82
89
  > **설정 파일은 git에 올리지 마세요.** config 파일에는 봇 토큰이 들어 있습니다. git 레포 안에 둔다면 `config.json`, `state*.json`, `attachments/`를 그 프로젝트의 `.gitignore`에 추가하세요. 이 레포는 해당 패턴을 이미 무시하므로 `claudebot.config.json` 같은 이름도 안전하지만, 다른 프로젝트는 직접 지정해야 합니다.
@@ -110,13 +117,17 @@ claude-telegram-bot ~/botconfigs/myproj/config.json
110
117
  - `테스트 돌려보고 통과하면 커밋하고 push 해줘`
111
118
  - `api.ts 에 에러 핸들링 추가해줘`
112
119
 
113
- 명령어: `/new`(맥락 초기화) · `/cron`(예약 작업 보기·추가·삭제) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
120
+ 명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
121
+
122
+ > **`/stop`** 은 실행 중인 Claude 프로세스를 즉시 종료하고 대기 중인 메시지 큐도 비웁니다. `--reset`을 붙이면 세션을 작업 시작 이전 상태로 되돌려 중단된 작업이 대화 맥락에 남지 않습니다.
114
123
 
115
124
  > **`/restart`** 는 먼저 `bot.mjs` 에 `node --check` 를 돌려 **문법 오류가 있으면 재시작을 취소**합니다(잘못된 수정이 봇을 크래시 루프에 빠뜨리는 것 방지). 통과하면 프로세스를 종료하고, 다시 띄우는 건 프로세스 관리자에게 맡깁니다. [launchd 설정](#상시-실행-launchd)(`KeepAlive`)이면 바로 동작하고, 관리자 없이 `node bot.mjs` 로만 돌리면 그냥 멈춥니다. 재시작 후 대화 세션은 `state.json` 의 ID로 이어집니다.
116
125
 
117
126
  ## 사용 메모
118
127
 
119
128
  - **세션 유지** — 대화는 `--resume`으로 자동으로 이어집니다. 마지막 세션 ID가 `state.json`에 저장되므로 봇을 재시작해도 맥락이 남습니다. 새로 시작하려면 `/new`.
129
+ - **메시지 큐** — 작업 중에 새 메시지가 오면 버리지 않고 큐에 쌓아둡니다. 작업이 끝나면 대기 중인 메시지를 모두 하나의 프롬프트로 합쳐서 처리합니다(예: "A 해줘" → "아니다 B 해줘"를 한 번에 처리). `/stop`으로 실행 중인 작업과 큐를 동시에 취소할 수 있습니다.
130
+ - **모델 권유** — 봇이 Claude에게 현재 모델을 알려줍니다. 질문 난이도가 현재 모델 수준을 넘는다고 판단되면 답변 끝에 전환 권유 한 줄이 붙습니다(예: 💡 `/model sonnet`). `/model <이름>`으로 전환(`haiku`, `sonnet`, `opus`, `fable`, 또는 전체 모델 ID) — `state.json`에 저장돼 재시작 후에도 유지됩니다.
120
131
  - **간결한 답변** — 텔레그램에 맞게 짧게 답하도록 시스템 프롬프트가 기본으로 붙습니다. 바꾸려면 `appendSystemPrompt`에 직접 넣으세요 (빈 문자열이면 끔).
121
132
  - **언어** — 봇 자체 문구(`/help`, 명령 메뉴, 상태 메시지)는 **기본 영어**, 텔레그램이 한국어인 사용자에겐 한국어로 나옵니다. `lang`(`"en"`/`"ko"`)으로 고정할 수 있습니다. Claude의 실제 답변은 **사용자가 쓴 언어**를 따라갑니다. `/` 명령 메뉴는 `setMyCommands`로 언어별 등록됩니다.
122
133
  - **서식 변환** — 답변의 마크다운(굵게·코드·표 등)을 텔레그램 HTML로 바꿔 보냅니다. 변환이 깨지는 경우엔 평문으로 다시 보냅니다.
package/README.md CHANGED
@@ -2,11 +2,15 @@
2
2
 
3
3
  [한국어](./README.ko.md) · **English**
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
6
+ [![npm downloads](https://img.shields.io/npm/dm/claude-telegram-bot.svg)](https://www.npmjs.com/package/claude-telegram-bot)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
5
9
  **A zero-dependency, single-file, daemonized Claude Code bot — no Bun, no Python, no open session.**
6
10
 
7
11
  A tiny bridge that takes your Telegram messages, runs `claude -p` (Claude Code headless mode)
8
12
  in a project folder, and sends the result back to the chat. One `.mjs` file on Node 18+ built-ins —
9
- nothing to `npm install`, nothing to audit but ~400 readable lines.
13
+ nothing to `npm install`, nothing to audit but under 1 000 readable lines.
10
14
 
11
15
  ```
12
16
  [you] → Telegram → bot.mjs → claude -p (config.projectDir) → result → Telegram
@@ -114,9 +118,11 @@ Prerequisites: **Node 18+** and the **`claude` CLI installed and authenticated**
114
118
  **Option A — npx (no install)**
115
119
 
116
120
  ```sh
117
- npx claude-telegram-bot init # writes ./config.json
118
- # edit config.json (token, projectDir, …)
119
- npx claude-telegram-bot # runs ./config.json
121
+ npx claude-telegram-bot init # writes ./config.json
122
+ npx claude-telegram-bot init mybot.json # or pick your own filename
123
+ # edit the config (token, projectDir, …)
124
+ npx claude-telegram-bot # runs ./config.json
125
+ npx claude-telegram-bot mybot.json # or pass the path directly
120
126
  ```
121
127
 
122
128
  **Option B — global install (recommended for an always-on daemon)**
@@ -124,9 +130,10 @@ npx claude-telegram-bot # runs ./config.json
124
130
  ```sh
125
131
  npm i -g claude-telegram-bot
126
132
 
127
- claude-telegram-bot init ~/botconfigs/myproj # writes ~/botconfigs/myproj/config.json
128
- # edit that config.json (token, projectDir, …)
129
- claude-telegram-bot ~/botconfigs/myproj/config.json
133
+ claude-telegram-bot init ~/botconfigs/myproj # writes ~/botconfigs/myproj/config.json
134
+ claude-telegram-bot init ~/botconfigs/myproj/mybot.json # or a custom filename
135
+ # edit the config (token, projectDir, …)
136
+ claude-telegram-bot ~/botconfigs/myproj/mybot.json
130
137
  ```
131
138
 
132
139
  Run several projects/personas by making one config file each and passing its path —
@@ -154,7 +161,11 @@ auth layer.)
154
161
  - `run the solver tests and commit + push if they pass`
155
162
  - `add an edge case to solve-2nd-floor-edges.ts`
156
163
 
157
- Commands: `/new` (reset context / new session) · `/cron` (list / add / remove scheduled tasks) · `/restart` (syntax-check & restart the bot) · `/status` (bot status & version) · `/model` (view / switch the model) · `/id` (show chat ID) · `/help`.
164
+ Commands: `/new` (reset context / new session) · `/stop` (stop current task; `--reset` to also roll back the session) · `/cron` (list / add / remove scheduled tasks) · `/restart` (syntax-check & restart the bot) · `/status` (bot status & version) · `/model` (view / switch the model) · `/id` (show chat ID) · `/help`.
165
+
166
+ > **`/stop`** kills the running Claude process immediately and clears any queued messages.
167
+ > Add `--reset` to also restore the session to the state it was in *before* the task started,
168
+ > so the conversation history doesn't include the interrupted work.
158
169
 
159
170
  > **`/restart`** runs `node --check` on `bot.mjs` first and **aborts the restart if it has a syntax
160
171
  > error** (so a bad edit can't crash-loop the bot), then exits — relying on a process supervisor
@@ -211,6 +222,8 @@ your launchd plist points them.
211
222
  absolute path is handed to Claude (caption included as the message). Images can be opened with Read.
212
223
  - **Sessions**: conversations resume automatically (`--resume`); the last session id is saved in
213
224
  `state.json`, so context survives restarts. Use `/new` to start fresh.
225
+ - **Message queue**: if you send a message while a task is running, it is queued (not dropped). When the task finishes, all queued messages are merged into a single prompt so Claude can resolve corrections and follow-ups in one pass (e.g. "do X" then "never mind, do Y" → handled together). Use `/stop` to cancel the running task and discard the queue.
226
+ - **Model hint**: the bot tells Claude which model it is running as. If Claude judges a question to be beyond its current tier, it appends a one-line suggestion at the end of the reply (e.g. 💡 `/model sonnet`). Switch with `/model <name>` — `haiku`, `sonnet`, `opus`, `fable`, or a full model id. The choice persists in `state.json` across restarts.
214
227
 
215
228
  ### Scheduled tasks (cron)
216
229
 
package/bot.mjs CHANGED
@@ -13,8 +13,8 @@
13
13
  // 사용자 대상 문구는 영어 기본 + 한국어(STR 테이블). 언어는 텔레그램 from.language_code 로
14
14
  // 자동 판별하고, cfg.lang 을 주면 그 언어로 고정함. 콘솔/CLI 출력은 영어 단일.
15
15
 
16
- import { basename, dirname, join } from "node:path";
17
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
+ import { basename, dirname, join, resolve } from "node:path";
17
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
18
 
19
19
  import dns from "node:dns";
20
20
  import { fileURLToPath } from "node:url";
@@ -57,7 +57,8 @@ Requires: the claude CLI installed and authenticated on the host.`);
57
57
  process.exit(0);
58
58
  }
59
59
  if (a === "init") {
60
- const target = join(process.argv[3] || process.cwd(), "config.json");
60
+ const arg = process.argv[3];
61
+ const target = arg?.endsWith(".json") ? resolve(arg) : join(arg || process.cwd(), "config.json");
61
62
  if (existsSync(target)) {
62
63
  console.error(`Already exists: ${target}`);
63
64
  process.exit(1);
@@ -126,6 +127,7 @@ const STR = {
126
127
  `${cfg.name || "Claude Code Telegram bot"}\n\n` +
127
128
  "• Just send a message and Claude works in the project.\n" +
128
129
  "• /new — reset conversation context (new session)\n" +
130
+ "• /stop — stop the current task · /stop --reset to also roll back the session\n" +
129
131
  "• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
130
132
  "• /restart — restart the bot (after a syntax check)\n" +
131
133
  "• /status — bot status & version\n" +
@@ -134,6 +136,11 @@ const STR = {
134
136
  `\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
135
137
  newSession: "🆕 Started a new conversation (previous context cleared).",
136
138
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
139
+ queued: (n) => `⏳ Queued (#${n}). Will run when the current task finishes.`,
140
+ stopOk: "🛑 Task stopped.",
141
+ stopReset: "🛑 Task stopped and session rolled back to before the task.",
142
+ stopNoop: "No task is running.",
143
+ localBusy: "💻 A local `ctb claude` session is active. Send a message when it's done.",
137
144
  needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
138
145
  cronEmpty:
139
146
  "No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
@@ -181,6 +188,7 @@ const STR = {
181
188
  `${cfg.name || "Claude Code 텔레그램 봇"}\n\n` +
182
189
  "• 그냥 메시지를 보내면 Claude가 프로젝트에서 작업합니다.\n" +
183
190
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
191
+ "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
184
192
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
185
193
  "• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
186
194
  "• /status — 봇 상태·버전 보기\n" +
@@ -189,6 +197,11 @@ const STR = {
189
197
  `\n작업 폴더: ${cfg.projectDir}\n권한 모드: ${cfg.permissionMode}`,
190
198
  newSession: "🆕 새 대화를 시작합니다 (이전 맥락 초기화).",
191
199
  busy: "⏳ 이전 작업이 아직 진행 중입니다. 끝나면 다시 보내주세요.",
200
+ queued: (n) => `⏳ 대기열에 추가됐습니다 (${n}번째). 현재 작업이 끝나면 자동으로 실행됩니다.`,
201
+ stopOk: "🛑 작업을 중단했습니다.",
202
+ stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
203
+ stopNoop: "실행 중인 작업이 없습니다.",
204
+ localBusy: "💻 로컬 `ctb claude` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
192
205
  needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
193
206
  cronEmpty:
194
207
  "등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
@@ -237,12 +250,13 @@ const t = (l, key, ...a) => {
237
250
  };
238
251
 
239
252
  // /model 에서 보여줄 추천 별칭(claude CLI 가 별칭·전체 모델 ID 모두 허용).
240
- const MODEL_SUGGESTIONS = ["opus", "sonnet", "haiku"];
253
+ const MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
241
254
 
242
255
  // /(슬래시) 자동완성 메뉴용 명령 목록 (언어별). setMyCommands 로 등록.
243
256
  const COMMANDS = {
244
257
  en: [
245
258
  { command: "new", description: "Reset context (new session)" },
259
+ { command: "stop", description: "Stop the current task (--reset to roll back session)" },
246
260
  { command: "cron", description: "List / add / remove scheduled tasks" },
247
261
  { command: "restart", description: "Restart the bot (after syntax check)" },
248
262
  { command: "status", description: "Bot status / version" },
@@ -252,6 +266,7 @@ const COMMANDS = {
252
266
  ],
253
267
  ko: [
254
268
  { command: "new", description: "대화 맥락 초기화 (새 세션)" },
269
+ { command: "stop", description: "작업 중단 (--reset 으로 세션 되돌리기)" },
255
270
  { command: "cron", description: "예약 작업 보기·추가·삭제" },
256
271
  { command: "restart", description: "봇 재시작 (문법 검사 후)" },
257
272
  { command: "status", description: "봇 상태·버전 보기" },
@@ -261,6 +276,23 @@ const COMMANDS = {
261
276
  ],
262
277
  };
263
278
 
279
+ // ── 로컬 세션 lock ────────────────────────────────────────────────────────
280
+ // ctb claude 실행 시 .claude-bot/local.lock (PID) 을 생성하고 종료 시 삭제.
281
+ // 봇은 claude 실행 전 이 파일을 확인해 동시 실행을 방지한다.
282
+ // PID 가 이미 종료된 경우(stale lock) 자동 제거 후 진행.
283
+ const LOCAL_LOCK_PATH = join(BOT_DIR, "local.lock");
284
+ function checkLocalLock() {
285
+ if (!existsSync(LOCAL_LOCK_PATH)) return false;
286
+ try {
287
+ const pid = parseInt(readFileSync(LOCAL_LOCK_PATH, "utf8"), 10);
288
+ process.kill(pid, 0); // throws if process is dead
289
+ return true; // lock is valid
290
+ } catch {
291
+ try { unlinkSync(LOCAL_LOCK_PATH); } catch {} // stale — remove
292
+ return false;
293
+ }
294
+ }
295
+
264
296
  // ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
265
297
  function loadState() {
266
298
  // 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
@@ -395,7 +427,7 @@ function runClaude(prompt, sessionId, opts = {}) {
395
427
  "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary, avoid large tables. Reply in the user's language.";
396
428
  // opts.modelHint: 현재 모델을 주입 → 답변 끝에 상위 모델 권유 제안(판단은 Claude 본인)
397
429
  const modelHint = opts.modelHint
398
- ? `Current model: ${model || "claude (default)"}. Model tiers (low→high): haiku → sonnet → opus. If this question seems to require more capability than the current model, append one short line at the very end of your reply: 💡 \`/model sonnet\` (or \`/model opus\`) for a stronger answer. Omit the suggestion for simple questions.`
430
+ ? `Current model: ${model || "claude (default)"}. Model tiers (low→high): haiku → sonnet → opus → fable. If this question seems to require more capability than the current model, append one short line at the very end of your reply: 💡 \`/model sonnet\` (or \`/model opus\`, \`/model fable\`) for a stronger answer. Omit the suggestion for simple questions.`
399
431
  : null;
400
432
  // 페르소나(cfg.persona) + 간결 지침 + 모델 힌트를 함께 주입 → 멀티 봇(역할별) 운영용
401
433
  const appendSys = [cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
@@ -407,6 +439,7 @@ function runClaude(prompt, sessionId, opts = {}) {
407
439
  cwd: cfg.projectDir,
408
440
  env: { ...process.env, ...(cfg.env || {}) },
409
441
  });
442
+ if (opts.trackChild) currentChild = child; // /stop 에서 kill 가능하도록 노출
410
443
 
411
444
  let out = "",
412
445
  err = "";
@@ -416,10 +449,12 @@ function runClaude(prompt, sessionId, opts = {}) {
416
449
  child.stderr.on("data", (d) => {
417
450
  err += d;
418
451
  });
419
- child.on("error", (e) =>
420
- resolve({ ok: false, text: `Failed to start claude: ${e.message}` }),
421
- );
452
+ child.on("error", (e) => {
453
+ currentChild = null;
454
+ resolve({ ok: false, text: `Failed to start claude: ${e.message}` });
455
+ });
422
456
  child.on("close", (code) => {
457
+ currentChild = null;
423
458
  try {
424
459
  const j = JSON.parse(out);
425
460
  resolve({
@@ -502,7 +537,7 @@ let schedule = buildSchedule();
502
537
  // 예약 작업은 사용자 대화 맥락을 오염시키지 않도록 항상 새 세션으로 독립 실행하고,
503
538
  // 결과를 allowedChatId 로 보낸다. busy 락을 공유해 사용자 요청과 직렬화됨.
504
539
  async function runScheduled(job) {
505
- if (busy) {
540
+ if (busy || checkLocalLock()) {
506
541
  console.warn(`Skipped scheduled job (busy): ${job.cron} — ${String(job.prompt).slice(0, 40)}`);
507
542
  return;
508
543
  }
@@ -674,6 +709,11 @@ async function downloadAttachment(att) {
674
709
 
675
710
  // ── 메시지 처리 ───────────────────────────────────────────────────────────
676
711
  let busy = false;
712
+ const msgQueue = []; // { msg, receivedAt } — busy 중 수신 메시지 대기열
713
+ let currentChild = null; // 실행 중인 claude child process (/stop 용)
714
+ let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
715
+ let prevSessionId; // /stop --reset 복원 대상
716
+ let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
677
717
 
678
718
  async function handle(msg) {
679
719
  const chatId = msg.chat?.id;
@@ -764,16 +804,37 @@ async function handle(msg) {
764
804
  await send(chatId, t(l, "newSession"));
765
805
  return;
766
806
  }
807
+ if (text === "/stop" || text.startsWith("/stop ")) {
808
+ if (!busy || !currentChild) {
809
+ await send(chatId, t(l, "stopNoop"));
810
+ return;
811
+ }
812
+ const reset = text.includes("--reset");
813
+ stopping = true;
814
+ msgQueue.length = 0; // 대기 메시지도 취소
815
+ currentChild.kill();
816
+ if (reset) {
817
+ state.sessionId = prevSessionId;
818
+ saveState(state);
819
+ }
820
+ await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
821
+ return;
822
+ }
767
823
 
768
824
  if (busy) {
769
- await send(chatId, t(l, "busy"));
825
+ msgQueue.push({ msg, receivedAt: Date.now() });
826
+ await send(chatId, t(l, "queued", msgQueue.length));
827
+ return;
828
+ }
829
+ if (checkLocalLock()) {
830
+ await send(chatId, t(l, "localBusy"));
770
831
  return;
771
832
  }
772
833
  busy = true;
773
834
  await tg("sendChatAction", { chat_id: chatId, action: "typing" });
774
835
  const started = Date.now();
775
836
  // 긴 작업 동안 타이핑 표시 유지
776
- const typing = setInterval(
837
+ currentTyping = setInterval(
777
838
  () =>
778
839
  tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(
779
840
  () => {},
@@ -792,7 +853,8 @@ async function handle(msg) {
792
853
  await send(chatId, t(l, "attachFail", e.message));
793
854
  }
794
855
  }
795
- const res = await runClaude(prompt, state.sessionId, { modelHint: true });
856
+ prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
857
+ const res = await runClaude(prompt, state.sessionId, { modelHint: true, trackChild: true });
796
858
  if (res.sessionId) {
797
859
  state.sessionId = res.sessionId;
798
860
  saveState(state);
@@ -801,15 +863,32 @@ async function handle(msg) {
801
863
  const footer = res.ok
802
864
  ? `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`
803
865
  : "";
804
- await send(chatId, (res.ok ? res.text : `⚠️ ${res.text}`) + footer);
866
+ if (!stopping) await send(chatId, (res.ok ? res.text : `⚠️ ${res.text}`) + footer);
805
867
  } catch (e) {
806
- await send(chatId, t(l, "botError", e.message));
868
+ if (!stopping) await send(chatId, t(l, "botError", e.message));
807
869
  } finally {
808
- clearInterval(typing);
870
+ clearInterval(currentTyping);
871
+ currentTyping = null;
872
+ stopping = false;
809
873
  busy = false;
874
+ if (msgQueue.length > 0) setImmediate(() => handle(drainQueue()));
810
875
  }
811
876
  }
812
877
 
878
+ // 큐 전체를 꺼내 하나의 메시지로 합침. 여러 개면 번호+경과시간 붙여 병합 → Claude가 맥락 일괄 파악.
879
+ function drainQueue() {
880
+ if (msgQueue.length === 1) return msgQueue.shift().msg;
881
+ const group = msgQueue.splice(0);
882
+ const merged = group
883
+ .map((item, i) => {
884
+ const text = item.msg.text || item.msg.caption || "";
885
+ const dt = Math.round((item.receivedAt - group[0].receivedAt) / 1000);
886
+ return i === 0 ? `[1] ${text}` : `[${i + 1}, +${dt}s] ${text}`;
887
+ })
888
+ .join("\n");
889
+ return { ...group[group.length - 1].msg, text: merged, caption: undefined };
890
+ }
891
+
813
892
  // ── 롱폴링 루프 ───────────────────────────────────────────────────────────
814
893
  async function main() {
815
894
  console.log("Bot started. Polling Telegram...");
package/ctb.mjs ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ // ctb — short-form CLI for claude-telegram-bot
3
+ //
4
+ // ctb [config.json] [...claude args] Run Claude, resuming the shared Telegram session
5
+ // ctb bot [config.json] Start the Telegram bot daemon (delegates to bot.mjs)
6
+ // ctb init [dir] Create a config.json template
7
+ // ctb --help | --version
8
+ //
9
+ // config.json is optional. A bare name like "planner.json" resolves relative to the
10
+ // package directory (where bot configs typically live alongside bot.mjs).
11
+ // Absolute or explicitly relative paths (/ or ./) resolve as-is.
12
+ //
13
+ // While Claude runs, .claude-bot/local.lock (PID) is created so the bot defers
14
+ // incoming Telegram messages until the local session ends.
15
+
16
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { basename, dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawn } from "node:child_process";
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const args = process.argv.slice(2);
23
+ const a = args[0];
24
+
25
+ const VERSION = (() => {
26
+ try {
27
+ return JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version;
28
+ } catch {
29
+ return "?";
30
+ }
31
+ })();
32
+
33
+ function runBot(botArgs) {
34
+ const child = spawn(process.execPath, [join(HERE, "bot.mjs"), ...botArgs], {
35
+ stdio: "inherit",
36
+ });
37
+ child.on("close", (code) => process.exit(code ?? 0));
38
+ }
39
+
40
+ function resolveConfig(arg) {
41
+ if (!arg) return process.env.BOT_CONFIG || join(HERE, "config.json");
42
+ // Absolute or explicitly relative path → use as-is
43
+ if (arg.startsWith("/") || arg.startsWith("./") || arg.startsWith("../"))
44
+ return arg;
45
+ // Bare name (e.g. "planner.json") → relative to package dir
46
+ return join(HERE, arg);
47
+ }
48
+
49
+ function main() {
50
+ if (a === "-h" || a === "--help") {
51
+ console.log(
52
+ `ctb v${VERSION} — claude-telegram-bot short CLI\n\n` +
53
+ `Usage:\n` +
54
+ ` ctb [config.json] [...args] Resume Telegram session and run Claude\n` +
55
+ ` ctb bot [config.json] Start the Telegram bot daemon\n` +
56
+ ` ctb init [dir] Create a config.json template\n` +
57
+ ` ctb --help | --version\n\n` +
58
+ `config.json defaults to $BOT_CONFIG or the package's own config.json.\n` +
59
+ `A bare name like "planner.json" resolves relative to the package directory.\n\n` +
60
+ `Examples:\n` +
61
+ ` ctb Interactive Claude, continuing the Telegram session\n` +
62
+ ` ctb -p "what did we do?" Headless Claude with session context\n` +
63
+ ` ctb planner.json Resume planner persona session interactively\n` +
64
+ ` ctb planner.json -p "..." Headless with planner session\n` +
65
+ ` ctb bot Start the bot with default config\n` +
66
+ ` ctb bot planner.json Start the bot with planner config`,
67
+ );
68
+ process.exit(0);
69
+ }
70
+
71
+ if (a === "-v" || a === "--version") {
72
+ console.log(VERSION);
73
+ process.exit(0);
74
+ }
75
+
76
+ if (a === "init") {
77
+ runBot(args);
78
+ return;
79
+ }
80
+
81
+ if (a === "bot") {
82
+ runBot(args.slice(1));
83
+ return;
84
+ }
85
+
86
+ // Run Claude, resuming the bot's session
87
+ const looksLikeConfig = a && a.endsWith(".json");
88
+ const configPath = resolveConfig(looksLikeConfig ? a : undefined);
89
+ const claudeArgs = looksLikeConfig ? args.slice(1) : args;
90
+
91
+ const dataDir = dirname(configPath);
92
+ const botDir = join(dataDir, ".claude-bot");
93
+ const stateBase = basename(configPath, ".json");
94
+ const stateFile = stateBase === "config" ? "state.json" : `${stateBase}.state.json`;
95
+ const statePath = join(botDir, stateFile);
96
+ const lockPath = join(botDir, "local.lock");
97
+
98
+ mkdirSync(botDir, { recursive: true });
99
+ writeFileSync(lockPath, String(process.pid));
100
+ const cleanup = () => { try { unlinkSync(lockPath); } catch {} };
101
+ process.on("exit", cleanup);
102
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
103
+ process.on("SIGTERM", () => { cleanup(); process.exit(143); });
104
+
105
+ let sessionId;
106
+ try {
107
+ sessionId = JSON.parse(readFileSync(statePath, "utf8")).sessionId;
108
+ } catch {}
109
+
110
+ const finalArgs = sessionId ? ["--resume", sessionId, ...claudeArgs] : claudeArgs;
111
+ if (sessionId) process.stderr.write(`Resuming session: ${sessionId}\n`);
112
+
113
+ const child = spawn("claude", finalArgs, { stdio: "inherit" });
114
+ child.on("close", (code) => process.exit(code ?? 0));
115
+ }
116
+
117
+ main();
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Drive Claude Code from Telegram — messages run headless `claude -p` in a project dir and replies come back to the chat. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "claude-telegram-bot": "bot.mjs"
7
+ "claude-telegram-bot": "bot.mjs",
8
+ "ctb": "ctb.mjs"
9
+ },
10
+ "exports": {
11
+ "./session.mjs": "./session.mjs"
8
12
  },
9
13
  "files": [
10
14
  "bot.mjs",
15
+ "ctb.mjs",
16
+ "session.mjs",
11
17
  "config.example.json",
12
18
  "com.claudebot.example.plist"
13
19
  ],
package/session.mjs ADDED
@@ -0,0 +1,129 @@
1
+ // Standalone Claude session manager — zero-dependency (Node 18+ built-ins only).
2
+ // Maintains conversational context across calls by persisting --resume session IDs.
3
+ //
4
+ // Usage:
5
+ // import { createSession } from 'claude-telegram-bot/session.mjs'
6
+ //
7
+ // const session = createSession({ projectDir: '/my/project' })
8
+ // const r1 = await session.run('리팩토링 계획 세워줘')
9
+ // const r2 = await session.run('그걸 실제로 실행해줘') // r1 컨텍스트 유지
10
+ // session.reset() // 세션 초기화
11
+ //
12
+ // statePath defaults to <projectDir>/.claude-bot/session.json.
13
+ // To share state with a running bot, pass the bot's state path explicitly:
14
+ // statePath: '/path/to/bot-config-dir/.claude-bot/state.json'
15
+
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
+ import { dirname, join } from 'node:path'
18
+ import { spawn } from 'node:child_process'
19
+
20
+ function loadState(statePath) {
21
+ try {
22
+ return JSON.parse(readFileSync(statePath, 'utf8'))
23
+ } catch {
24
+ return {}
25
+ }
26
+ }
27
+
28
+ function saveState(statePath, state) {
29
+ try {
30
+ mkdirSync(dirname(statePath), { recursive: true })
31
+ writeFileSync(statePath, JSON.stringify(state, null, 2))
32
+ } catch (e) {
33
+ console.error('Failed to save session state:', e.message)
34
+ }
35
+ }
36
+
37
+ function _runClaude(prompt, sessionId, opts) {
38
+ return new Promise((resolve) => {
39
+ const {
40
+ projectDir = process.cwd(),
41
+ permissionMode = 'acceptEdits',
42
+ model,
43
+ claudeBin = 'claude',
44
+ appendSystemPrompt,
45
+ env = {},
46
+ } = opts
47
+
48
+ const args = [
49
+ '-p', prompt,
50
+ '--output-format', 'json',
51
+ '--permission-mode', permissionMode,
52
+ ]
53
+ if (model) args.push('--model', model)
54
+ if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt)
55
+ if (sessionId) args.push('--resume', sessionId)
56
+
57
+ const child = spawn(claudeBin, args, {
58
+ cwd: projectDir,
59
+ env: { ...process.env, ...env },
60
+ })
61
+
62
+ let out = '', err = ''
63
+ child.stdout.on('data', (d) => { out += d })
64
+ child.stderr.on('data', (d) => { err += d })
65
+ child.on('error', (e) => resolve({ ok: false, text: `Failed to start claude: ${e.message}` }))
66
+ child.on('close', (code) => {
67
+ try {
68
+ const j = JSON.parse(out)
69
+ resolve({
70
+ ok: !j.is_error,
71
+ text: j.result ?? '(empty response)',
72
+ sessionId: j.session_id,
73
+ cost: j.total_cost_usd,
74
+ })
75
+ } catch {
76
+ resolve({
77
+ ok: false,
78
+ text: `Execution error (exit ${code}):\n${(err || out || 'no output').slice(0, 3500)}`,
79
+ })
80
+ }
81
+ })
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Create a stateful Claude session.
87
+ *
88
+ * @param {object} [opts]
89
+ * @param {string} [opts.projectDir] Working directory for claude (default: process.cwd())
90
+ * @param {string} [opts.statePath] Where to persist the session ID (default: <projectDir>/.claude-bot/session.json; bot uses <configDir>/.claude-bot/state.json)
91
+ * @param {string} [opts.permissionMode] Claude permission mode (default: 'acceptEdits')
92
+ * @param {string} [opts.model] Model override (e.g. 'sonnet', 'opus')
93
+ * @param {string} [opts.claudeBin] Path to the claude CLI (default: 'claude')
94
+ * @param {string} [opts.appendSystemPrompt] Extra system prompt to append
95
+ * @param {object} [opts.env] Extra environment variables
96
+ * @returns {{ run(prompt: string): Promise<{ok, text, sessionId, cost}>, reset(): void, getSessionId(): string|undefined }}
97
+ */
98
+ export function createSession(opts = {}) {
99
+ const projectDir = opts.projectDir ?? process.cwd()
100
+ const statePath = opts.statePath ?? join(projectDir, '.claude-bot', 'session.json')
101
+ const claudeOpts = {
102
+ projectDir,
103
+ permissionMode: opts.permissionMode ?? 'acceptEdits',
104
+ model: opts.model,
105
+ claudeBin: opts.claudeBin ?? 'claude',
106
+ appendSystemPrompt: opts.appendSystemPrompt,
107
+ env: opts.env ?? {},
108
+ }
109
+
110
+ let state = loadState(statePath)
111
+
112
+ return {
113
+ async run(prompt) {
114
+ const res = await _runClaude(prompt, state.sessionId, claudeOpts)
115
+ if (res.sessionId) {
116
+ state.sessionId = res.sessionId
117
+ saveState(statePath, state)
118
+ }
119
+ return res
120
+ },
121
+ reset() {
122
+ state.sessionId = undefined
123
+ saveState(statePath, state)
124
+ },
125
+ getSessionId() {
126
+ return state.sessionId
127
+ },
128
+ }
129
+ }