claude-telegram-bot 0.4.1 → 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,21 @@
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
+
3
19
  ## [0.4.1] - 2026-07-10
4
20
  ### Changed
5
21
  - `ctb`의 provider 결정 우선순위(`--provider` → `config.provider` → `claude`)를 도움말에 명시했다.
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
  자리를 비운 사이에도 폰으로 빌드를 돌려보거나 간단한 수정을 맡기고 싶을 때가 있습니다. 그렇다고 외부에서 데스크톱에 원격 접속해서 터미널을 여는 건 번거롭죠.
@@ -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.
@@ -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
@@ -1225,9 +1225,23 @@ async function downloadAttachment(att) {
1225
1225
  }
1226
1226
 
1227
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
+
1228
1236
  function buildMsgMeta(msg) {
1229
1237
  const parts = [];
1230
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
+
1231
1245
  // 포워드 출처 (신규 API: forward_origin, 구버전 폴백: forward_from / forward_from_chat)
1232
1246
  const fo = msg.forward_origin;
1233
1247
  if (fo) {
@@ -1275,6 +1289,16 @@ const pendingPlans = new Map(); // chatId → { sessionId, messageId } — /plan
1275
1289
  const PLAN_PROCEED_PROMPT = "Proceed with the plan you just approved above. Implement it now.";
1276
1290
  let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
1277
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
+
1278
1302
  // runClaude 결과를 답장으로 변환 — 폴백/큐잉/자동 컴팩션 처리. handle()과 /plan 승인 실행이 공유.
1279
1303
  async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
1280
1304
  const secs = Math.round((Date.now() - started) / 1000);
@@ -1479,19 +1503,23 @@ async function handle(msg) {
1479
1503
  return;
1480
1504
  }
1481
1505
  if (arg === "default" || arg === "reset") {
1506
+ const previousProvider = currentProvider();
1482
1507
  state.provider = undefined;
1483
1508
  saveState(state);
1484
1509
  await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
1510
+ resumeQueueAfterProviderSwitch(previousProvider);
1485
1511
  return;
1486
1512
  }
1487
1513
  if (!["claude", "codex"].includes(arg)) {
1488
1514
  await send(chatId, t(l, "providerUsage"));
1489
1515
  return;
1490
1516
  }
1517
+ const previousProvider = currentProvider();
1491
1518
  state.provider = arg;
1492
1519
  state.ollamaMode = false;
1493
1520
  saveState(state);
1494
1521
  await send(chatId, t(l, "providerSet", arg));
1522
+ resumeQueueAfterProviderSwitch(previousProvider);
1495
1523
  return;
1496
1524
  }
1497
1525
  if (text === "/model" || text.startsWith("/model ")) {
@@ -1801,11 +1829,14 @@ async function handle(msg) {
1801
1829
  function drainQueue() {
1802
1830
  if (msgQueue.length === 1) return msgQueue.shift().msg;
1803
1831
  const group = msgQueue.splice(0);
1832
+ const groupChat = isGroupChat(group[0].msg.chat);
1804
1833
  const merged = group
1805
1834
  .map((item, i) => {
1806
1835
  const text = item.msg.text || item.msg.caption || "";
1807
1836
  const dt = Math.round((item.receivedAt - group[0].receivedAt) / 1000);
1808
- 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}`;
1809
1840
  })
1810
1841
  .join("\n");
1811
1842
  // 마지막 메시지 필드만 남기면 앞서 온 메시지의 사진/첨부가 유실되므로, 전체 첨부를 순서대로 모아 둠
@@ -1818,6 +1849,8 @@ function drainQueue() {
1818
1849
  ...group[group.length - 1].msg,
1819
1850
  text: merged,
1820
1851
  caption: undefined,
1852
+ // 줄마다 발신자를 이미 표기했으니 buildMsgMeta의 단일 [From: ] 태그(마지막 발신자 기준)는 중복이라 생략
1853
+ _merged: groupChat || undefined,
1821
1854
  _mediaGroup: fileIds.length ? fileIds : undefined,
1822
1855
  };
1823
1856
  }
package/ctb.mjs CHANGED
@@ -120,11 +120,6 @@ async function main() {
120
120
  forwardedArgs.push(arg);
121
121
  }
122
122
  }
123
- const provider = providerOverride || cfg.provider || "claude";
124
- if (!["claude", "codex"].includes(provider)) {
125
- throw new Error(`Unsupported provider: ${provider} (expected claude or codex)`);
126
- }
127
-
128
123
  const dataDir = dirname(configPath);
129
124
  const botDir = join(dataDir, ".claude-bot");
130
125
  const stateBase = basename(configPath, ".json");
@@ -132,6 +127,18 @@ async function main() {
132
127
  const statePath = join(botDir, stateFile);
133
128
  const lockPath = join(botDir, "local.lock");
134
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
+
135
142
  mkdirSync(botDir, { recursive: true });
136
143
  writeFileSync(lockPath, String(process.pid));
137
144
  const cleanup = () => { try { unlinkSync(lockPath); } catch {} };
@@ -228,20 +235,24 @@ async function summarizeSession(provider, sid, lang, cfg) {
228
235
  async function notifyTelegram(configPath, provider, sessionId) {
229
236
  try {
230
237
  const cfg = JSON.parse(readFileSync(configPath, "utf8"));
231
- 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;
232
241
  const lang = cfg.lang || process.env.LANG || "";
233
242
  process.stderr.write("ctb: summarizing session...\n");
234
243
  const summary = await summarizeSession(provider, sessionId, lang, cfg);
235
244
  if (!summary) { process.stderr.write("ctb: nothing to summarize (SKIP)\n"); return; }
236
245
  process.stderr.write(`ctb: sending to Telegram — ${summary}\n`);
237
246
  const label = lang.startsWith("ko") ? "[터미널]" : "[local]";
238
- const r = await fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
239
- method: "POST",
240
- headers: { "content-type": "application/json" },
241
- body: JSON.stringify({ chat_id: cfg.allowedChatId, text: `💻 ${label} ${summary}` }),
242
- });
243
- const json = await r.json();
244
- 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
+ }
245
256
  } catch (e) {
246
257
  process.stderr.write(`ctb: notify error — ${e.message}\n`);
247
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.4.1",
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",