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.
- package/README.ko.md +28 -2
- package/README.md +33 -2
- package/bot.mjs +237 -51
- 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`(
|
|
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
|
-
- **한도 초과
|
|
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` (
|
|
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
|
|
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
|
|
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 — 한도 리셋 시
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|