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 +16 -0
- package/README.ko.md +51 -0
- package/README.md +57 -0
- package/bot.mjs +34 -1
- package/ctb.mjs +24 -13
- package/package.json +4 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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.
|
|
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",
|