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