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 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
- - **모델 권유**봇이 Claude에게 현재 모델을 알려줍니다. 질문 난이도가 현재 모델 수준을 넘는다고 판단되면 답변 끝에 전환 권유 한 줄이 붙습니다(예: 💡 `/model sonnet`). `/model <이름>`으로 전환(`haiku`, `sonnet`, `opus`, `fable`, 또는 전체 모델 ID) `state.json`에 저장돼 재시작 후에도 유지됩니다.
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 override passed with `--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
- - **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.
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
- modelStatus: (cur, list) =>
221
- `🧠 Model: ${cur}\n` +
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
- modelSet: (m) => `🧠 Model set to: ${m}`,
225
- modelReset: (def) => `🧠 Model reset to default (${def}).`,
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
- modelStatus: (cur, list) =>
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
- modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
320
- modelReset: (def) => `🧠 모델을 기본값(${def})으로 되돌렸습니다.`,
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 MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
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 modelStateKey = currentProvider() === "codex" ? "codexModel" : "model";
1492
- const configuredModel = currentProvider() === "codex" ? cfg.codexModel : cfg.model;
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, t(l, "modelStatus", cur, MODEL_SUGGESTIONS));
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
- return i === 0 ? `[1] ${text}` : `[${i + 1}, +${dt}s] ${text}`;
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 Claude, continuing the Telegram session\n` +
79
- ` ctb -p "what did we do?" Headless Claude with session context\n` +
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
- if (!cfg.token || !cfg.allowedChatId || cfg.ctbNotify === false) return;
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 r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
238
- method: "POST",
239
- headers: { "content-type": "application/json" },
240
- body: JSON.stringify({ chat_id: cfg.allowedChatId, text: `💻 ${label} ${summary}` }),
241
- });
242
- const json = await r.json();
243
- if (!json.ok) process.stderr.write(`ctb: Telegram error — ${JSON.stringify(json)}\n`);
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.0",
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",