claude-telegram-bot 0.3.14 → 0.3.17
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 +4 -1
- package/README.md +4 -1
- package/bot.mjs +103 -26
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/claude-telegram-bot)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
+
텔레그램으로 Claude Code를 — 어디서든, 어떤 기기에서도.
|
|
10
|
+
|
|
9
11
|
텔레그램으로 메시지를 보내면, 집이나 서버에 켜둔 Claude Code가 작업하고 결과를 다시 텔레그램으로 돌려주는 봇입니다.
|
|
10
12
|
|
|
11
13
|
```
|
|
@@ -116,7 +118,7 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
|
|
|
116
118
|
- `테스트 돌려보고 통과하면 커밋하고 push 해줘`
|
|
117
119
|
- `api.ts 에 에러 핸들링 추가해줘`
|
|
118
120
|
|
|
119
|
-
명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
|
|
121
|
+
명령어: `/new`(맥락 초기화) · `/stop`(작업 중단; `--reset`으로 세션도 롤백) · `/cron`(예약 작업 보기·추가·삭제) · `/reserve`(한도 리셋 시 재시도 예약) · `/restart`(문법 검사 후 재시작) · `/status`(봇 상태·버전) · `/model`(모델 보기·전환) · `/id`(채팅 ID 확인) · `/help`(도움말)
|
|
120
122
|
|
|
121
123
|
> **`/stop`** 은 실행 중인 Claude 프로세스를 즉시 종료하고 대기 중인 메시지 큐도 비웁니다. `--reset`을 붙이면 세션을 작업 시작 이전 상태로 되돌려 중단된 작업이 대화 맥락에 남지 않습니다.
|
|
122
124
|
|
|
@@ -127,6 +129,7 @@ claude-telegram-bot ~/botconfigs/myproj/mybot.json
|
|
|
127
129
|
- **세션 유지** — 대화는 `--resume`으로 자동으로 이어집니다. 마지막 세션 ID가 `state.json`에 저장되므로 봇을 재시작해도 맥락이 남습니다. 새로 시작하려면 `/new`.
|
|
128
130
|
- **메시지 큐** — 작업 중에 새 메시지가 오면 버리지 않고 큐에 쌓아둡니다. 작업이 끝나면 대기 중인 메시지를 모두 하나의 프롬프트로 합쳐서 처리합니다(예: "A 해줘" → "아니다 B 해줘"를 한 번에 처리). `/stop`으로 실행 중인 작업과 큐를 동시에 취소할 수 있습니다.
|
|
129
131
|
- **모델 권유** — 봇이 Claude에게 현재 모델을 알려줍니다. 질문 난이도가 현재 모델 수준을 넘는다고 판단되면 답변 끝에 전환 권유 한 줄이 붙습니다(예: 💡 `/model sonnet`). `/model <이름>`으로 전환(`haiku`, `sonnet`, `opus`, `fable`, 또는 전체 모델 ID) — `state.json`에 저장돼 재시작 후에도 유지됩니다.
|
|
132
|
+
- **한도 초과 재시도** — Claude Max / API 레이트 리밋 에러에 리셋 시간이 포함되면 `/reserve` 힌트를 표시합니다. `/reserve`를 입력하면 마지막 메시지를 해당 시각에 자동 재전송 예약. `/reserve <다른 메시지>`로 내용 변경, `/reserve rm`으로 취소.
|
|
130
133
|
- **간결한 답변** — 텔레그램에 맞게 짧게 답하도록 시스템 프롬프트가 기본으로 붙습니다. 바꾸려면 `appendSystemPrompt`에 직접 넣으세요 (빈 문자열이면 끔).
|
|
131
134
|
- **언어** — 봇 자체 문구(`/help`, 명령 메뉴, 상태 메시지)는 **기본 영어**, 텔레그램이 한국어인 사용자에겐 한국어로 나옵니다. `lang`(`"en"`/`"ko"`)으로 고정할 수 있습니다. Claude의 실제 답변은 **사용자가 쓴 언어**를 따라갑니다. `/` 명령 메뉴는 `setMyCommands`로 언어별 등록됩니다.
|
|
132
135
|
- **서식 변환** — 답변의 마크다운(굵게·코드·표 등)을 텔레그램 HTML로 바꿔 보냅니다. 변환이 깨지는 경우엔 평문으로 다시 보냅니다.
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
[](https://www.npmjs.com/package/claude-telegram-bot)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
|
|
9
|
+
Use Claude Code from Telegram — run it anywhere, from any device.
|
|
10
|
+
|
|
9
11
|
**A zero-dependency, single-file, daemonized Claude Code bot — no Bun, no Python, no open session.**
|
|
10
12
|
|
|
11
13
|
A tiny bridge that takes your Telegram messages, runs `claude -p` (Claude Code headless mode)
|
|
@@ -160,7 +162,7 @@ auth layer.)
|
|
|
160
162
|
- `run the solver tests and commit + push if they pass`
|
|
161
163
|
- `add an edge case to solve-2nd-floor-edges.ts`
|
|
162
164
|
|
|
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`.
|
|
165
|
+
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
166
|
|
|
165
167
|
> **`/stop`** kills the running Claude process immediately and clears any queued messages.
|
|
166
168
|
> Add `--reset` to also restore the session to the state it was in *before* the task started,
|
|
@@ -223,6 +225,7 @@ your launchd plist points them.
|
|
|
223
225
|
`state.json`, so context survives restarts. Use `/new` to start fresh.
|
|
224
226
|
- **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
227
|
- **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.
|
|
228
|
+
- **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
229
|
|
|
227
230
|
### Scheduled tasks (cron)
|
|
228
231
|
|
package/bot.mjs
CHANGED
|
@@ -114,6 +114,11 @@ if (!existsSync(CONFIG_PATH)) {
|
|
|
114
114
|
const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
115
115
|
console.log({ ...cfg, token: cfg.token ? "<redacted>" : "(none)" });
|
|
116
116
|
const TG = `https://api.telegram.org/bot${cfg.token}`;
|
|
117
|
+
// allowedChatId 는 문자열 또는 배열 모두 허용 (하위 호환)
|
|
118
|
+
const allowedIds = []
|
|
119
|
+
.concat(cfg.allowedChatId)
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map(String);
|
|
117
122
|
|
|
118
123
|
// ── i18n (영어 기본 + 한국어) ─────────────────────────────────────────────
|
|
119
124
|
// cfg.lang 를 "en"/"ko" 로 주면 그 언어로 고정. 비우면 메시지의 from.language_code 로
|
|
@@ -133,6 +138,7 @@ const STR = {
|
|
|
133
138
|
"• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
|
|
134
139
|
"• /remember <text> — save to persistent memory (survives /new)\n" +
|
|
135
140
|
"• /memory — view memory · /memory clear to wipe\n" +
|
|
141
|
+
"• /reserve — retry at usage-limit reset time · /reserve <msg> for a different message · /reserve rm to cancel\n" +
|
|
136
142
|
"• /restart — restart the bot (after a syntax check)\n" +
|
|
137
143
|
"• /status — bot status & version\n" +
|
|
138
144
|
"• /model — view / switch the model\n" +
|
|
@@ -192,6 +198,11 @@ const STR = {
|
|
|
192
198
|
remembered: "💾 Saved to memory.",
|
|
193
199
|
rememberUsage: "Usage: /remember <text to remember>",
|
|
194
200
|
memoryUsage: "Usage: /memory · /memory clear",
|
|
201
|
+
reserveHint: "\n\nTo retry when the limit resets, send `/reserve` (or `/reserve <different message>`).",
|
|
202
|
+
reserveOk: (time) => `⏰ Retry scheduled for ${time}. Cancel with /reserve rm.`,
|
|
203
|
+
reserveRm: "🚫 Scheduled retry canceled.",
|
|
204
|
+
reserveNone: "No retry is scheduled.",
|
|
205
|
+
reserveNoLimit: "No recent usage limit error. Send a message first.",
|
|
195
206
|
},
|
|
196
207
|
ko: {
|
|
197
208
|
help: () =>
|
|
@@ -202,6 +213,7 @@ const STR = {
|
|
|
202
213
|
"• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
|
|
203
214
|
"• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
|
|
204
215
|
"• /memory — 메모리 보기 · /memory clear 로 삭제\n" +
|
|
216
|
+
"• /reserve — 한도 리셋 시 재시도 예약 · /reserve <다른 메시지> 로 내용 변경 · /reserve rm 으로 취소\n" +
|
|
205
217
|
"• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
|
|
206
218
|
"• /status — 봇 상태·버전 보기\n" +
|
|
207
219
|
"• /model — 모델 보기·전환\n" +
|
|
@@ -260,6 +272,11 @@ const STR = {
|
|
|
260
272
|
remembered: "💾 메모리에 저장했습니다.",
|
|
261
273
|
rememberUsage: "사용법: /remember <기억할 내용>",
|
|
262
274
|
memoryUsage: "사용법: /memory · /memory clear",
|
|
275
|
+
reserveHint: "\n\n리셋 후 재시도하려면 `/reserve` (또는 `/reserve <다른 메시지>`)를 입력하세요.",
|
|
276
|
+
reserveOk: (time) => `⏰ ${time}에 재시도 예약됨. 취소: /reserve rm`,
|
|
277
|
+
reserveRm: "🚫 예약된 재시도를 취소했습니다.",
|
|
278
|
+
reserveNone: "예약된 재시도가 없습니다.",
|
|
279
|
+
reserveNoLimit: "최근 한도 초과 에러가 없습니다. 먼저 메시지를 보내주세요.",
|
|
263
280
|
},
|
|
264
281
|
};
|
|
265
282
|
const t = (l, key, ...a) => {
|
|
@@ -281,6 +298,7 @@ const COMMANDS = {
|
|
|
281
298
|
{ command: "restart", description: "Restart the bot (after syntax check)" },
|
|
282
299
|
{ command: "status", description: "Bot status / version" },
|
|
283
300
|
{ command: "model", description: "View / switch the model" },
|
|
301
|
+
{ command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
|
|
284
302
|
{ command: "id", description: "Show this chat ID" },
|
|
285
303
|
{ command: "help", description: "Help" },
|
|
286
304
|
],
|
|
@@ -293,6 +311,7 @@ const COMMANDS = {
|
|
|
293
311
|
{ command: "restart", description: "봇 재시작 (문법 검사 후)" },
|
|
294
312
|
{ command: "status", description: "봇 상태·버전 보기" },
|
|
295
313
|
{ command: "model", description: "모델 보기·전환" },
|
|
314
|
+
{ command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
|
|
296
315
|
{ command: "id", description: "이 채팅 ID 확인" },
|
|
297
316
|
{ command: "help", description: "도움말" },
|
|
298
317
|
],
|
|
@@ -340,7 +359,7 @@ function isNewer(latest, current) {
|
|
|
340
359
|
}
|
|
341
360
|
|
|
342
361
|
async function checkForUpdate() {
|
|
343
|
-
if (!
|
|
362
|
+
if (!allowedIds.length) return;
|
|
344
363
|
const now = Date.now();
|
|
345
364
|
if (state.lastVersionCheck && now - state.lastVersionCheck < VERSION_CHECK_INTERVAL) return;
|
|
346
365
|
const latest = await fetchLatestVersion();
|
|
@@ -350,9 +369,10 @@ async function checkForUpdate() {
|
|
|
350
369
|
if (state.notifiedVersion === latest) return; // 이미 알린 버전
|
|
351
370
|
state.notifiedVersion = latest;
|
|
352
371
|
saveState(state);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
372
|
+
for (const id of allowedIds)
|
|
373
|
+
await send(id,
|
|
374
|
+
`✨ 업데이트가 있습니다: ${VERSION} → ${latest}\n\`npm i -g claude-telegram-bot\` 후 /restart`
|
|
375
|
+
).catch(() => {});
|
|
356
376
|
}
|
|
357
377
|
|
|
358
378
|
// ── 퍼시스턴트 메모리 ─────────────────────────────────────────────────────
|
|
@@ -482,11 +502,34 @@ async function send(chatId, text) {
|
|
|
482
502
|
}
|
|
483
503
|
|
|
484
504
|
// ── Claude 에러 분류 ──────────────────────────────────────────────────────
|
|
505
|
+
function parseResetTime(raw) {
|
|
506
|
+
// ISO timestamp: 2026-06-17T14:00:00Z
|
|
507
|
+
const iso = raw.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?/);
|
|
508
|
+
if (iso) { const d = new Date(iso[0]); if (!isNaN(d)) return d; }
|
|
509
|
+
// "in X minutes" / "in X hours"
|
|
510
|
+
const inMin = raw.match(/in (\d+)\s*minute/i);
|
|
511
|
+
if (inMin) return new Date(Date.now() + parseInt(inMin[1]) * 60000);
|
|
512
|
+
const inHour = raw.match(/in (\d+)\s*hour/i);
|
|
513
|
+
if (inHour) return new Date(Date.now() + parseInt(inHour[1]) * 3600000);
|
|
514
|
+
// "resets at HH:MM" or "available at HH:MM"
|
|
515
|
+
const atTime = raw.match(/(?:resets?|reset|available|retry)\s+at\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?/i);
|
|
516
|
+
if (atTime) {
|
|
517
|
+
let h = parseInt(atTime[1]);
|
|
518
|
+
const m = parseInt(atTime[2]);
|
|
519
|
+
if (atTime[3]) { if (/pm/i.test(atTime[3]) && h < 12) h += 12; if (/am/i.test(atTime[3]) && h === 12) h = 0; }
|
|
520
|
+
const d = new Date(); d.setHours(h, m, 0, 0);
|
|
521
|
+
if (d <= new Date()) d.setDate(d.getDate() + 1);
|
|
522
|
+
return d;
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
485
527
|
function classifyClaudeError(raw, code) {
|
|
486
528
|
const t = raw.toLowerCase();
|
|
487
529
|
if (t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment"))
|
|
488
530
|
return "💳 API 크레딧이 부족합니다. console.anthropic.com 에서 충전해주세요.";
|
|
489
|
-
if (t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
|
|
531
|
+
if (t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
|
|
532
|
+
|| t.includes("usage limit") || t.includes("monthly limit"))
|
|
490
533
|
return "⏱️ 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.";
|
|
491
534
|
if (t.includes("overloaded") || code === 529)
|
|
492
535
|
return "🔄 Claude 서버가 일시적으로 과부하 상태입니다. 잠시 후 다시 시도해주세요.";
|
|
@@ -516,9 +559,9 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
516
559
|
: null;
|
|
517
560
|
// opts.injectMemory: 퍼시스턴트 메모리를 시스템 프롬프트에 주입 (/new 로 초기화해도 유지)
|
|
518
561
|
const mem = opts.injectMemory ? loadMemory() : "";
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const appendSys = [cfg.persona, brevity, modelHint
|
|
562
|
+
// 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
|
|
563
|
+
const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
|
|
564
|
+
const appendSys = [memoryBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
|
|
522
565
|
if (appendSys) args.push("--append-system-prompt", appendSys);
|
|
523
566
|
if (model) args.push("--model", model);
|
|
524
567
|
if (sessionId) args.push("--resume", sessionId);
|
|
@@ -545,13 +588,13 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
545
588
|
currentChild = null;
|
|
546
589
|
try {
|
|
547
590
|
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 });
|
|
591
|
+
const rawErr = j.result ?? "";
|
|
592
|
+
const text = j.is_error ? classifyClaudeError(rawErr, code) : (rawErr || "(empty response)");
|
|
593
|
+
const resetAt = j.is_error ? parseResetTime(rawErr) : null;
|
|
594
|
+
resolve({ ok: !j.is_error, text, sessionId: j.session_id, cost: j.total_cost_usd, resetAt });
|
|
552
595
|
} catch {
|
|
553
596
|
const raw = (err || out || "no output").slice(0, 3500);
|
|
554
|
-
resolve({ ok: false, text: classifyClaudeError(raw, code) });
|
|
597
|
+
resolve({ ok: false, text: classifyClaudeError(raw, code), resetAt: parseResetTime(raw) });
|
|
555
598
|
}
|
|
556
599
|
});
|
|
557
600
|
});
|
|
@@ -643,9 +686,9 @@ async function runScheduled(job) {
|
|
|
643
686
|
const footer = res.ok
|
|
644
687
|
? `\n\n— ⏰ ${label} · ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`
|
|
645
688
|
: `\n\n— ⏰ ${label}`;
|
|
646
|
-
await send(
|
|
689
|
+
for (const id of allowedIds) await send(id, (res.ok ? res.text : `⚠️ ${res.text}`) + footer);
|
|
647
690
|
} catch (e) {
|
|
648
|
-
await send(
|
|
691
|
+
for (const id of allowedIds) await send(id, t(BOT_LANG, "scheduledError", e.message));
|
|
649
692
|
} finally {
|
|
650
693
|
busy = false;
|
|
651
694
|
}
|
|
@@ -654,7 +697,7 @@ async function runScheduled(job) {
|
|
|
654
697
|
function startScheduler() {
|
|
655
698
|
// allowedChatId 없으면 결과를 보낼 곳이 없으니 비활성화. /cron add 로 나중에 작업이
|
|
656
699
|
// 늘 수 있으므로, schedule 이 지금 비어 있어도 인터벌은 항상 돌린다(없으면 no-op).
|
|
657
|
-
if (!
|
|
700
|
+
if (!allowedIds.length) {
|
|
658
701
|
console.warn("allowedChatId missing → scheduler disabled");
|
|
659
702
|
return;
|
|
660
703
|
}
|
|
@@ -795,10 +838,12 @@ async function downloadAttachment(att) {
|
|
|
795
838
|
let busy = false;
|
|
796
839
|
const msgQueue = []; // { msg, receivedAt } — busy 중 수신 메시지 대기열
|
|
797
840
|
const mediaGroups = new Map(); // media_group_id → { msgs, timer } — 미디어 그룹 수집 대기
|
|
798
|
-
let currentChild = null;
|
|
799
|
-
let currentTyping = null;
|
|
800
|
-
let prevSessionId;
|
|
801
|
-
let stopping = false;
|
|
841
|
+
let currentChild = null; // 실행 중인 claude child process (/stop 용)
|
|
842
|
+
let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
|
|
843
|
+
let prevSessionId; // /stop --reset 복원 대상
|
|
844
|
+
let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
|
|
845
|
+
let rateLimitState = null; // 마지막 레이트 리밋 { prompt, resetAt } — /reserve 용
|
|
846
|
+
let pendingRetry = null; // /reserve 로 예약된 재시도 { timer, resetAt }
|
|
802
847
|
|
|
803
848
|
async function handle(msg) {
|
|
804
849
|
const chatId = msg.chat?.id;
|
|
@@ -809,11 +854,11 @@ async function handle(msg) {
|
|
|
809
854
|
if (!text && !attachment && !msg._mediaGroup?.length) return;
|
|
810
855
|
|
|
811
856
|
// 화이트리스트
|
|
812
|
-
if (!
|
|
857
|
+
if (!allowedIds.length) {
|
|
813
858
|
await send(chatId, t(l, "needChatId", chatId));
|
|
814
859
|
return;
|
|
815
860
|
}
|
|
816
|
-
if (String(chatId)
|
|
861
|
+
if (!allowedIds.includes(String(chatId))) {
|
|
817
862
|
console.warn(`Ignoring unauthorized chatId ${chatId}`);
|
|
818
863
|
return;
|
|
819
864
|
}
|
|
@@ -928,6 +973,32 @@ async function handle(msg) {
|
|
|
928
973
|
await send(chatId, mem ? t(l, "memoryShow", mem) : t(l, "memoryEmpty"));
|
|
929
974
|
return;
|
|
930
975
|
}
|
|
976
|
+
if (text === "/reserve" || text.startsWith("/reserve ")) {
|
|
977
|
+
const arg = text.slice(8).trim();
|
|
978
|
+
if (arg === "rm") {
|
|
979
|
+
if (!pendingRetry) { await send(chatId, t(l, "reserveNone")); return; }
|
|
980
|
+
clearTimeout(pendingRetry.timer); pendingRetry = null;
|
|
981
|
+
await send(chatId, t(l, "reserveRm"));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (!rateLimitState) { await send(chatId, t(l, "reserveNoLimit")); return; }
|
|
985
|
+
const { resetAt } = rateLimitState;
|
|
986
|
+
const reservePrompt = arg || rateLimitState.prompt;
|
|
987
|
+
if (pendingRetry) clearTimeout(pendingRetry.timer);
|
|
988
|
+
const capturedPrompt = reservePrompt;
|
|
989
|
+
const capturedMsg = msg;
|
|
990
|
+
const delay = Math.max(resetAt - Date.now(), 1000);
|
|
991
|
+
pendingRetry = {
|
|
992
|
+
resetAt,
|
|
993
|
+
timer: setTimeout(() => {
|
|
994
|
+
pendingRetry = null;
|
|
995
|
+
handle({ ...capturedMsg, text: capturedPrompt, caption: undefined });
|
|
996
|
+
}, delay),
|
|
997
|
+
};
|
|
998
|
+
const timeStr = resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
|
|
999
|
+
await send(chatId, t(l, "reserveOk", timeStr));
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
931
1002
|
|
|
932
1003
|
if (busy) {
|
|
933
1004
|
msgQueue.push({ msg, receivedAt: Date.now() });
|
|
@@ -979,10 +1050,16 @@ async function handle(msg) {
|
|
|
979
1050
|
saveState(state);
|
|
980
1051
|
}
|
|
981
1052
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
: "";
|
|
985
|
-
|
|
1053
|
+
if (!res.ok) {
|
|
1054
|
+
// 레이트 리밋이고 리셋 시간을 알면 /reserve 힌트 추가
|
|
1055
|
+
const hint = res.resetAt ? t(l, "reserveHint") : "";
|
|
1056
|
+
rateLimitState = res.resetAt ? { prompt, resetAt: res.resetAt } : null;
|
|
1057
|
+
if (!stopping) await send(chatId, `⚠️ ${res.text}${hint}`);
|
|
1058
|
+
} else {
|
|
1059
|
+
rateLimitState = null;
|
|
1060
|
+
const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
|
|
1061
|
+
if (!stopping) await send(chatId, res.text + footer);
|
|
1062
|
+
}
|
|
986
1063
|
} catch (e) {
|
|
987
1064
|
if (!stopping) await send(chatId, t(l, "botError", e.message));
|
|
988
1065
|
} 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.17",
|
|
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": {
|