claude-telegram-bot 0.3.14 → 0.3.15
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 +2 -1
- package/README.md +2 -1
- package/bot.mjs +85 -14
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -116,7 +116,7 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
|
|
|
116
116
|
- `테스트 돌려보고 통과하면 커밋하고 push 해줘`
|
|
117
117
|
- `api.ts 에 에러 핸들링 추가해줘`
|
|
118
118
|
|
|
119
|
-
명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
|
|
119
|
+
명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/reserve`(한도 리셋 시 재시도 예약) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
|
|
120
120
|
|
|
121
121
|
> **`/stop`** 은 실행 중인 Claude 프로세스를 즉시 종료하고 대기 중인 메시지 큐도 비웁니다. `--reset`을 붙이면 세션을 작업 시작 이전 상태로 되돌려 중단된 작업이 대화 맥락에 남지 않습니다.
|
|
122
122
|
|
|
@@ -127,6 +127,7 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
|
|
|
127
127
|
- **세션 유지** — 대화는 `--resume`으로 자동으로 이어집니다. 마지막 세션 ID가 `state.json`에 저장되므로 봇을 재시작해도 맥락이 남습니다. 새로 시작하려면 `/new`.
|
|
128
128
|
- **메시지 큐** — 작업 중에 새 메시지가 오면 버리지 않고 큐에 쌓아둡니다. 작업이 끝나면 대기 중인 메시지를 모두 하나의 프롬프트로 합쳐서 처리합니다(예: "A 해줘" → "아니다 B 해줘"를 한 번에 처리). `/stop`으로 실행 중인 작업과 큐를 동시에 취소할 수 있습니다.
|
|
129
129
|
- **모델 권유** — 봇이 Claude에게 현재 모델을 알려줍니다. 질문 난이도가 현재 모델 수준을 넘는다고 판단되면 답변 끝에 전환 권유 한 줄이 붙습니다(예: 💡 `/model sonnet`). `/model <이름>`으로 전환(`haiku`, `sonnet`, `opus`, `fable`, 또는 전체 모델 ID) — `state.json`에 저장돼 재시작 후에도 유지됩니다.
|
|
130
|
+
- **한도 초과 재시도** — Claude Max / API 레이트 리밋 에러에 리셋 시간이 포함되면 `/reserve` 힌트를 표시합니다. `/reserve`를 입력하면 마지막 메시지를 해당 시각에 자동 재전송 예약. `/reserve <다른 메시지>`로 내용 변경, `/reserve rm`으로 취소.
|
|
130
131
|
- **간결한 답변** — 텔레그램에 맞게 짧게 답하도록 시스템 프롬프트가 기본으로 붙습니다. 바꾸려면 `appendSystemPrompt`에 직접 넣으세요 (빈 문자열이면 끔).
|
|
131
132
|
- **언어** — 봇 자체 문구(`/help`, 명령 메뉴, 상태 메시지)는 **기본 영어**, 텔레그램이 한국어인 사용자에겐 한국어로 나옵니다. `lang`(`"en"`/`"ko"`)으로 고정할 수 있습니다. Claude의 실제 답변은 **사용자가 쓴 언어**를 따라갑니다. `/` 명령 메뉴는 `setMyCommands`로 언어별 등록됩니다.
|
|
132
133
|
- **서식 변환** — 답변의 마크다운(굵게·코드·표 등)을 텔레그램 HTML로 바꿔 보냅니다. 변환이 깨지는 경우엔 평문으로 다시 보냅니다.
|
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ auth layer.)
|
|
|
160
160
|
- `run the solver tests and commit + push if they pass`
|
|
161
161
|
- `add an edge case to solve-2nd-floor-edges.ts`
|
|
162
162
|
|
|
163
|
-
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`.
|
|
163
|
+
Commands: `/new` (reset context / new session) · `/stop` (stop current task; `--reset` to also roll back the session) · `/cron` (list / add / remove scheduled tasks) · `/reserve` (schedule retry at usage-limit reset) · `/restart` (syntax-check & restart the bot) · `/status` (bot status & version) · `/model` (view / switch the model) · `/id` (show chat ID) · `/help`.
|
|
164
164
|
|
|
165
165
|
> **`/stop`** kills the running Claude process immediately and clears any queued messages.
|
|
166
166
|
> Add `--reset` to also restore the session to the state it was in *before* the task started,
|
|
@@ -223,6 +223,7 @@ your launchd plist points them.
|
|
|
223
223
|
`state.json`, so context survives restarts. Use `/new` to start fresh.
|
|
224
224
|
- **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.
|
|
225
225
|
- **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.
|
|
226
|
+
- **Usage-limit retry**: when a Claude Max / API rate-limit error includes a reset time, the bot shows a `/reserve` hint. `/reserve` schedules your last message to be resent automatically at that time. Use `/reserve <different text>` to change what's sent, or `/reserve rm` to cancel.
|
|
226
227
|
|
|
227
228
|
### Scheduled tasks (cron)
|
|
228
229
|
|
package/bot.mjs
CHANGED
|
@@ -133,6 +133,7 @@ const STR = {
|
|
|
133
133
|
"• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
|
|
134
134
|
"• /remember <text> — save to persistent memory (survives /new)\n" +
|
|
135
135
|
"• /memory — view memory · /memory clear to wipe\n" +
|
|
136
|
+
"• /reserve — retry at usage-limit reset time · /reserve <msg> for a different message · /reserve rm to cancel\n" +
|
|
136
137
|
"• /restart — restart the bot (after a syntax check)\n" +
|
|
137
138
|
"• /status — bot status & version\n" +
|
|
138
139
|
"• /model — view / switch the model\n" +
|
|
@@ -192,6 +193,11 @@ const STR = {
|
|
|
192
193
|
remembered: "💾 Saved to memory.",
|
|
193
194
|
rememberUsage: "Usage: /remember <text to remember>",
|
|
194
195
|
memoryUsage: "Usage: /memory · /memory clear",
|
|
196
|
+
reserveHint: "\n\nTo retry when the limit resets, send `/reserve` (or `/reserve <different message>`).",
|
|
197
|
+
reserveOk: (time) => `⏰ Retry scheduled for ${time}. Cancel with /reserve rm.`,
|
|
198
|
+
reserveRm: "🚫 Scheduled retry canceled.",
|
|
199
|
+
reserveNone: "No retry is scheduled.",
|
|
200
|
+
reserveNoLimit: "No recent usage limit error. Send a message first.",
|
|
195
201
|
},
|
|
196
202
|
ko: {
|
|
197
203
|
help: () =>
|
|
@@ -202,6 +208,7 @@ const STR = {
|
|
|
202
208
|
"• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
|
|
203
209
|
"• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
|
|
204
210
|
"• /memory — 메모리 보기 · /memory clear 로 삭제\n" +
|
|
211
|
+
"• /reserve — 한도 리셋 시 재시도 예약 · /reserve <다른 메시지> 로 내용 변경 · /reserve rm 으로 취소\n" +
|
|
205
212
|
"• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
|
|
206
213
|
"• /status — 봇 상태·버전 보기\n" +
|
|
207
214
|
"• /model — 모델 보기·전환\n" +
|
|
@@ -260,6 +267,11 @@ const STR = {
|
|
|
260
267
|
remembered: "💾 메모리에 저장했습니다.",
|
|
261
268
|
rememberUsage: "사용법: /remember <기억할 내용>",
|
|
262
269
|
memoryUsage: "사용법: /memory · /memory clear",
|
|
270
|
+
reserveHint: "\n\n리셋 후 재시도하려면 `/reserve` (또는 `/reserve <다른 메시지>`)를 입력하세요.",
|
|
271
|
+
reserveOk: (time) => `⏰ ${time}에 재시도 예약됨. 취소: /reserve rm`,
|
|
272
|
+
reserveRm: "🚫 예약된 재시도를 취소했습니다.",
|
|
273
|
+
reserveNone: "예약된 재시도가 없습니다.",
|
|
274
|
+
reserveNoLimit: "최근 한도 초과 에러가 없습니다. 먼저 메시지를 보내주세요.",
|
|
263
275
|
},
|
|
264
276
|
};
|
|
265
277
|
const t = (l, key, ...a) => {
|
|
@@ -281,6 +293,7 @@ const COMMANDS = {
|
|
|
281
293
|
{ command: "restart", description: "Restart the bot (after syntax check)" },
|
|
282
294
|
{ command: "status", description: "Bot status / version" },
|
|
283
295
|
{ command: "model", description: "View / switch the model" },
|
|
296
|
+
{ command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
|
|
284
297
|
{ command: "id", description: "Show this chat ID" },
|
|
285
298
|
{ command: "help", description: "Help" },
|
|
286
299
|
],
|
|
@@ -293,6 +306,7 @@ const COMMANDS = {
|
|
|
293
306
|
{ command: "restart", description: "봇 재시작 (문법 검사 후)" },
|
|
294
307
|
{ command: "status", description: "봇 상태·버전 보기" },
|
|
295
308
|
{ command: "model", description: "모델 보기·전환" },
|
|
309
|
+
{ command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
|
|
296
310
|
{ command: "id", description: "이 채팅 ID 확인" },
|
|
297
311
|
{ command: "help", description: "도움말" },
|
|
298
312
|
],
|
|
@@ -482,11 +496,34 @@ async function send(chatId, text) {
|
|
|
482
496
|
}
|
|
483
497
|
|
|
484
498
|
// ── Claude 에러 분류 ──────────────────────────────────────────────────────
|
|
499
|
+
function parseResetTime(raw) {
|
|
500
|
+
// ISO timestamp: 2026-06-17T14:00:00Z
|
|
501
|
+
const iso = raw.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/);
|
|
502
|
+
if (iso) { const d = new Date(iso[0]); if (!isNaN(d)) return d; }
|
|
503
|
+
// "in X minutes" / "in X hours"
|
|
504
|
+
const inMin = raw.match(/in (\d+)\s*minute/i);
|
|
505
|
+
if (inMin) return new Date(Date.now() + parseInt(inMin[1]) * 60000);
|
|
506
|
+
const inHour = raw.match(/in (\d+)\s*hour/i);
|
|
507
|
+
if (inHour) return new Date(Date.now() + parseInt(inHour[1]) * 3600000);
|
|
508
|
+
// "resets at HH:MM" or "available at HH:MM"
|
|
509
|
+
const atTime = raw.match(/(?:resets?|reset|available|retry)\s+at\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?/i);
|
|
510
|
+
if (atTime) {
|
|
511
|
+
let h = parseInt(atTime[1]);
|
|
512
|
+
const m = parseInt(atTime[2]);
|
|
513
|
+
if (atTime[3]) { if (/pm/i.test(atTime[3]) && h < 12) h += 12; if (/am/i.test(atTime[3]) && h === 12) h = 0; }
|
|
514
|
+
const d = new Date(); d.setHours(h, m, 0, 0);
|
|
515
|
+
if (d <= new Date()) d.setDate(d.getDate() + 1);
|
|
516
|
+
return d;
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
485
521
|
function classifyClaudeError(raw, code) {
|
|
486
522
|
const t = raw.toLowerCase();
|
|
487
523
|
if (t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment"))
|
|
488
524
|
return "💳 API 크레딧이 부족합니다. console.anthropic.com 에서 충전해주세요.";
|
|
489
|
-
if (t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
|
|
525
|
+
if (t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
|
|
526
|
+
|| t.includes("usage limit") || t.includes("monthly limit"))
|
|
490
527
|
return "⏱️ 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.";
|
|
491
528
|
if (t.includes("overloaded") || code === 529)
|
|
492
529
|
return "🔄 Claude 서버가 일시적으로 과부하 상태입니다. 잠시 후 다시 시도해주세요.";
|
|
@@ -545,13 +582,13 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
545
582
|
currentChild = null;
|
|
546
583
|
try {
|
|
547
584
|
const j = JSON.parse(out);
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
resolve({ ok: !j.is_error, text, sessionId: j.session_id, cost: j.total_cost_usd });
|
|
585
|
+
const rawErr = j.result ?? "";
|
|
586
|
+
const text = j.is_error ? classifyClaudeError(rawErr, code) : (rawErr || "(empty response)");
|
|
587
|
+
const resetAt = j.is_error ? parseResetTime(rawErr) : null;
|
|
588
|
+
resolve({ ok: !j.is_error, text, sessionId: j.session_id, cost: j.total_cost_usd, resetAt });
|
|
552
589
|
} catch {
|
|
553
590
|
const raw = (err || out || "no output").slice(0, 3500);
|
|
554
|
-
resolve({ ok: false, text: classifyClaudeError(raw, code) });
|
|
591
|
+
resolve({ ok: false, text: classifyClaudeError(raw, code), resetAt: parseResetTime(raw) });
|
|
555
592
|
}
|
|
556
593
|
});
|
|
557
594
|
});
|
|
@@ -795,10 +832,12 @@ async function downloadAttachment(att) {
|
|
|
795
832
|
let busy = false;
|
|
796
833
|
const msgQueue = []; // { msg, receivedAt } — busy 중 수신 메시지 대기열
|
|
797
834
|
const mediaGroups = new Map(); // media_group_id → { msgs, timer } — 미디어 그룹 수집 대기
|
|
798
|
-
let currentChild = null;
|
|
799
|
-
let currentTyping = null;
|
|
800
|
-
let prevSessionId;
|
|
801
|
-
let stopping = false;
|
|
835
|
+
let currentChild = null; // 실행 중인 claude child process (/stop 용)
|
|
836
|
+
let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
|
|
837
|
+
let prevSessionId; // /stop --reset 복원 대상
|
|
838
|
+
let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
|
|
839
|
+
let rateLimitState = null; // 마지막 레이트 리밋 { prompt, resetAt } — /reserve 용
|
|
840
|
+
let pendingRetry = null; // /reserve 로 예약된 재시도 { timer, resetAt }
|
|
802
841
|
|
|
803
842
|
async function handle(msg) {
|
|
804
843
|
const chatId = msg.chat?.id;
|
|
@@ -928,6 +967,32 @@ async function handle(msg) {
|
|
|
928
967
|
await send(chatId, mem ? t(l, "memoryShow", mem) : t(l, "memoryEmpty"));
|
|
929
968
|
return;
|
|
930
969
|
}
|
|
970
|
+
if (text === "/reserve" || text.startsWith("/reserve ")) {
|
|
971
|
+
const arg = text.slice(8).trim();
|
|
972
|
+
if (arg === "rm") {
|
|
973
|
+
if (!pendingRetry) { await send(chatId, t(l, "reserveNone")); return; }
|
|
974
|
+
clearTimeout(pendingRetry.timer); pendingRetry = null;
|
|
975
|
+
await send(chatId, t(l, "reserveRm"));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (!rateLimitState) { await send(chatId, t(l, "reserveNoLimit")); return; }
|
|
979
|
+
const { resetAt } = rateLimitState;
|
|
980
|
+
const reservePrompt = arg || rateLimitState.prompt;
|
|
981
|
+
if (pendingRetry) clearTimeout(pendingRetry.timer);
|
|
982
|
+
const capturedPrompt = reservePrompt;
|
|
983
|
+
const capturedMsg = msg;
|
|
984
|
+
const delay = Math.max(resetAt - Date.now(), 1000);
|
|
985
|
+
pendingRetry = {
|
|
986
|
+
resetAt,
|
|
987
|
+
timer: setTimeout(() => {
|
|
988
|
+
pendingRetry = null;
|
|
989
|
+
handle({ ...capturedMsg, text: capturedPrompt, caption: undefined });
|
|
990
|
+
}, delay),
|
|
991
|
+
};
|
|
992
|
+
const timeStr = resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
|
|
993
|
+
await send(chatId, t(l, "reserveOk", timeStr));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
931
996
|
|
|
932
997
|
if (busy) {
|
|
933
998
|
msgQueue.push({ msg, receivedAt: Date.now() });
|
|
@@ -979,10 +1044,16 @@ async function handle(msg) {
|
|
|
979
1044
|
saveState(state);
|
|
980
1045
|
}
|
|
981
1046
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
: "";
|
|
985
|
-
|
|
1047
|
+
if (!res.ok) {
|
|
1048
|
+
// 레이트 리밋이고 리셋 시간을 알면 /reserve 힌트 추가
|
|
1049
|
+
const hint = res.resetAt ? t(l, "reserveHint") : "";
|
|
1050
|
+
rateLimitState = res.resetAt ? { prompt, resetAt: res.resetAt } : null;
|
|
1051
|
+
if (!stopping) await send(chatId, `⚠️ ${res.text}${hint}`);
|
|
1052
|
+
} else {
|
|
1053
|
+
rateLimitState = null;
|
|
1054
|
+
const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
|
|
1055
|
+
if (!stopping) await send(chatId, res.text + footer);
|
|
1056
|
+
}
|
|
986
1057
|
} catch (e) {
|
|
987
1058
|
if (!stopping) await send(chatId, t(l, "botError", e.message));
|
|
988
1059
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-telegram-bot",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
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": {
|