claude-telegram-bot 0.4.0 → 0.4.2
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/CHANGELOG.md +22 -0
- package/README.ko.md +54 -3
- package/README.md +60 -3
- package/bot.mjs +59 -15
- package/ctb.mjs +27 -15
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.2] - 2026-07-14
|
|
4
|
+
### Added
|
|
5
|
+
- 그룹 채팅에서 메시지를 보낸 사람을 `[From: 이름 (@username)]` 형태로 프롬프트에 표시한다(1:1 채팅은
|
|
6
|
+
한 명뿐이라 표시하지 않음). `busy` 중 쌓인 메시지를 한 번에 합쳐 보내는 큐 병합 시에도 줄마다 발신자를
|
|
7
|
+
구분해 표기한다 — 이전엔 마지막 발신자만 남아 앞서 온 메시지들의 발신자 정보가 유실됐었다.
|
|
8
|
+
### Fixed
|
|
9
|
+
- `ctb`가 provider를 고를 때 `state.json`의 `provider`(텔레그램 `/provider`로 전환한 값)를 무시하고
|
|
10
|
+
`config.provider`만 보던 버그. 텔레그램에서 `/provider codex`로 전환해둔 상태에서 `ctb`를 실행하면
|
|
11
|
+
엉뚱하게 Claude 세션을 resume해 로컬 대화가 텔레그램 쪽 세션과 분리되던 문제였음 — 우선순위를
|
|
12
|
+
`--provider` → `state.provider` → `config.provider` → `claude`로 수정해 bot.mjs의 `currentProvider()`와
|
|
13
|
+
동일하게 맞췄다.
|
|
14
|
+
- `ctb` 세션 종료 알림(`notifyTelegram`)이 `allowedChatId`가 배열일 때 `chat_id`에 배열을 그대로 넣어
|
|
15
|
+
텔레그램 API가 `400 Bad Request`로 거부하던 버그 — 각 chat_id를 순회하며 개별 전송하도록 수정.
|
|
16
|
+
- 레이트리밋으로 메시지가 reserve 큐에 쌓인 상태에서 `/provider`로 다른 provider로 전환해도 큐가 그대로
|
|
17
|
+
묶여 있던 버그 — 전환 시 예약을 즉시 풀고 새 provider로 큐를 이어서 처리하도록 수정.
|
|
18
|
+
|
|
19
|
+
## [0.4.1] - 2026-07-10
|
|
20
|
+
### Changed
|
|
21
|
+
- `ctb`의 provider 결정 우선순위(`--provider` → `config.provider` → `claude`)를 도움말에 명시했다.
|
|
22
|
+
- `/model` 상태·설정 안내를 provider별로 분리했다. Claude에서는 모델 별칭을 제안하고, Codex에서는
|
|
23
|
+
Claude 별칭을 노출하지 않고 전체 Codex 모델 ID와 `codexModel`/CLI 기본값 사용법을 안내한다.
|
|
24
|
+
|
|
3
25
|
## [0.4.0] - 2026-07-10
|
|
4
26
|
### Added
|
|
5
27
|
- `config.json`의 `provider`로 Claude 또는 Codex를 텔레그램 봇의 메인 실행자로 선택할 수 있다.
|
package/README.ko.md
CHANGED
|
@@ -21,6 +21,47 @@
|
|
|
21
21
|
Claude 한도 → Codex 폴백 → codex-handoff.md → 다음 Claude 호출
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
> ### ⚠️ 의도적으로 원격 코드 실행 도구입니다
|
|
25
|
+
> 텔레그램 메시지가 호스트 머신의 코드 실행으로 이어질 수 있습니다. 처음에는
|
|
26
|
+
> `permissionMode: acceptEdits`로 시작하고, 반드시 `allowedChatId`를 설정하세요. 상시 실행 전에
|
|
27
|
+
> [보안](#보안)을 읽어주세요.
|
|
28
|
+
|
|
29
|
+
## 3분 빠른 시작
|
|
30
|
+
|
|
31
|
+
필요한 것: **Node.js 18+**, 설치·로그인된 **Claude Code `claude` CLI**, `@BotFather`에서 받은 텔레그램 봇 토큰.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm i -g claude-telegram-bot
|
|
35
|
+
claude-telegram-bot init ~/botconfigs/my-project
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`~/botconfigs/my-project/mybot.json`을 수정합니다.
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"token": "BOT_TOKEN_FROM_BOTFATHER",
|
|
43
|
+
"allowedChatId": "",
|
|
44
|
+
"projectDir": "/ABSOLUTE/PATH/TO/PROJECT",
|
|
45
|
+
"claudeBin": "/ABSOLUTE/PATH/TO/claude",
|
|
46
|
+
"permissionMode": "acceptEdits"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
처음 한 번 실행해서 chat ID를 확인합니다.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
claude-telegram-bot ~/botconfigs/my-project/mybot.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
텔레그램 봇에 아무 메시지나 보내면 봇이 `chatId`를 알려줍니다. 그 값을 `allowedChatId`에 넣고 재시작한 뒤, 이렇게 요청해보세요.
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
테스트 돌려보고 실패한 부분 요약해줘
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
설치 없이 시험하려면 `npx claude-telegram-bot init`, `npx claude-telegram-bot`을 사용하면 됩니다.
|
|
63
|
+
Claude 대신(또는 함께) Codex를 쓰려면 [설정](#설정)을 참고하세요.
|
|
64
|
+
|
|
24
65
|
## 왜 만들었나
|
|
25
66
|
|
|
26
67
|
자리를 비운 사이에도 폰으로 빌드를 돌려보거나 간단한 수정을 맡기고 싶을 때가 있습니다. 그렇다고 외부에서 데스크톱에 원격 접속해서 터미널을 여는 건 번거롭죠.
|
|
@@ -137,7 +178,7 @@ CLI를 업데이트한 뒤에는 무인 실행에 맡기기 전에 `/testfallbac
|
|
|
137
178
|
| `commands` | (선택) 쉘 스크립트를 실행하는 커스텀 `/명령어` — [커스텀 명령어](#커스텀-명령어) 참고 |
|
|
138
179
|
| `codexFallback` | (선택) `true`로 설정하면 Claude 레이트 리밋·크레딧 부족 시 Codex를 우선 폴백으로 사용 |
|
|
139
180
|
| `codexBin` | (선택) `codex` 실행 파일 경로. 기본값은 `PATH`의 `"codex"`이며 launchd에서는 절대경로 권장 |
|
|
140
|
-
| `codexModel` | (선택) Codex에 `--model`로 넘길
|
|
181
|
+
| `codexModel` | (선택) Codex에 `--model`로 넘길 모델. Codex 활성 상태에서 `/model <전체-ID>`로 변경 가능 |
|
|
141
182
|
| `codexSandbox` | (선택) 첫 `codex exec` 세션의 샌드박스 (기본값: `"workspace-write"`) |
|
|
142
183
|
| `codexTimeout` | (선택) Codex 응답을 기다리는 최대 시간(ms). 실패하면 reserve/Ollama로 넘어감 (기본값: `600000`) |
|
|
143
184
|
| `ollamaFallback` | (선택) `true`로 설정하면 Claude 레이트 리밋·크레딧 부족 시 로컬 Ollama를 보조 폴백으로 사용 |
|
|
@@ -176,7 +217,7 @@ Claude는 `state.sessionId`, Codex는 `state.codexSessionId`를 읽어 `codex re
|
|
|
176
217
|
| 명령어 | 기능 |
|
|
177
218
|
|---|---|
|
|
178
219
|
| `/provider [claude\|codex\|default]` | 텔레그램 봇 provider 확인·전환 |
|
|
179
|
-
| `/model [이름\|default]` | 활성 provider 모델
|
|
220
|
+
| `/model [이름\|default]` | 활성 provider 모델 확인·전환, provider별 안내 표시 |
|
|
180
221
|
| `/new` | 활성 provider의 대화 세션 초기화 |
|
|
181
222
|
| `/plan <요청>` | 계획 작성 후 승인 대기 (Claude 전용) |
|
|
182
223
|
| `/compact` | 현재 컨텍스트 압축 (Claude 전용) |
|
|
@@ -197,7 +238,7 @@ Claude는 `state.sessionId`, Codex는 `state.codexSessionId`를 읽어 `codex re
|
|
|
197
238
|
|
|
198
239
|
- **세션 유지** — Claude와 Codex의 세션 ID를 따로 저장합니다. `/provider`로 전환해도 각각의 맥락이 유지되며 `/new`는 활성 provider 세션만 초기화합니다.
|
|
199
240
|
- **메시지 큐** — 작업 중에 새 메시지가 오면 버리지 않고 큐에 쌓아둡니다. 작업이 끝나면 대기 중인 메시지를 모두 하나의 프롬프트로 합쳐서 처리합니다(예: "A 해줘" → "아니다 B 해줘"를 한 번에 처리). `/stop`으로 실행 중인 작업과 큐를 동시에 취소할 수 있습니다.
|
|
200
|
-
- **모델
|
|
241
|
+
- **모델 설정** — `/model`은 활성 provider를 따르며 Claude와 Codex override를 별도로 저장합니다. Claude에서는 `haiku`, `sonnet`, `opus`, `fable` 별칭을 안내하고, Codex에서는 Claude 별칭 대신 전체 Codex 모델 ID 입력을 안내합니다. `/model default`는 활성 provider의 override만 해제합니다.
|
|
201
242
|
- **한도 초과 큐** — Claude Max / API 레이트 리밋 에러에 리셋 시간이 포함되면, 먼저 활성화된 폴백을 시도합니다. 폴백이 없거나 모두 실패할 때만 해당 메시지를 큐에 넣고 리셋 시각에 재시도합니다 — 작업 중 큐와 같은 방식입니다. 한도가 걸린 동안 추가로 보내는 메시지도 자동으로 큐에 쌓입니다. `/reserve`로 대기 현황과 리셋 시각 확인, `/reserve rm`으로 큐 전체 취소.
|
|
202
243
|
- **Codex 폴백** — `"codexFallback": true`로 설정하면 Claude 레이트 리밋·크레딧 부족 시 `codex exec`가 대신 실행됩니다. Codex는 `state.codexSessionId`에 별도 세션을 저장하고 `codex exec resume <id>`로 이어가지만, Claude 세션과 Codex 세션은 서로 호환되지 않습니다. 대신 성공한 Codex 폴백마다 `.claude-bot/codex-handoff.md`에 요약을 남기고, 이후 Claude 호출 때 최근 handoff 내용을 맥락으로 주입합니다.
|
|
203
244
|
- **실행 중 provider 전환** — `/provider`로 확인하고, `/provider claude` 또는 `/provider codex`로 봇 state override를 저장할 수 있습니다. `/provider default`는 config 값으로 되돌립니다.
|
|
@@ -360,6 +401,16 @@ Claude와 Codex의 안전 설정은 서로 다르며 `/provider` 전환 시 실
|
|
|
360
401
|
|
|
361
402
|
보안 이슈는 공개로 올리기보다 GitHub 이슈(민감한 내용은 메인테이너에게 비공개)로 알려주세요.
|
|
362
403
|
|
|
404
|
+
## 개발
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
npm test
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
smoke test는 CLI 파일에 `node --check`를 실행하고, 두 바이너리가 버전을 출력하는지 확인합니다. CI는 Node 18, 20, 22에서 같은 검사를 실행합니다.
|
|
411
|
+
|
|
412
|
+
최근 변경 사항은 [CHANGELOG.md](./CHANGELOG.md)를 참고하세요.
|
|
413
|
+
|
|
363
414
|
## 라이선스
|
|
364
415
|
|
|
365
416
|
MIT © Jongtaek Choi
|
package/README.md
CHANGED
|
@@ -32,6 +32,52 @@ It runs as a **background daemon** (launchd), so there's no interactive session
|
|
|
32
32
|
> A message you send from Telegram is executed as a command on the machine running the bot.
|
|
33
33
|
> With `permissionMode: bypassPermissions`, a one-line message can run **anything** as your user.
|
|
34
34
|
|
|
35
|
+
## 3-minute quick start
|
|
36
|
+
|
|
37
|
+
Prerequisites: **Node.js 18+**, the **Claude Code `claude` CLI installed and authenticated**, and a Telegram bot token from `@BotFather`.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm i -g claude-telegram-bot
|
|
41
|
+
claude-telegram-bot init ~/botconfigs/my-project
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Edit `~/botconfigs/my-project/mybot.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"token": "BOT_TOKEN_FROM_BOTFATHER",
|
|
49
|
+
"allowedChatId": "",
|
|
50
|
+
"projectDir": "/ABSOLUTE/PATH/TO/PROJECT",
|
|
51
|
+
"claudeBin": "/ABSOLUTE/PATH/TO/claude",
|
|
52
|
+
"permissionMode": "acceptEdits"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Start once to discover your chat ID:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
claude-telegram-bot ~/botconfigs/my-project/mybot.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Send any message to the Telegram bot. It replies with your `chatId`. Put that value into `allowedChatId`, restart the bot, then send something useful:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
run the tests and summarize any failures
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For a no-install trial, use `npx claude-telegram-bot init` and `npx claude-telegram-bot` instead. To
|
|
69
|
+
run Codex instead of (or alongside) Claude, see [Configuration](#configuration).
|
|
70
|
+
|
|
71
|
+
## Why this exists
|
|
72
|
+
|
|
73
|
+
Sometimes you are away from your desk but still want to ask a coding agent to inspect a repo, run
|
|
74
|
+
tests, make a small edit, or prepare a commit. Remote desktop and SSH are heavy for that; a Telegram
|
|
75
|
+
chat is enough.
|
|
76
|
+
|
|
77
|
+
This project is intentionally small: a CLI/daemon that reuses the `claude` and/or `codex` CLI already
|
|
78
|
+
authenticated on your machine. It is best for a Mac mini, home server, dev box, or personal VPS that
|
|
79
|
+
you already trust.
|
|
80
|
+
|
|
35
81
|
**Highlights**
|
|
36
82
|
|
|
37
83
|
- **Zero dependencies** — just Node 18+. No npm install, no supply chain.
|
|
@@ -186,7 +232,7 @@ Core commands:
|
|
|
186
232
|
| Command | Purpose |
|
|
187
233
|
|---|---|
|
|
188
234
|
| `/provider [claude\|codex\|default]` | View or switch the Telegram bot's provider override |
|
|
189
|
-
| `/model [name\|default]` | View or switch the active provider's model |
|
|
235
|
+
| `/model [name\|default]` | View or switch the active provider's model; suggestions are provider-specific |
|
|
190
236
|
| `/new` | Reset the active provider's conversation session |
|
|
191
237
|
| `/plan <request>` | Produce a plan and wait for approval (Claude only) |
|
|
192
238
|
| `/compact` | Compact the current context (Claude only) |
|
|
@@ -264,7 +310,7 @@ Edit `mybot.json`:
|
|
|
264
310
|
| `commands` | (optional) Custom `/commands` that run shell scripts — see [Custom commands](#custom-commands) |
|
|
265
311
|
| `codexFallback` | (optional) `true` to enable Codex as the preferred fallback when Claude is rate-limited or out of credits |
|
|
266
312
|
| `codexBin` | (optional) Path to the `codex` binary. Defaults to `"codex"` on `PATH`; use an absolute path for launchd |
|
|
267
|
-
| `codexModel` | (optional) Codex model
|
|
313
|
+
| `codexModel` | (optional) Codex model passed with `--model`; override while Codex is active with `/model <full-id>` |
|
|
268
314
|
| `codexSandbox` | (optional) Codex sandbox for a new `codex exec` session (default: `"workspace-write"`) |
|
|
269
315
|
| `codexTimeout` | (optional) Milliseconds to wait for Codex before falling back to reserve/Ollama (default: `600000`) |
|
|
270
316
|
| `ollamaFallback` | (optional) `true` to enable Ollama as a secondary fallback when Claude is rate-limited or out of credits |
|
|
@@ -309,7 +355,7 @@ your launchd plist points them.
|
|
|
309
355
|
- **Sessions**: Claude and Codex keep separate session IDs, so switching providers preserves both
|
|
310
356
|
conversations. `/new` resets only the active provider's session.
|
|
311
357
|
- **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.
|
|
312
|
-
- **
|
|
358
|
+
- **Models**: `/model` follows the active provider and stores separate Claude/Codex overrides. Claude shows the `haiku`, `sonnet`, `opus`, and `fable` aliases; Codex asks for a full Codex model ID instead of displaying Claude aliases. `/model default` clears only the active provider's override.
|
|
313
359
|
- **Usage-limit queue**: when a Claude Max / API rate-limit error includes a reset time, the bot first tries enabled fallbacks. If no fallback is enabled or every fallback fails, the triggering message is 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.
|
|
314
360
|
- **Codex fallback**: set `"codexFallback": true` to run `codex exec` when Claude is rate-limited or out of credits. Codex keeps its own session in `state.codexSessionId` using `codex exec resume <id>`, but Claude and Codex sessions are not interoperable. Each successful Codex fallback appends a summary to `.claude-bot/codex-handoff.md`, and future Claude calls receive the recent handoff notes as context.
|
|
315
361
|
- **Runtime provider switching**: use `/provider` to view the active provider, `/provider claude` or `/provider codex` to store a bot-state override, and `/provider default` to return to the config value. Each provider's session is preserved separately.
|
|
@@ -511,6 +557,17 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claudebot.example.pl
|
|
|
511
557
|
- At least one provider: authenticated Claude CLI or authenticated Codex CLI
|
|
512
558
|
- Optional: Codex CLI for Codex fallback; Ollama and a downloaded model for Ollama mode/fallback
|
|
513
559
|
|
|
560
|
+
## Development
|
|
561
|
+
|
|
562
|
+
```sh
|
|
563
|
+
npm test
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
The smoke test runs `node --check` on the CLI files and verifies both binaries can print their
|
|
567
|
+
version. CI runs the same checks on Node 18, 20, and 22.
|
|
568
|
+
|
|
569
|
+
See [CHANGELOG.md](./CHANGELOG.md) for recent changes.
|
|
570
|
+
|
|
514
571
|
## License
|
|
515
572
|
|
|
516
573
|
MIT © Jongtaek Choi
|
package/bot.mjs
CHANGED
|
@@ -217,12 +217,16 @@ const STR = {
|
|
|
217
217
|
`• Scheduled jobs: ${i.jobs}\n` +
|
|
218
218
|
`• Project: ${i.projectDir}\n` +
|
|
219
219
|
`• Permission: ${i.permissionMode}`,
|
|
220
|
-
|
|
221
|
-
`🧠
|
|
220
|
+
claudeModelStatus: (cur, list) =>
|
|
221
|
+
`🧠 Claude model: ${cur}\n` +
|
|
222
222
|
`Switch: ${list.map((x) => `/model ${x}`).join(" · ")} (or a full model id)\n` +
|
|
223
223
|
`/model default — clear the override`,
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
codexModelStatus: (cur) =>
|
|
225
|
+
`🧠 Codex model: ${cur}\n` +
|
|
226
|
+
"Set: `/model <full-codex-model-id>`\n" +
|
|
227
|
+
`/model default — clear the override and use codexModel/CLI default`,
|
|
228
|
+
modelSet: (provider, m) => `🧠 ${provider} model set to: ${m}`,
|
|
229
|
+
modelReset: (provider, def) => `🧠 ${provider} model reset to default (${def}).`,
|
|
226
230
|
providerStatus: (cur, def) => `🤖 Provider: ${cur}${cur === def ? " (config default)" : ` (config default: ${def})`}\nSwitch: /provider claude · /provider codex · /provider default`,
|
|
227
231
|
providerSet: (provider) => `🤖 Default provider set to ${provider}. Existing Claude and Codex sessions are preserved separately.`,
|
|
228
232
|
providerReset: (provider) => `🤖 Provider reset to the config default (${provider}).`,
|
|
@@ -312,12 +316,16 @@ const STR = {
|
|
|
312
316
|
`• 예약 작업: ${i.jobs}개\n` +
|
|
313
317
|
`• 작업 폴더: ${i.projectDir}\n` +
|
|
314
318
|
`• 권한 모드: ${i.permissionMode}`,
|
|
315
|
-
|
|
316
|
-
`🧠 현재 모델: ${cur}\n` +
|
|
319
|
+
claudeModelStatus: (cur, list) =>
|
|
320
|
+
`🧠 현재 Claude 모델: ${cur}\n` +
|
|
317
321
|
`전환: ${list.map((x) => `/model ${x}`).join(" · ")} (또는 전체 모델 ID)\n` +
|
|
318
322
|
`/model default — 오버라이드 해제`,
|
|
319
|
-
|
|
320
|
-
|
|
323
|
+
codexModelStatus: (cur) =>
|
|
324
|
+
`🧠 현재 Codex 모델: ${cur}\n` +
|
|
325
|
+
"설정: `/model <Codex 전체 모델 ID>`\n" +
|
|
326
|
+
`/model default — 오버라이드를 해제하고 codexModel/CLI 기본값 사용`,
|
|
327
|
+
modelSet: (provider, m) => `🧠 ${provider} 모델을 ${m}(으)로 설정했습니다.`,
|
|
328
|
+
modelReset: (provider, def) => `🧠 ${provider} 모델을 기본값(${def})으로 되돌렸습니다.`,
|
|
321
329
|
providerStatus: (cur, def) => `🤖 현재 provider: ${cur}${cur === def ? " (config 기본값)" : ` (config 기본값: ${def})`}\n전환: /provider claude · /provider codex · /provider default`,
|
|
322
330
|
providerSet: (provider) => `🤖 기본 provider를 ${provider}(으)로 변경했습니다. Claude와 Codex의 기존 세션은 각각 유지됩니다.`,
|
|
323
331
|
providerReset: (provider) => `🤖 provider를 config 기본값(${provider})으로 되돌렸습니다.`,
|
|
@@ -365,7 +373,7 @@ const t = (l, key, ...a) => {
|
|
|
365
373
|
};
|
|
366
374
|
|
|
367
375
|
// /model 에서 보여줄 추천 별칭(claude CLI 가 별칭·전체 모델 ID 모두 허용).
|
|
368
|
-
const
|
|
376
|
+
const CLAUDE_MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
|
|
369
377
|
|
|
370
378
|
// /(슬래시) 자동완성 메뉴용 명령 목록 (언어별). setMyCommands 로 등록.
|
|
371
379
|
const COMMANDS = {
|
|
@@ -1217,9 +1225,23 @@ async function downloadAttachment(att) {
|
|
|
1217
1225
|
}
|
|
1218
1226
|
|
|
1219
1227
|
// ── 텔레그램 메시지 메타데이터 추출 ──────────────────────────────────────
|
|
1228
|
+
// 그룹 채팅에서 누가 보냈는지 표시할 때 쓰는 이름 포맷 (buildMsgMeta / drainQueue 공용)
|
|
1229
|
+
function formatSender(u) {
|
|
1230
|
+
if (!u) return null;
|
|
1231
|
+
if (u.first_name) return `${u.first_name}${u.username ? ` (@${u.username})` : ""}`;
|
|
1232
|
+
return u.username ? `@${u.username}` : null;
|
|
1233
|
+
}
|
|
1234
|
+
const isGroupChat = (chat) => chat?.type === "group" || chat?.type === "supergroup";
|
|
1235
|
+
|
|
1220
1236
|
function buildMsgMeta(msg) {
|
|
1221
1237
|
const parts = [];
|
|
1222
1238
|
|
|
1239
|
+
// 그룹 채팅은 발신자가 여러 명일 수 있으니 표시 (1:1은 한 명뿐이라 생략, 큐 병합 메시지는 줄마다 이미 표기)
|
|
1240
|
+
if (isGroupChat(msg.chat) && !msg._merged) {
|
|
1241
|
+
const sender = formatSender(msg.from);
|
|
1242
|
+
if (sender) parts.push(`[From: ${sender}]`);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1223
1245
|
// 포워드 출처 (신규 API: forward_origin, 구버전 폴백: forward_from / forward_from_chat)
|
|
1224
1246
|
const fo = msg.forward_origin;
|
|
1225
1247
|
if (fo) {
|
|
@@ -1267,6 +1289,16 @@ const pendingPlans = new Map(); // chatId → { sessionId, messageId } — /plan
|
|
|
1267
1289
|
const PLAN_PROCEED_PROMPT = "Proceed with the plan you just approved above. Implement it now.";
|
|
1268
1290
|
let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
|
|
1269
1291
|
|
|
1292
|
+
// 한도에 걸린 provider에서 다른 provider로 전환하면 예약을 기다릴 이유가 없다.
|
|
1293
|
+
// 기존 실패 메시지를 포함한 대기열을 새 provider로 즉시 이어서 처리한다.
|
|
1294
|
+
function resumeQueueAfterProviderSwitch(previousProvider) {
|
|
1295
|
+
if (previousProvider === currentProvider() || (!rateLimitUntil && !rateLimitTimer)) return;
|
|
1296
|
+
if (rateLimitTimer) clearTimeout(rateLimitTimer);
|
|
1297
|
+
rateLimitTimer = null;
|
|
1298
|
+
rateLimitUntil = null;
|
|
1299
|
+
if (msgQueue.length > 0) setImmediate(() => handle(drainQueue()));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1270
1302
|
// runClaude 결과를 답장으로 변환 — 폴백/큐잉/자동 컴팩션 처리. handle()과 /plan 승인 실행이 공유.
|
|
1271
1303
|
async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
1272
1304
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
@@ -1471,39 +1503,46 @@ async function handle(msg) {
|
|
|
1471
1503
|
return;
|
|
1472
1504
|
}
|
|
1473
1505
|
if (arg === "default" || arg === "reset") {
|
|
1506
|
+
const previousProvider = currentProvider();
|
|
1474
1507
|
state.provider = undefined;
|
|
1475
1508
|
saveState(state);
|
|
1476
1509
|
await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
|
|
1510
|
+
resumeQueueAfterProviderSwitch(previousProvider);
|
|
1477
1511
|
return;
|
|
1478
1512
|
}
|
|
1479
1513
|
if (!["claude", "codex"].includes(arg)) {
|
|
1480
1514
|
await send(chatId, t(l, "providerUsage"));
|
|
1481
1515
|
return;
|
|
1482
1516
|
}
|
|
1517
|
+
const previousProvider = currentProvider();
|
|
1483
1518
|
state.provider = arg;
|
|
1484
1519
|
state.ollamaMode = false;
|
|
1485
1520
|
saveState(state);
|
|
1486
1521
|
await send(chatId, t(l, "providerSet", arg));
|
|
1522
|
+
resumeQueueAfterProviderSwitch(previousProvider);
|
|
1487
1523
|
return;
|
|
1488
1524
|
}
|
|
1489
1525
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1490
1526
|
const arg = text.slice(6).trim();
|
|
1491
|
-
const
|
|
1492
|
-
const
|
|
1527
|
+
const provider = currentProvider();
|
|
1528
|
+
const modelStateKey = provider === "codex" ? "codexModel" : "model";
|
|
1529
|
+
const configuredModel = provider === "codex" ? cfg.codexModel : cfg.model;
|
|
1493
1530
|
if (!arg) {
|
|
1494
1531
|
const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
|
|
1495
|
-
await send(chatId,
|
|
1532
|
+
await send(chatId, provider === "codex"
|
|
1533
|
+
? t(l, "codexModelStatus", cur)
|
|
1534
|
+
: t(l, "claudeModelStatus", cur, CLAUDE_MODEL_SUGGESTIONS));
|
|
1496
1535
|
return;
|
|
1497
1536
|
}
|
|
1498
1537
|
if (arg === "default" || arg === "reset") {
|
|
1499
1538
|
state[modelStateKey] = undefined;
|
|
1500
1539
|
saveState(state);
|
|
1501
|
-
await send(chatId, t(l, "modelReset", configuredModel || (l === "ko" ? "기본값" : "default")));
|
|
1540
|
+
await send(chatId, t(l, "modelReset", provider, configuredModel || (l === "ko" ? "기본값" : "default")));
|
|
1502
1541
|
return;
|
|
1503
1542
|
}
|
|
1504
1543
|
state[modelStateKey] = arg;
|
|
1505
1544
|
saveState(state);
|
|
1506
|
-
await send(chatId, t(l, "modelSet", arg));
|
|
1545
|
+
await send(chatId, t(l, "modelSet", provider, arg));
|
|
1507
1546
|
return;
|
|
1508
1547
|
}
|
|
1509
1548
|
if (text === "/autocompact" || text.startsWith("/autocompact ")) {
|
|
@@ -1790,11 +1829,14 @@ async function handle(msg) {
|
|
|
1790
1829
|
function drainQueue() {
|
|
1791
1830
|
if (msgQueue.length === 1) return msgQueue.shift().msg;
|
|
1792
1831
|
const group = msgQueue.splice(0);
|
|
1832
|
+
const groupChat = isGroupChat(group[0].msg.chat);
|
|
1793
1833
|
const merged = group
|
|
1794
1834
|
.map((item, i) => {
|
|
1795
1835
|
const text = item.msg.text || item.msg.caption || "";
|
|
1796
1836
|
const dt = Math.round((item.receivedAt - group[0].receivedAt) / 1000);
|
|
1797
|
-
|
|
1837
|
+
const label = i === 0 ? "[1]" : `[${i + 1}, +${dt}s]`;
|
|
1838
|
+
const sender = groupChat ? formatSender(item.msg.from) : null;
|
|
1839
|
+
return sender ? `${label} ${sender}: ${text}` : `${label} ${text}`;
|
|
1798
1840
|
})
|
|
1799
1841
|
.join("\n");
|
|
1800
1842
|
// 마지막 메시지 필드만 남기면 앞서 온 메시지의 사진/첨부가 유실되므로, 전체 첨부를 순서대로 모아 둠
|
|
@@ -1807,6 +1849,8 @@ function drainQueue() {
|
|
|
1807
1849
|
...group[group.length - 1].msg,
|
|
1808
1850
|
text: merged,
|
|
1809
1851
|
caption: undefined,
|
|
1852
|
+
// 줄마다 발신자를 이미 표기했으니 buildMsgMeta의 단일 [From: ] 태그(마지막 발신자 기준)는 중복이라 생략
|
|
1853
|
+
_merged: groupChat || undefined,
|
|
1810
1854
|
_mediaGroup: fileIds.length ? fileIds : undefined,
|
|
1811
1855
|
};
|
|
1812
1856
|
}
|
package/ctb.mjs
CHANGED
|
@@ -74,9 +74,10 @@ async function main() {
|
|
|
74
74
|
` ctb --help | --version\n\n` +
|
|
75
75
|
`config.json defaults to $BOT_CONFIG or the package's own config.json.\n` +
|
|
76
76
|
`A bare name like "planner.json" resolves relative to the package directory.\n\n` +
|
|
77
|
+
`Provider precedence: --provider flag → config.provider → claude.\n\n` +
|
|
77
78
|
`Examples:\n` +
|
|
78
|
-
` ctb Interactive
|
|
79
|
-
` ctb -p "what did we do?" Headless
|
|
79
|
+
` ctb Interactive configured provider, continuing its session\n` +
|
|
80
|
+
` ctb -p "what did we do?" Headless configured provider with session context\n` +
|
|
80
81
|
` ctb planner.json Resume planner persona session interactively\n` +
|
|
81
82
|
` ctb planner.json -p "..." Headless with planner session\n` +
|
|
82
83
|
` ctb planner.json --provider codex Interactive Codex with its Telegram session\n` +
|
|
@@ -119,11 +120,6 @@ async function main() {
|
|
|
119
120
|
forwardedArgs.push(arg);
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
|
-
const provider = providerOverride || cfg.provider || "claude";
|
|
123
|
-
if (!["claude", "codex"].includes(provider)) {
|
|
124
|
-
throw new Error(`Unsupported provider: ${provider} (expected claude or codex)`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
123
|
const dataDir = dirname(configPath);
|
|
128
124
|
const botDir = join(dataDir, ".claude-bot");
|
|
129
125
|
const stateBase = basename(configPath, ".json");
|
|
@@ -131,6 +127,18 @@ async function main() {
|
|
|
131
127
|
const statePath = join(botDir, stateFile);
|
|
132
128
|
const lockPath = join(botDir, "local.lock");
|
|
133
129
|
|
|
130
|
+
// 봇이 텔레그램에서 /provider 로 전환했을 수 있으니 state.json 의 provider 를
|
|
131
|
+
// cfg.provider 보다 우선한다 (bot.mjs의 currentProvider()와 동일한 우선순위).
|
|
132
|
+
let stateProvider;
|
|
133
|
+
try {
|
|
134
|
+
const p = JSON.parse(readFileSync(statePath, "utf8")).provider;
|
|
135
|
+
if (["claude", "codex"].includes(p)) stateProvider = p;
|
|
136
|
+
} catch {}
|
|
137
|
+
const provider = providerOverride || stateProvider || cfg.provider || "claude";
|
|
138
|
+
if (!["claude", "codex"].includes(provider)) {
|
|
139
|
+
throw new Error(`Unsupported provider: ${provider} (expected claude or codex)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
134
142
|
mkdirSync(botDir, { recursive: true });
|
|
135
143
|
writeFileSync(lockPath, String(process.pid));
|
|
136
144
|
const cleanup = () => { try { unlinkSync(lockPath); } catch {} };
|
|
@@ -227,20 +235,24 @@ async function summarizeSession(provider, sid, lang, cfg) {
|
|
|
227
235
|
async function notifyTelegram(configPath, provider, sessionId) {
|
|
228
236
|
try {
|
|
229
237
|
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
|
230
|
-
|
|
238
|
+
// allowedChatId 는 문자열 또는 배열 모두 허용 (bot.mjs의 allowedIds와 동일 규칙).
|
|
239
|
+
const chatIds = [].concat(cfg.allowedChatId).filter(Boolean).map(String);
|
|
240
|
+
if (!cfg.token || !chatIds.length || cfg.ctbNotify === false) return;
|
|
231
241
|
const lang = cfg.lang || process.env.LANG || "";
|
|
232
242
|
process.stderr.write("ctb: summarizing session...\n");
|
|
233
243
|
const summary = await summarizeSession(provider, sessionId, lang, cfg);
|
|
234
244
|
if (!summary) { process.stderr.write("ctb: nothing to summarize (SKIP)\n"); return; }
|
|
235
245
|
process.stderr.write(`ctb: sending to Telegram — ${summary}\n`);
|
|
236
246
|
const label = lang.startsWith("ko") ? "[터미널]" : "[local]";
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
247
|
+
for (const chatId of chatIds) {
|
|
248
|
+
const r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
body: JSON.stringify({ chat_id: chatId, text: `💻 ${label} ${summary}` }),
|
|
252
|
+
});
|
|
253
|
+
const json = await r.json();
|
|
254
|
+
if (!json.ok) process.stderr.write(`ctb: Telegram error — ${JSON.stringify(json)}\n`);
|
|
255
|
+
}
|
|
244
256
|
} catch (e) {
|
|
245
257
|
process.stderr.write(`ctb: notify error — ${e.message}\n`);
|
|
246
258
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-telegram-bot",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Drive Claude Code or Codex from Telegram with persistent sessions, provider switching, and zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"node": ">=18"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"start": "node bot.mjs"
|
|
25
|
+
"start": "node bot.mjs",
|
|
26
|
+
"smoke": "node --check bot.mjs && node --check ctb.mjs && node bot.mjs --version && node ctb.mjs --version",
|
|
27
|
+
"test": "npm run smoke"
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
|
28
30
|
"claude",
|