claude-telegram-bot 0.3.34 → 0.3.40

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 +237 -51
  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`이면 비활성화. 런타임에 `/autocompact`로 전환 가능(state에 저장) |
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`(모델 보기·전환) · `/autocompact`(자동 압축 임계값 보기·설정) · `/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에서 임계값을 조정하거나, 런타임에 `/autocompact <숫자>`로 설정(`/autocompact off`로 비활성화, `/autocompact default`로 초기화) — `state.json`에 저장돼 재시작 후에도 유지됩니다. 수동으로 `/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) · `/autocompact` (view / set the auto-compact token threshold) · `/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. Override at runtime with `/autocompact` (persists in state). |
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 at runtime with `/autocompact <number>` (`/autocompact off` to disable, `/autocompact default` to reset) — the override persists in `state.json` across restarts. 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,15 +135,17 @@ 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" +
148
+ "• /autocompact — view / set the auto-compact token threshold\n" +
147
149
  "• /id — show this chat ID\n" +
148
150
  `\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
149
151
  newSession: "🆕 Started a new conversation (previous context cleared).",
@@ -151,6 +153,11 @@ const STR = {
151
153
  compactFail: (m) => `⚠️ Compact failed: ${m}`,
152
154
  compactNoSession: "No active session to compact. Just send a message to start one.",
153
155
  autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
156
+ planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
157
+ planApprove: "✅ Proceed",
158
+ planCancel: "❌ Cancel",
159
+ planCancelled: "❌ Plan cancelled. No changes were made.",
160
+ planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
154
161
  testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
155
162
  testFallbackFail: (m) => `⚠️ Ollama test failed: ${m}`,
156
163
  ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
@@ -202,6 +209,13 @@ const STR = {
202
209
  `/model default — clear the override`,
203
210
  modelSet: (m) => `🧠 Model set to: ${m}`,
204
211
  modelReset: (def) => `🧠 Model reset to default (${def}).`,
212
+ autoCompactStatus: (cur, def) =>
213
+ `🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
214
+ "Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
215
+ autoCompactSet: (n) => `🗜️ Auto-compact threshold set to ${n} tokens.`,
216
+ autoCompactOff: "🗜️ Auto-compact disabled.",
217
+ autoCompactReset: (def) => `🗜️ Auto-compact threshold reset to default (${def}).`,
218
+ autoCompactUsage: "Usage: `/autocompact <number>` · `/autocompact off` · `/autocompact default`",
205
219
  memoryEmpty: "No memory yet. Use `/remember <text>` to add.",
206
220
  memoryShow: (m) => `💾 Memory:\n\`\`\`\n${m}\n\`\`\``,
207
221
  memoryCleared: "🧹 Memory cleared.",
@@ -221,15 +235,17 @@ const STR = {
221
235
  "• 그냥 메시지를 보내면 Claude가 프로젝트에서 작업합니다.\n" +
222
236
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
223
237
  "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
238
+ "• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
224
239
  "• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
225
240
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
226
241
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
227
242
  "• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
228
243
  "• /memory — 메모리 보기 · /memory clear 로 삭제\n" +
229
- "• /reserve — 한도 리셋 시 재시도 예약 · /reserve <다른 메시지> 로 내용 변경 · /reserve rm 으로 취소\n" +
244
+ "• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
230
245
  "• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
231
246
  "• /status — 봇 상태·버전 보기\n" +
232
247
  "• /model — 모델 보기·전환\n" +
248
+ "• /autocompact — 자동 압축 임계값 보기·설정\n" +
233
249
  "• /id — 이 채팅 ID 확인\n" +
234
250
  `\n작업 폴더: ${cfg.projectDir}\n권한 모드: ${cfg.permissionMode}`,
235
251
  newSession: "🆕 새 대화를 시작합니다 (이전 맥락 초기화).",
@@ -279,6 +295,13 @@ const STR = {
279
295
  `/model default — 오버라이드 해제`,
280
296
  modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
281
297
  modelReset: (def) => `🧠 모델을 기본값(${def})으로 되돌렸습니다.`,
298
+ autoCompactStatus: (cur, def) =>
299
+ `🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
300
+ "설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
301
+ autoCompactSet: (n) => `🗜️ 자동 압축 임계값을 ${n} 토큰으로 설정했습니다.`,
302
+ autoCompactOff: "🗜️ 자동 압축을 껐습니다.",
303
+ autoCompactReset: (def) => `🗜️ 자동 압축 임계값을 기본값(${def})으로 되돌렸습니다.`,
304
+ autoCompactUsage: "사용법: `/autocompact <숫자>` · `/autocompact off` · `/autocompact default`",
282
305
  memoryEmpty: "저장된 메모리가 없습니다. `/remember <내용>`으로 추가하세요.",
283
306
  memoryShow: (m) => `💾 메모리:\n\`\`\`\n${m}\n\`\`\``,
284
307
  memoryCleared: "🧹 메모리를 삭제했습니다.",
@@ -294,6 +317,11 @@ const STR = {
294
317
  compactFail: (m) => `⚠️ compact 실패: ${m}`,
295
318
  compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
296
319
  autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
320
+ planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
321
+ planApprove: "✅ 진행",
322
+ planCancel: "❌ 취소",
323
+ planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
324
+ planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
297
325
  contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
298
326
  testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
299
327
  testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
@@ -314,6 +342,7 @@ const COMMANDS = {
314
342
  en: [
315
343
  { command: "new", description: "Reset context (new session)" },
316
344
  { command: "compact", description: "Compress context to free up space (keeps session)" },
345
+ { command: "plan", description: "Plan only (no edits), then approve/cancel to run for real" },
317
346
  { command: "ollama", description: "Toggle Ollama chat mode (bypass Claude, use local LLM)" },
318
347
  { command: "stop", description: "Stop the current task (--reset to roll back session)" },
319
348
  { command: "remember", description: "Save to persistent memory (survives /new)" },
@@ -322,6 +351,7 @@ const COMMANDS = {
322
351
  { command: "restart", description: "Restart the bot (after syntax check)" },
323
352
  { command: "status", description: "Bot status / version" },
324
353
  { command: "model", description: "View / switch the model" },
354
+ { command: "autocompact", description: "View / set the auto-compact token threshold" },
325
355
  { command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
326
356
  { command: "id", description: "Show this chat ID" },
327
357
  { command: "help", description: "Help" },
@@ -329,6 +359,7 @@ const COMMANDS = {
329
359
  ko: [
330
360
  { command: "new", description: "대화 맥락 초기화 (새 세션)" },
331
361
  { command: "compact", description: "컨텍스트 압축 (세션 유지, 공간 확보)" },
362
+ { command: "plan", description: "계획만 세우기 (편집 없음) → 승인/취소로 실제 실행" },
332
363
  { command: "ollama", description: "Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM)" },
333
364
  { command: "stop", description: "작업 중단 (--reset 으로 세션 되돌리기)" },
334
365
  { command: "remember", description: "퍼시스턴트 메모리에 저장 (/new 후에도 유지)" },
@@ -337,6 +368,7 @@ const COMMANDS = {
337
368
  { command: "restart", description: "봇 재시작 (문법 검사 후)" },
338
369
  { command: "status", description: "봇 상태·버전 보기" },
339
370
  { command: "model", description: "모델 보기·전환" },
371
+ { command: "autocompact", description: "자동 압축 임계값 보기·설정" },
340
372
  { command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
341
373
  { command: "id", description: "이 채팅 ID 확인" },
342
374
  { command: "help", description: "도움말" },
@@ -512,19 +544,30 @@ function mdToTelegramHtml(md) {
512
544
  return out.join("\n").replace(/ CB(\d+) /g, (_, i) => codeBlocks[Number(i)]);
513
545
  }
514
546
 
515
- async function send(chatId, text) {
516
- for (const c of chunks(text)) {
517
- const r = await tg("sendMessage", {
547
+ // opts.replyMarkup: 인라인 키보드 — 여러 청크로 나뉘면 마지막 청크에만 붙음.
548
+ // 반환값: 마지막으로 보낸 메시지의 message_id (버튼 클릭 편집용, 실패 시 null).
549
+ async function send(chatId, text, opts = {}) {
550
+ const cs = [...chunks(text)];
551
+ let lastId = null;
552
+ for (let i = 0; i < cs.length; i++) {
553
+ const isLast = i === cs.length - 1;
554
+ const body = {
518
555
  chat_id: chatId,
519
- text: mdToTelegramHtml(c),
556
+ text: mdToTelegramHtml(cs[i]),
520
557
  parse_mode: "HTML",
521
558
  disable_web_page_preview: true,
522
- });
559
+ };
560
+ if (isLast && opts.replyMarkup) body.reply_markup = opts.replyMarkup;
561
+ let r = await tg("sendMessage", body);
523
562
  // If our HTML is malformed for some edge case, resend as plain text.
524
563
  if (!r || r.ok === false) {
525
- await tg("sendMessage", { chat_id: chatId, text: c, disable_web_page_preview: true });
564
+ const plain = { chat_id: chatId, text: cs[i], disable_web_page_preview: true };
565
+ if (isLast && opts.replyMarkup) plain.reply_markup = opts.replyMarkup;
566
+ r = await tg("sendMessage", plain);
526
567
  }
568
+ if (r?.ok) lastId = r.result?.message_id;
527
569
  }
570
+ return lastId;
528
571
  }
529
572
 
530
573
  // ── Claude 에러 분류 ──────────────────────────────────────────────────────
@@ -598,7 +641,7 @@ function runClaude(prompt, sessionId, opts = {}) {
598
641
  return new Promise((resolve) => {
599
642
  const args = [
600
643
  "--output-format", "json",
601
- "--permission-mode", cfg.permissionMode || "acceptEdits",
644
+ "--permission-mode", opts.permissionMode || cfg.permissionMode || "acceptEdits",
602
645
  ];
603
646
  const model = state.model || cfg.model; // /model 로 바꾸면 state.model 우선
604
647
  const brevity =
@@ -848,8 +891,8 @@ async function handleCron(chatId, rest, l) {
848
891
  return;
849
892
  }
850
893
  busy = true;
851
- await tg("sendChatAction", { chat_id: chatId, action: "typing" });
852
894
  try {
895
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
853
896
  const r = await extractCron(input, l);
854
897
  if (r.error) {
855
898
  await send(chatId, `⚠️ ${r.error}`);
@@ -960,8 +1003,120 @@ let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
960
1003
  let prevSessionId; // /stop --reset 복원 대상
961
1004
  let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
962
1005
  let rateLimitUntil = null; // 레이트 리밋 활성 시 리셋 Date — 이 시간까지 메시지를 큐에 쌓음
1006
+ const pendingPlans = new Map(); // chatId → { sessionId, messageId } — /plan 승인 대기
1007
+ const PLAN_PROCEED_PROMPT = "Proceed with the plan you just approved above. Implement it now.";
963
1008
  let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
964
1009
 
1010
+ // runClaude 결과를 답장으로 변환 — 폴백/큐잉/자동 컴팩션 처리. handle()과 /plan 승인 실행이 공유.
1011
+ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
1012
+ const secs = Math.round((Date.now() - started) / 1000);
1013
+ if (!res.ok) {
1014
+ // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1015
+ if (cfg.ollamaFallback && res.canFallback && !stopping) {
1016
+ try {
1017
+ const oRes = await runOllama(prompt, l);
1018
+ if (oRes.ok) { await send(chatId, oRes.text); return; }
1019
+ } catch {}
1020
+ }
1021
+ // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
1022
+ let autoRetryMsg = "";
1023
+ if (res.resetAt && !stopping) {
1024
+ msgQueue.unshift({ msg, receivedAt: Date.now() });
1025
+ rateLimitUntil = res.resetAt;
1026
+ if (rateLimitTimer) clearTimeout(rateLimitTimer);
1027
+ rateLimitTimer = setTimeout(() => {
1028
+ rateLimitTimer = null;
1029
+ rateLimitUntil = null;
1030
+ if (msgQueue.length > 0) handle(drainQueue());
1031
+ }, Math.max(res.resetAt - Date.now(), 1000));
1032
+ const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1033
+ autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1034
+ }
1035
+ const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1036
+ if (!stopping) await send(chatId, errMsg);
1037
+ } else {
1038
+ const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1039
+ if (!stopping) await send(chatId, res.text + footer);
1040
+ // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1041
+ const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
1042
+ if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1043
+ try {
1044
+ const cr = await runClaude("/compact", state.sessionId);
1045
+ if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
1046
+ if (cr.ok !== false) await send(chatId, t(l, "autoCompact"));
1047
+ } catch {}
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ // /plan 승인 후 실행 — busy 락·타이핑 표시를 handle()과 동일한 패턴으로 자체 처리.
1053
+ async function runApprovedPlan(chatId, l) {
1054
+ const pending = pendingPlans.get(chatId);
1055
+ pendingPlans.delete(chatId);
1056
+ // /new 등으로 세션이 바뀌었으면 이 계획은 더 이상 유효하지 않음
1057
+ if (!pending || pending.sessionId !== state.sessionId) {
1058
+ await send(chatId, t(l, "planNoPending"));
1059
+ return;
1060
+ }
1061
+ if (busy) {
1062
+ msgQueue.push({ msg: { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT }, receivedAt: Date.now() });
1063
+ await send(chatId, t(l, "queued", msgQueue.length));
1064
+ return;
1065
+ }
1066
+ if (checkLocalLock()) {
1067
+ await send(chatId, t(l, "localBusy"));
1068
+ return;
1069
+ }
1070
+ busy = true;
1071
+ const started = Date.now();
1072
+ currentTyping = setInterval(
1073
+ () => tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}),
1074
+ 5000,
1075
+ );
1076
+ const syntheticMsg = { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT };
1077
+ try {
1078
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1079
+ prevSessionId = state.sessionId;
1080
+ const res = await runClaude(PLAN_PROCEED_PROMPT, pending.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1081
+ if (res.sessionId) {
1082
+ state.sessionId = res.sessionId;
1083
+ saveState(state);
1084
+ }
1085
+ await replyWithClaudeResult(chatId, l, PLAN_PROCEED_PROMPT, syntheticMsg, res, started);
1086
+ } catch (e) {
1087
+ if (!stopping) await send(chatId, t(l, "botError", e.message));
1088
+ } finally {
1089
+ clearInterval(currentTyping);
1090
+ currentTyping = null;
1091
+ stopping = false;
1092
+ busy = false;
1093
+ if (msgQueue.length > 0 && !rateLimitUntil) setImmediate(() => handle(drainQueue()));
1094
+ }
1095
+ }
1096
+
1097
+ // 텔레그램 인라인 버튼(✅/❌) 클릭 처리
1098
+ async function handleCallback(cq) {
1099
+ const chatId = cq.message?.chat?.id;
1100
+ if (!chatId || !allowedIds.includes(String(chatId))) {
1101
+ await tg("answerCallbackQuery", { callback_query_id: cq.id }).catch(() => {});
1102
+ return;
1103
+ }
1104
+ const l = langOf({ from: cq.from });
1105
+ await tg("answerCallbackQuery", { callback_query_id: cq.id }).catch(() => {});
1106
+ // 중복 클릭 방지 — 원본 메시지의 버튼 제거
1107
+ tg("editMessageReplyMarkup", {
1108
+ chat_id: chatId,
1109
+ message_id: cq.message.message_id,
1110
+ reply_markup: { inline_keyboard: [] },
1111
+ }).catch(() => {});
1112
+ if (cq.data === "plan:yes") {
1113
+ runApprovedPlan(chatId, l).catch((e) => console.error("Plan approval error:", e.message));
1114
+ } else if (cq.data === "plan:no") {
1115
+ pendingPlans.delete(chatId);
1116
+ await send(chatId, t(l, "planCancelled"));
1117
+ }
1118
+ }
1119
+
965
1120
  async function handle(msg) {
966
1121
  const chatId = msg.chat?.id;
967
1122
  if (!chatId) return;
@@ -999,7 +1154,7 @@ async function handle(msg) {
999
1154
  }
1000
1155
  if (text === "/status") {
1001
1156
  const latest = await fetchLatestVersion();
1002
- const versionStr = !latest || latest === VERSION
1157
+ const versionStr = !latest || !isNewer(latest, VERSION)
1003
1158
  ? VERSION
1004
1159
  : `${VERSION} → ${latest} ✨`;
1005
1160
  await send(
@@ -1034,6 +1189,36 @@ async function handle(msg) {
1034
1189
  await send(chatId, t(l, "modelSet", arg));
1035
1190
  return;
1036
1191
  }
1192
+ if (text === "/autocompact" || text.startsWith("/autocompact ")) {
1193
+ const arg = text.slice(13).trim();
1194
+ const def = cfg.autoCompactThreshold ?? 100000;
1195
+ if (!arg) {
1196
+ const cur = state.autoCompactThreshold ?? def;
1197
+ await send(chatId, t(l, "autoCompactStatus", cur, def));
1198
+ return;
1199
+ }
1200
+ if (arg === "default" || arg === "reset") {
1201
+ state.autoCompactThreshold = undefined;
1202
+ saveState(state);
1203
+ await send(chatId, t(l, "autoCompactReset", def));
1204
+ return;
1205
+ }
1206
+ if (arg === "off") {
1207
+ state.autoCompactThreshold = 0;
1208
+ saveState(state);
1209
+ await send(chatId, t(l, "autoCompactOff"));
1210
+ return;
1211
+ }
1212
+ const n = Number(arg);
1213
+ if (!Number.isFinite(n) || n < 0) {
1214
+ await send(chatId, t(l, "autoCompactUsage"));
1215
+ return;
1216
+ }
1217
+ state.autoCompactThreshold = n;
1218
+ saveState(state);
1219
+ await send(chatId, t(l, "autoCompactSet", n));
1220
+ return;
1221
+ }
1037
1222
  if (text === "/cron" || text.startsWith("/cron ")) {
1038
1223
  await handleCron(chatId, text.slice(5).trim(), l);
1039
1224
  return;
@@ -1173,7 +1358,6 @@ async function handle(msg) {
1173
1358
  return;
1174
1359
  }
1175
1360
  busy = true;
1176
- await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1177
1361
  const started = Date.now();
1178
1362
  // 긴 작업 동안 타이핑 표시 유지
1179
1363
  currentTyping = setInterval(
@@ -1185,6 +1369,33 @@ async function handle(msg) {
1185
1369
  );
1186
1370
 
1187
1371
  try {
1372
+ await tg("sendChatAction", { chat_id: chatId, action: "typing" });
1373
+ // /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
1374
+ // 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
1375
+ if (text === "/plan" || text.startsWith("/plan ")) {
1376
+ const planReq = text.slice(5).trim();
1377
+ if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
1378
+ prevSessionId = state.sessionId;
1379
+ const res = await runClaude(planReq, state.sessionId, { permissionMode: "plan", modelHint: true, trackChild: true, injectMemory: true });
1380
+ if (res.sessionId) {
1381
+ state.sessionId = res.sessionId;
1382
+ saveState(state);
1383
+ }
1384
+ if (!res.ok) {
1385
+ await send(chatId, `⚠️ ${res.text}`);
1386
+ return;
1387
+ }
1388
+ const messageId = await send(chatId, res.text, {
1389
+ replyMarkup: {
1390
+ inline_keyboard: [[
1391
+ { text: t(l, "planApprove"), callback_data: "plan:yes" },
1392
+ { text: t(l, "planCancel"), callback_data: "plan:no" },
1393
+ ]],
1394
+ },
1395
+ });
1396
+ pendingPlans.set(chatId, { sessionId: state.sessionId, messageId });
1397
+ return;
1398
+ }
1188
1399
  let prompt = text;
1189
1400
  if (msg._mediaGroup?.length) {
1190
1401
  const notes = [];
@@ -1224,44 +1435,7 @@ async function handle(msg) {
1224
1435
  state.sessionId = res.sessionId;
1225
1436
  saveState(state);
1226
1437
  }
1227
- const secs = Math.round((Date.now() - started) / 1000);
1228
- if (!res.ok) {
1229
- // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1230
- if (cfg.ollamaFallback && res.canFallback && !stopping) {
1231
- try {
1232
- const oRes = await runOllama(prompt, l);
1233
- if (oRes.ok) { await send(chatId, oRes.text); return; }
1234
- } catch {}
1235
- }
1236
- // 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
1237
- let autoRetryMsg = "";
1238
- if (res.resetAt && !stopping) {
1239
- msgQueue.unshift({ msg, receivedAt: Date.now() });
1240
- rateLimitUntil = res.resetAt;
1241
- if (rateLimitTimer) clearTimeout(rateLimitTimer);
1242
- rateLimitTimer = setTimeout(() => {
1243
- rateLimitTimer = null;
1244
- rateLimitUntil = null;
1245
- if (msgQueue.length > 0) handle(drainQueue());
1246
- }, Math.max(res.resetAt - Date.now(), 1000));
1247
- const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1248
- autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1249
- }
1250
- const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1251
- if (!stopping) await send(chatId, errMsg);
1252
- } else {
1253
- const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1254
- if (!stopping) await send(chatId, res.text + footer);
1255
- // 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
1256
- const compactThreshold = cfg.autoCompactThreshold ?? 100000;
1257
- if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
1258
- try {
1259
- const cr = await runClaude("/compact", state.sessionId);
1260
- if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
1261
- if (cr.ok !== false) await send(chatId, t(l, "autoCompact"));
1262
- } catch {}
1263
- }
1264
- }
1438
+ await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
1265
1439
  } catch (e) {
1266
1440
  if (!stopping) await send(chatId, t(l, "botError", e.message));
1267
1441
  } finally {
@@ -1284,7 +1458,18 @@ function drainQueue() {
1284
1458
  return i === 0 ? `[1] ${text}` : `[${i + 1}, +${dt}s] ${text}`;
1285
1459
  })
1286
1460
  .join("\n");
1287
- return { ...group[group.length - 1].msg, text: merged, caption: undefined };
1461
+ // 마지막 메시지 필드만 남기면 앞서 메시지의 사진/첨부가 유실되므로, 전체 첨부를 순서대로 모아 둠
1462
+ const fileIds = group.flatMap((item) => {
1463
+ if (item.msg._mediaGroup?.length) return item.msg._mediaGroup;
1464
+ const att = pickAttachment(item.msg);
1465
+ return att ? [att.fileId] : [];
1466
+ });
1467
+ return {
1468
+ ...group[group.length - 1].msg,
1469
+ text: merged,
1470
+ caption: undefined,
1471
+ _mediaGroup: fileIds.length ? fileIds : undefined,
1472
+ };
1288
1473
  }
1289
1474
 
1290
1475
  // 미디어 그룹(여러 장 동시 전송) — 1초 대기 후 일괄 처리
@@ -1352,6 +1537,7 @@ async function main() {
1352
1537
  for (const upd of res.result) {
1353
1538
  offset = upd.update_id + 1;
1354
1539
  if (upd.message) dispatch(upd.message);
1540
+ else if (upd.callback_query) handleCallback(upd.callback_query).catch((e) => console.error("Callback error:", e.message));
1355
1541
  }
1356
1542
  } catch (e) {
1357
1543
  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.34",
3
+ "version": "0.3.40",
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": {