claude-telegram-bot 0.3.33 → 0.3.36

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.
Files changed (4) hide show
  1. package/README.ko.md +28 -2
  2. package/README.md +33 -2
  3. package/bot.mjs +218 -49
  4. package/package.json +1 -1
package/README.ko.md CHANGED
@@ -107,6 +107,10 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
107
107
  | `appendSystemPrompt` | (선택) 기본 "간결하게 답하기" 지침을 직접 덮어쓸 때 |
108
108
  | `env` | (선택) `claude` 프로세스에 넘길 환경 변수 |
109
109
  | `schedule` | (선택) 정해진 시각에 프롬프트를 실행하는 cron 작업 — [예약 작업](#예약-작업-cron) 참고 |
110
+ | `commands` | (선택) 쉘 스크립트를 실행하는 커스텀 `/명령어` — [커스텀 명령어](#커스텀-명령어) 참고 |
111
+ | `ollamaFallback` | (선택) `true`로 설정하면 Claude 레이트 리밋·크레딧 부족 시 로컬 Ollama로 자동 대체 |
112
+ | `ollamaModel` | (선택) 폴백에 쓸 Ollama 모델 (기본값: `"phi3:mini"`) |
113
+ | `autoCompactThreshold` | (선택) 캐시된 입력 토큰이 이 값을 초과하면 컨텍스트 자동 압축 (기본값: `100000`). `0`이면 비활성화 |
110
114
 
111
115
  `state`와 첨부 파일은 config 파일 옆 **`.claude-bot/` 숨김 폴더**에 저장됩니다(프로젝트 격리). 구버전에서 올리면 첫 시작 때 기존 `state.json`·`attachments/`를 `.claude-bot/`로 **자동 이동**합니다(무손실). 로그는 launchd plist가 가리키는 위치 그대로입니다.
112
116
 
@@ -118,7 +122,9 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
118
122
  - `테스트 돌려보고 통과하면 커밋하고 push 해줘`
119
123
  - `api.ts 에 에러 핸들링 추가해줘`
120
124
 
121
- 명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/reserve`(한도 리셋 재시도 예약) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
125
+ 명령어: `/new`(맥락 초기화) · `/compact`(컨텍스트 압축, 세션 유지) · `/plan <요청>`(계획만 세우고 승인/취소) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/ollama`(Ollama 채팅 모드 토글) · `/cron`(예약 작업 보기·추가·삭제) · `/reserve`(대기 상태 확인 · `/reserve rm`으로 취소) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
126
+
127
+ > **`/plan <요청>`** 은 봇에 설정된 `permissionMode`와 무관하게 강제로 읽기 전용 plan 모드(편집·쉘 없음)로 요청을 실행하고, 계획 내용과 함께 **✅ 진행 / ❌ 취소** 버튼을 보냅니다. **진행**을 누르면 같은 세션을 봇의 실제 `permissionMode`로 이어서 승인된 계획을 실행하고, **취소**를 누르면 세션은 그대로 유지됩니다. `bypassPermissions` 봇이 뭔가 건드리기 전에 검토 단계를 두고 싶을 때 유용합니다. `/new`로 새 세션을 시작하면 대기 중인 승인은 만료됩니다.
122
128
 
123
129
  > **`/stop`** 은 실행 중인 Claude 프로세스를 즉시 종료하고 대기 중인 메시지 큐도 비웁니다. `--reset`을 붙이면 세션을 작업 시작 이전 상태로 되돌려 중단된 작업이 대화 맥락에 남지 않습니다.
124
130
 
@@ -129,12 +135,32 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
129
135
  - **세션 유지** — 대화는 `--resume`으로 자동으로 이어집니다. 마지막 세션 ID가 `state.json`에 저장되므로 봇을 재시작해도 맥락이 남습니다. 새로 시작하려면 `/new`.
130
136
  - **메시지 큐** — 작업 중에 새 메시지가 오면 버리지 않고 큐에 쌓아둡니다. 작업이 끝나면 대기 중인 메시지를 모두 하나의 프롬프트로 합쳐서 처리합니다(예: "A 해줘" → "아니다 B 해줘"를 한 번에 처리). `/stop`으로 실행 중인 작업과 큐를 동시에 취소할 수 있습니다.
131
137
  - **모델 권유** — 봇이 Claude에게 현재 모델을 알려줍니다. 질문 난이도가 현재 모델 수준을 넘는다고 판단되면 답변 끝에 전환 권유 한 줄이 붙습니다(예: 💡 `/model sonnet`). `/model <이름>`으로 전환(`haiku`, `sonnet`, `opus`, `fable`, 또는 전체 모델 ID) — `state.json`에 저장돼 재시작 후에도 유지됩니다.
132
- - **한도 초과 재시도** — Claude Max / API 레이트 리밋 에러에 리셋 시간이 포함되면 `/reserve` 힌트를 표시합니다. `/reserve`를 입력하면 마지막 메시지를 해당 시각에 자동 재전송 예약. `/reserve <다른 메시지>`로 내용 변경, `/reserve rm`으로 취소.
138
+ - **한도 초과 큐** — Claude Max / API 레이트 리밋 에러에 리셋 시간이 포함되면, 해당 메시지를 자동으로 큐에 넣고 리셋 시각에 재시도합니다 작업 큐와 같은 방식입니다. 한도가 걸린 동안 추가로 보내는 메시지도 자동으로 큐에 쌓입니다. `/reserve`로 대기 현황과 리셋 시각 확인, `/reserve rm`으로 큐 전체 취소.
139
+ - **Ollama 폴백** — `"ollamaFallback": true`로 설정하고 `"ollamaModel"`에 로컬 [Ollama](https://ollama.ai) 모델을 지정하면(기본값: `"phi3:mini"`), Claude 레이트 리밋·크레딧 부족 시 자동으로 Ollama로 대체 응답합니다. `/ollama`로 Claude와 관계없이 Ollama를 기본 채팅 상대로 수동 전환할 수도 있습니다 (가벼운 질문에 유용).
140
+ - **자동 컴팩션** — 세션의 캐시된 입력 토큰이 `autoCompactThreshold`(기본값 100,000)를 초과하면 `/compact`를 자동 실행하고 알림을 보냅니다. config에서 임계값을 조정하거나 `0`으로 비활성화. 수동으로 `/compact`를 써도 됩니다.
133
141
  - **간결한 답변** — 텔레그램에 맞게 짧게 답하도록 시스템 프롬프트가 기본으로 붙습니다. 바꾸려면 `appendSystemPrompt`에 직접 넣으세요 (빈 문자열이면 끔).
134
142
  - **언어** — 봇 자체 문구(`/help`, 명령 메뉴, 상태 메시지)는 **기본 영어**, 텔레그램이 한국어인 사용자에겐 한국어로 나옵니다. `lang`(`"en"`/`"ko"`)으로 고정할 수 있습니다. Claude의 실제 답변은 **사용자가 쓴 언어**를 따라갑니다. `/` 명령 메뉴는 `setMyCommands`로 언어별 등록됩니다.
135
143
  - **서식 변환** — 답변의 마크다운(굵게·코드·표 등)을 텔레그램 HTML로 바꿔 보냅니다. 변환이 깨지는 경우엔 평문으로 다시 보냅니다.
136
144
  - **첨부 파일** — 사진·문서·음성·영상을 보내면 `attachments/`에 내려받고, 그 경로를 Claude에게 전달합니다(캡션도 함께). 이미지는 Read로 열어볼 수 있습니다.
137
145
 
146
+ ## 커스텀 명령어
147
+
148
+ config에 `commands`를 정의하면 프로젝트별 `/명령어`로 쉘 스크립트를 실행하고 결과를 채팅으로 받을 수 있습니다. 정의한 명령어는 텔레그램 `/` 자동완성 메뉴에 자동 등록됩니다.
149
+
150
+ ```json
151
+ "commands": {
152
+ "deploy": { "run": "npm run deploy", "description": "프로덕션 배포" },
153
+ "logs": { "run": "tail -n 50 ./app.log", "description": "최근 로그" },
154
+ "status": { "run": "git status && git log --oneline -5", "description": "Git 상태" }
155
+ }
156
+ ```
157
+
158
+ - **`run`** — 실행할 쉘 명령어. `projectDir`에서 실행됩니다.
159
+ - **`description`** — 텔레그램 `/` 자동완성 메뉴에 표시되는 설명
160
+ - **인자 전달**: `/deploy staging`처럼 뒤에 붙이면 명령어에 그대로 추가됩니다 (`npm run deploy staging`)
161
+ - Claude와 독립적으로 실행 — Claude 작업 중에도 동작합니다
162
+ - 출력은 최대 4,000자, 타임아웃 60초
163
+
138
164
  ## 예약 작업 (cron)
139
165
 
140
166
  config에 `schedule` 배열을 두면 정해진 시각에 프롬프트를 자동 실행합니다 — 아침 브리핑, 주기적 점검, 리마인더 등. 각 항목은 프롬프트를 실행하고 결과를 `allowedChatId`로 보냅니다.
package/README.md CHANGED
@@ -162,7 +162,14 @@ auth layer.)
162
162
  - `run the solver tests and commit + push if they pass`
163
163
  - `add an edge case to solve-2nd-floor-edges.ts`
164
164
 
165
- Commands: `/new` (reset context / new session) · `/stop` (stop current task; `--reset` to also roll back the session) · `/cron` (list / add / remove scheduled tasks) · `/reserve` (schedule retry at usage-limit reset) · `/restart` (syntax-check & restart the bot) · `/status` (bot status & version) · `/model` (view / switch the model) · `/id` (show chat ID) · `/help`.
165
+ Commands: `/new` (reset context / new session) · `/compact` (compress context, keep session) · `/plan <request>` (plan only, then approve/cancel) · `/stop` (stop current task; `--reset` to also roll back the session) · `/ollama` (toggle Ollama chat mode) · `/cron` (list / add / remove scheduled tasks) · `/reserve` (show queue status at usage-limit · `/reserve rm` to cancel) · `/restart` (syntax-check & restart the bot) · `/status` (bot status & version) · `/model` (view / switch the model) · `/id` (show chat ID) · `/help`.
166
+
167
+ > **`/plan <request>`** runs the request in read-only plan mode (no edits, no shell) regardless of
168
+ > the bot's configured `permissionMode`, and replies with the plan plus **✅ Proceed / ❌ Cancel**
169
+ > buttons. Tapping **Proceed** resumes the same session with the bot's normal `permissionMode` and
170
+ > tells Claude to implement the approved plan; **Cancel** leaves the session untouched. This gives
171
+ > you a review step before a `bypassPermissions` bot touches anything. The pending approval expires
172
+ > if you start a new session with `/new` first.
166
173
 
167
174
  > **`/stop`** kills the running Claude process immediately and clears any queued messages.
168
175
  > Add `--reset` to also restore the session to the state it was in *before* the task started,
@@ -203,6 +210,10 @@ Edit `mybot.json`:
203
210
  | `appendSystemPrompt` | (optional) Override the default "be concise for Telegram" instruction |
204
211
  | `env` | (optional) Extra environment variables passed to the `claude` process |
205
212
  | `schedule` | (optional) Cron jobs that run a prompt on a timer — see [Scheduled tasks](#scheduled-tasks-cron) |
213
+ | `commands` | (optional) Custom `/commands` that run shell scripts — see [Custom commands](#custom-commands) |
214
+ | `ollamaFallback` | (optional) `true` to enable Ollama as a fallback when Claude is rate-limited or out of credits |
215
+ | `ollamaModel` | (optional) Ollama model to use for fallback (default: `"phi3:mini"`) |
216
+ | `autoCompactThreshold` | (optional) Auto-compact context when cached input tokens exceed this value (default: `100000`). Set to `0` to disable. |
206
217
 
207
218
  State and downloaded attachments live in a hidden **`.claude-bot/`** folder next to the config
208
219
  file, so projects stay isolated. Upgrading from an older version **auto-moves** an existing
@@ -225,7 +236,27 @@ your launchd plist points them.
225
236
  `state.json`, so context survives restarts. Use `/new` to start fresh.
226
237
  - **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.
227
238
  - **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.
228
- - **Usage-limit retry**: when a Claude Max / API rate-limit error includes a reset time, the bot shows a `/reserve` hint. `/reserve` schedules your last message to be resent automatically at that time. Use `/reserve <different text>` to change what's sent, or `/reserve rm` to cancel.
239
+ - **Usage-limit queue**: when a Claude Max / API rate-limit error includes a reset time, the triggering message is automatically queued and retried at that time — just like messages queued while Claude is busy. Any additional messages you send during the limit window are also added to the queue. Use `/reserve` to check queue status and reset time, `/reserve rm` to cancel and clear the queue.
240
+ - **Ollama fallback**: set `"ollamaFallback": true` and point `"ollamaModel"` at a locally-running [Ollama](https://ollama.ai) model (default: `"phi3:mini"`). When Claude is rate-limited or out of credits, the bot automatically falls back to Ollama and prepends a notice to the reply. Use `/ollama` to toggle Ollama as your primary chat partner at any time, even when Claude is working fine — useful for lightweight queries.
241
+ - **Auto-compact**: the bot tracks how many cached tokens are in the session context. When `cache_read_input_tokens` exceeds `autoCompactThreshold` (default 100 000), it automatically runs `/compact` and notifies you. Tune the threshold in config or set it to `0` to disable. You can also run `/compact` manually at any time.
242
+
243
+ ### Custom commands
244
+
245
+ Define project-specific `/commands` in config that run shell scripts and return their output to the chat. Commands appear in Telegram's `/` autocomplete menu automatically.
246
+
247
+ ```json
248
+ "commands": {
249
+ "deploy": { "run": "npm run deploy", "description": "Deploy to production" },
250
+ "logs": { "run": "tail -n 50 ./app.log", "description": "Recent logs" },
251
+ "status": { "run": "git status && git log --oneline -5", "description": "Git status" }
252
+ }
253
+ ```
254
+
255
+ - **`run`** — any shell command, executed in `projectDir`
256
+ - **`description`** — shown in the Telegram `/` autocomplete menu
257
+ - **Arguments**: `/deploy staging` appends `staging` to the command (`npm run deploy staging`)
258
+ - Scripts run independently of Claude — they work even when Claude is busy
259
+ - Output capped at 4 000 characters; 60-second timeout
229
260
 
230
261
  ### Scheduled tasks (cron)
231
262
 
package/bot.mjs CHANGED
@@ -135,12 +135,13 @@ const STR = {
135
135
  "• Just send a message and Claude works in the project.\n" +
136
136
  "• /new — reset conversation context (new session)\n" +
137
137
  "• /compact — compress context to free up space (keeps the session)\n" +
138
+ "• /plan <request> — plan only (no edits), then approve/cancel to run for real\n" +
138
139
  "• /ollama — toggle Ollama chat mode (bypass Claude, use local LLM)\n" +
139
140
  "• /stop — stop the current task · /stop --reset to also roll back the session\n" +
140
141
  "• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
141
142
  "• /remember <text> — save to persistent memory (survives /new)\n" +
142
143
  "• /memory — view memory · /memory clear to wipe\n" +
143
- "• /reserve — retry at usage-limit reset time · /reserve <msg> for a different message · /reserve rm to cancel\n" +
144
+ "• /reserve — show retry queue status at usage-limit reset · /reserve rm to cancel\n" +
144
145
  "• /restart — restart the bot (after a syntax check)\n" +
145
146
  "• /status — bot status & version\n" +
146
147
  "• /model — view / switch the model\n" +
@@ -151,6 +152,11 @@ const STR = {
151
152
  compactFail: (m) => `⚠️ Compact failed: ${m}`,
152
153
  compactNoSession: "No active session to compact. Just send a message to start one.",
153
154
  autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
155
+ planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
156
+ planApprove: "✅ Proceed",
157
+ planCancel: "❌ Cancel",
158
+ planCancelled: "❌ Plan cancelled. No changes were made.",
159
+ planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
154
160
  testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
155
161
  testFallbackFail: (m) => `⚠️ Ollama test failed: ${m}`,
156
162
  ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
@@ -221,12 +227,13 @@ const STR = {
221
227
  "• 그냥 메시지를 보내면 Claude가 프로젝트에서 작업합니다.\n" +
222
228
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
223
229
  "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
230
+ "• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
224
231
  "• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
225
232
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
226
233
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
227
234
  "• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
228
235
  "• /memory — 메모리 보기 · /memory clear 로 삭제\n" +
229
- "• /reserve — 한도 리셋 시 재시도 예약 · /reserve <다른 메시지> 로 내용 변경 · /reserve rm 으로 취소\n" +
236
+ "• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
230
237
  "• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
231
238
  "• /status — 봇 상태·버전 보기\n" +
232
239
  "• /model — 모델 보기·전환\n" +
@@ -294,6 +301,11 @@ const STR = {
294
301
  compactFail: (m) => `⚠️ compact 실패: ${m}`,
295
302
  compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
296
303
  autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
304
+ planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
305
+ planApprove: "✅ 진행",
306
+ planCancel: "❌ 취소",
307
+ planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
308
+ planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
297
309
  contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
298
310
  testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
299
311
  testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
@@ -314,6 +326,7 @@ const COMMANDS = {
314
326
  en: [
315
327
  { command: "new", description: "Reset context (new session)" },
316
328
  { command: "compact", description: "Compress context to free up space (keeps session)" },
329
+ { command: "plan", description: "Plan only (no edits), then approve/cancel to run for real" },
317
330
  { command: "ollama", description: "Toggle Ollama chat mode (bypass Claude, use local LLM)" },
318
331
  { command: "stop", description: "Stop the current task (--reset to roll back session)" },
319
332
  { command: "remember", description: "Save to persistent memory (survives /new)" },
@@ -329,6 +342,7 @@ const COMMANDS = {
329
342
  ko: [
330
343
  { command: "new", description: "대화 맥락 초기화 (새 세션)" },
331
344
  { command: "compact", description: "컨텍스트 압축 (세션 유지, 공간 확보)" },
345
+ { command: "plan", description: "계획만 세우기 (편집 없음) → 승인/취소로 실제 실행" },
332
346
  { command: "ollama", description: "Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM)" },
333
347
  { command: "stop", description: "작업 중단 (--reset 으로 세션 되돌리기)" },
334
348
  { command: "remember", description: "퍼시스턴트 메모리에 저장 (/new 후에도 유지)" },
@@ -512,19 +526,30 @@ function mdToTelegramHtml(md) {
512
526
  return out.join("\n").replace(/ CB(\d+) /g, (_, i) => codeBlocks[Number(i)]);
513
527
  }
514
528
 
515
- async function send(chatId, text) {
516
- for (const c of chunks(text)) {
517
- const r = await tg("sendMessage", {
529
+ // opts.replyMarkup: 인라인 키보드 — 여러 청크로 나뉘면 마지막 청크에만 붙음.
530
+ // 반환값: 마지막으로 보낸 메시지의 message_id (버튼 클릭 편집용, 실패 시 null).
531
+ async function send(chatId, text, opts = {}) {
532
+ const cs = [...chunks(text)];
533
+ let lastId = null;
534
+ for (let i = 0; i < cs.length; i++) {
535
+ const isLast = i === cs.length - 1;
536
+ const body = {
518
537
  chat_id: chatId,
519
- text: mdToTelegramHtml(c),
538
+ text: mdToTelegramHtml(cs[i]),
520
539
  parse_mode: "HTML",
521
540
  disable_web_page_preview: true,
522
- });
541
+ };
542
+ if (isLast && opts.replyMarkup) body.reply_markup = opts.replyMarkup;
543
+ let r = await tg("sendMessage", body);
523
544
  // If our HTML is malformed for some edge case, resend as plain text.
524
545
  if (!r || r.ok === false) {
525
- await tg("sendMessage", { chat_id: chatId, text: c, disable_web_page_preview: true });
546
+ const plain = { chat_id: chatId, text: cs[i], disable_web_page_preview: true };
547
+ if (isLast && opts.replyMarkup) plain.reply_markup = opts.replyMarkup;
548
+ r = await tg("sendMessage", plain);
526
549
  }
550
+ if (r?.ok) lastId = r.result?.message_id;
527
551
  }
552
+ return lastId;
528
553
  }
529
554
 
530
555
  // ── Claude 에러 분류 ──────────────────────────────────────────────────────
@@ -572,12 +597,33 @@ function classifyClaudeError(raw, code) {
572
597
  return `Execution error (exit ${code}):\n${raw}`;
573
598
  }
574
599
 
600
+ // ── 커스텀 명령어 스크립트 실행 ──────────────────────────────────────────
601
+ function runCustomCommand(run, args) {
602
+ return new Promise((resolve) => {
603
+ const cmd = args ? `${run} ${args}` : run;
604
+ const child = spawn(cmd, [], {
605
+ shell: true,
606
+ cwd: cfg.projectDir,
607
+ env: { ...process.env, ...(cfg.env || {}) },
608
+ });
609
+ let out = "", err = "";
610
+ child.stdout.on("data", (d) => (out += d));
611
+ child.stderr.on("data", (d) => (err += d));
612
+ child.on("close", (code) => {
613
+ const text = (out + (err ? `\n[stderr]\n${err}` : "")).trim() || "(no output)";
614
+ resolve({ ok: code === 0, text, code });
615
+ });
616
+ child.on("error", (e) => resolve({ ok: false, text: e.message, code: -1 }));
617
+ setTimeout(() => { try { child.kill(); } catch {} resolve({ ok: false, text: "timeout (60s)", code: -1 }); }, 60_000);
618
+ });
619
+ }
620
+
575
621
  // ── Claude 실행 ───────────────────────────────────────────────────────────
576
622
  function runClaude(prompt, sessionId, opts = {}) {
577
623
  return new Promise((resolve) => {
578
624
  const args = [
579
625
  "--output-format", "json",
580
- "--permission-mode", cfg.permissionMode || "acceptEdits",
626
+ "--permission-mode", opts.permissionMode || cfg.permissionMode || "acceptEdits",
581
627
  ];
582
628
  const model = state.model || cfg.model; // /model 로 바꾸면 state.model 우선
583
629
  const brevity =
@@ -939,8 +985,120 @@ let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
939
985
  let prevSessionId; // /stop --reset 복원 대상
940
986
  let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
941
987
  let rateLimitUntil = null; // 레이트 리밋 활성 시 리셋 Date — 이 시간까지 메시지를 큐에 쌓음
988
+ const pendingPlans = new Map(); // chatId → { sessionId, messageId } — /plan 승인 대기
989
+ const PLAN_PROCEED_PROMPT = "Proceed with the plan you just approved above. Implement it now.";
942
990
  let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
943
991
 
992
+ // runClaude 결과를 답장으로 변환 — 폴백/큐잉/자동 컴팩션 처리. handle()과 /plan 승인 실행이 공유.
993
+ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
994
+ const secs = Math.round((Date.now() - started) / 1000);
995
+ if (!res.ok) {
996
+ // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
997
+ if (cfg.ollamaFallback && res.canFallback && !stopping) {
998
+ try {
999
+ const oRes = await runOllama(prompt, l);
1000
+ if (oRes.ok) { await send(chatId, oRes.text); return; }
1001
+ } catch {}
1002
+ }
1003
+ // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
1004
+ let autoRetryMsg = "";
1005
+ if (res.resetAt && !stopping) {
1006
+ msgQueue.unshift({ msg, receivedAt: Date.now() });
1007
+ rateLimitUntil = res.resetAt;
1008
+ if (rateLimitTimer) clearTimeout(rateLimitTimer);
1009
+ rateLimitTimer = setTimeout(() => {
1010
+ rateLimitTimer = null;
1011
+ rateLimitUntil = null;
1012
+ if (msgQueue.length > 0) handle(drainQueue());
1013
+ }, Math.max(res.resetAt - Date.now(), 1000));
1014
+ const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1015
+ autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1016
+ }
1017
+ const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1018
+ if (!stopping) await send(chatId, errMsg);
1019
+ } else {
1020
+ const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1021
+ if (!stopping) await send(chatId, res.text + footer);
1022
+ // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1023
+ const compactThreshold = cfg.autoCompactThreshold ?? 100000;
1024
+ if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1025
+ try {
1026
+ const cr = await runClaude("/compact", state.sessionId);
1027
+ if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
1028
+ if (cr.ok !== false) await send(chatId, t(l, "autoCompact"));
1029
+ } catch {}
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // /plan 승인 후 실행 — busy 락·타이핑 표시를 handle()과 동일한 패턴으로 자체 처리.
1035
+ async function runApprovedPlan(chatId, l) {
1036
+ const pending = pendingPlans.get(chatId);
1037
+ pendingPlans.delete(chatId);
1038
+ // /new 등으로 세션이 바뀌었으면 이 계획은 더 이상 유효하지 않음
1039
+ if (!pending || pending.sessionId !== state.sessionId) {
1040
+ await send(chatId, t(l, "planNoPending"));
1041
+ return;
1042
+ }
1043
+ if (busy) {
1044
+ msgQueue.push({ msg: { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT }, receivedAt: Date.now() });
1045
+ await send(chatId, t(l, "queued", msgQueue.length));
1046
+ return;
1047
+ }
1048
+ if (checkLocalLock()) {
1049
+ await send(chatId, t(l, "localBusy"));
1050
+ return;
1051
+ }
1052
+ busy = true;
1053
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1054
+ const started = Date.now();
1055
+ currentTyping = setInterval(
1056
+ () => tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}),
1057
+ 5000,
1058
+ );
1059
+ const syntheticMsg = { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT };
1060
+ try {
1061
+ prevSessionId = state.sessionId;
1062
+ const res = await runClaude(PLAN_PROCEED_PROMPT, pending.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1063
+ if (res.sessionId) {
1064
+ state.sessionId = res.sessionId;
1065
+ saveState(state);
1066
+ }
1067
+ await replyWithClaudeResult(chatId, l, PLAN_PROCEED_PROMPT, syntheticMsg, res, started);
1068
+ } catch (e) {
1069
+ if (!stopping) await send(chatId, t(l, "botError", e.message));
1070
+ } finally {
1071
+ clearInterval(currentTyping);
1072
+ currentTyping = null;
1073
+ stopping = false;
1074
+ busy = false;
1075
+ if (msgQueue.length > 0 && !rateLimitUntil) setImmediate(() => handle(drainQueue()));
1076
+ }
1077
+ }
1078
+
1079
+ // 텔레그램 인라인 버튼(✅/❌) 클릭 처리
1080
+ async function handleCallback(cq) {
1081
+ const chatId = cq.message?.chat?.id;
1082
+ if (!chatId || !allowedIds.includes(String(chatId))) {
1083
+ await tg("answerCallbackQuery", { callback_query_id: cq.id }).catch(() => {});
1084
+ return;
1085
+ }
1086
+ const l = langOf({ from: cq.from });
1087
+ await tg("answerCallbackQuery", { callback_query_id: cq.id }).catch(() => {});
1088
+ // 중복 클릭 방지 — 원본 메시지의 버튼 제거
1089
+ tg("editMessageReplyMarkup", {
1090
+ chat_id: chatId,
1091
+ message_id: cq.message.message_id,
1092
+ reply_markup: { inline_keyboard: [] },
1093
+ }).catch(() => {});
1094
+ if (cq.data === "plan:yes") {
1095
+ runApprovedPlan(chatId, l).catch((e) => console.error("Plan approval error:", e.message));
1096
+ } else if (cq.data === "plan:no") {
1097
+ pendingPlans.delete(chatId);
1098
+ await send(chatId, t(l, "planCancelled"));
1099
+ }
1100
+ }
1101
+
944
1102
  async function handle(msg) {
945
1103
  const chatId = msg.chat?.id;
946
1104
  if (!chatId) return;
@@ -1126,6 +1284,22 @@ async function handle(msg) {
1126
1284
  return;
1127
1285
  }
1128
1286
 
1287
+ // 커스텀 명령어 (config.commands) — Claude와 독립 실행
1288
+ if (text.startsWith("/")) {
1289
+ const cmdName = text.slice(1).split(" ")[0];
1290
+ const def = (cfg.commands || {})[cmdName];
1291
+ if (def) {
1292
+ const run = typeof def === "object" ? def.run : null;
1293
+ if (run) {
1294
+ const args = text.length > cmdName.length + 1 ? text.slice(cmdName.length + 2) : "";
1295
+ const res = await runCustomCommand(run, args || undefined);
1296
+ const out = res.text.length > 4000 ? res.text.slice(0, 3990) + "\n…(truncated)" : res.text;
1297
+ await send(chatId, `${res.ok ? "" : "⚠️ "}${out}`);
1298
+ return;
1299
+ }
1300
+ }
1301
+ }
1302
+
1129
1303
  if (busy) {
1130
1304
  msgQueue.push({ msg, receivedAt: Date.now() });
1131
1305
  await send(chatId, t(l, "queued", msgQueue.length));
@@ -1148,6 +1322,32 @@ async function handle(msg) {
1148
1322
  );
1149
1323
 
1150
1324
  try {
1325
+ // /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
1326
+ // 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
1327
+ if (text === "/plan" || text.startsWith("/plan ")) {
1328
+ const planReq = text.slice(5).trim();
1329
+ if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
1330
+ prevSessionId = state.sessionId;
1331
+ const res = await runClaude(planReq, state.sessionId, { permissionMode: "plan", modelHint: true, trackChild: true, injectMemory: true });
1332
+ if (res.sessionId) {
1333
+ state.sessionId = res.sessionId;
1334
+ saveState(state);
1335
+ }
1336
+ if (!res.ok) {
1337
+ await send(chatId, `⚠️ ${res.text}`);
1338
+ return;
1339
+ }
1340
+ const messageId = await send(chatId, res.text, {
1341
+ replyMarkup: {
1342
+ inline_keyboard: [[
1343
+ { text: t(l, "planApprove"), callback_data: "plan:yes" },
1344
+ { text: t(l, "planCancel"), callback_data: "plan:no" },
1345
+ ]],
1346
+ },
1347
+ });
1348
+ pendingPlans.set(chatId, { sessionId: state.sessionId, messageId });
1349
+ return;
1350
+ }
1151
1351
  let prompt = text;
1152
1352
  if (msg._mediaGroup?.length) {
1153
1353
  const notes = [];
@@ -1187,44 +1387,7 @@ async function handle(msg) {
1187
1387
  state.sessionId = res.sessionId;
1188
1388
  saveState(state);
1189
1389
  }
1190
- const secs = Math.round((Date.now() - started) / 1000);
1191
- if (!res.ok) {
1192
- // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1193
- if (cfg.ollamaFallback && res.canFallback && !stopping) {
1194
- try {
1195
- const oRes = await runOllama(prompt, l);
1196
- if (oRes.ok) { await send(chatId, oRes.text); return; }
1197
- } catch {}
1198
- }
1199
- // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
1200
- let autoRetryMsg = "";
1201
- if (res.resetAt && !stopping) {
1202
- msgQueue.unshift({ msg, receivedAt: Date.now() });
1203
- rateLimitUntil = res.resetAt;
1204
- if (rateLimitTimer) clearTimeout(rateLimitTimer);
1205
- rateLimitTimer = setTimeout(() => {
1206
- rateLimitTimer = null;
1207
- rateLimitUntil = null;
1208
- if (msgQueue.length > 0) handle(drainQueue());
1209
- }, Math.max(res.resetAt - Date.now(), 1000));
1210
- const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1211
- autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1212
- }
1213
- const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1214
- if (!stopping) await send(chatId, errMsg);
1215
- } else {
1216
- const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1217
- if (!stopping) await send(chatId, res.text + footer);
1218
- // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1219
- const compactThreshold = cfg.autoCompactThreshold ?? 100000;
1220
- if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1221
- try {
1222
- const cr = await runClaude("/compact", state.sessionId);
1223
- if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
1224
- if (cr.ok !== false) await send(chatId, t(l, "autoCompact"));
1225
- } catch {}
1226
- }
1227
- }
1390
+ await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
1228
1391
  } catch (e) {
1229
1392
  if (!stopping) await send(chatId, t(l, "botError", e.message));
1230
1393
  } finally {
@@ -1284,9 +1447,14 @@ async function main() {
1284
1447
  }
1285
1448
  // 텔레그램 명령어 자동완성(/ 입력 시 뜨는 메뉴) 등록. 직접 파싱과 별개로 한 번 알려줘야 함.
1286
1449
  // 기본 목록(BOT_LANG) + 한국어 변형(language_code: ko) → ko 클라이언트는 한국어, 그 외 기본.
1287
- tg("setMyCommands", { commands: COMMANDS[BOT_LANG] || COMMANDS.en }).catch(() => {});
1450
+ // config.commands 정의된 커스텀 명령어도 목록에 추가.
1451
+ const customCmdEntries = Object.entries(cfg.commands || {}).map(([name, def]) => ({
1452
+ command: name,
1453
+ description: (typeof def === "object" ? (def.description || name) : name).slice(0, 256),
1454
+ }));
1455
+ tg("setMyCommands", { commands: [...(COMMANDS[BOT_LANG] || COMMANDS.en), ...customCmdEntries] }).catch(() => {});
1288
1456
  if (!FORCE_LANG) {
1289
- tg("setMyCommands", { commands: COMMANDS.ko, language_code: "ko" }).catch(() => {});
1457
+ tg("setMyCommands", { commands: [...COMMANDS.ko, ...customCmdEntries], language_code: "ko" }).catch(() => {});
1290
1458
  }
1291
1459
 
1292
1460
  // 시작 시 밀린 메시지 건너뛰기
@@ -1310,6 +1478,7 @@ async function main() {
1310
1478
  for (const upd of res.result) {
1311
1479
  offset = upd.update_id + 1;
1312
1480
  if (upd.message) dispatch(upd.message);
1481
+ else if (upd.callback_query) handleCallback(upd.callback_query).catch((e) => console.error("Callback error:", e.message));
1313
1482
  }
1314
1483
  } catch (e) {
1315
1484
  console.error("Polling error:", e.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.3.33",
3
+ "version": "0.3.36",
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": {