claude-telegram-bot 0.3.24 → 0.3.28

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.
Files changed (2) hide show
  1. package/bot.mjs +82 -46
  2. package/package.json +1 -1
package/bot.mjs CHANGED
@@ -135,6 +135,7 @@ const STR = {
135
135
  "• Just send a message and Claude works in the project.\n" +
136
136
  "• /new — reset conversation context (new session)\n" +
137
137
  "• /compact — compress context to free up space (keeps the session)\n" +
138
+ "• /ollama — toggle Ollama chat mode (bypass Claude, use local LLM)\n" +
138
139
  "• /stop — stop the current task · /stop --reset to also roll back the session\n" +
139
140
  "• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
140
141
  "• /remember <text> — save to persistent memory (survives /new)\n" +
@@ -151,6 +152,8 @@ const STR = {
151
152
  compactNoSession: "No active session to compact. Just send a message to start one.",
152
153
  testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
153
154
  testFallbackFail: (m) => `⚠️ Ollama test failed: ${m}`,
155
+ ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
156
+ ollamaOff: "✅ Ollama mode off. Back to Claude.",
154
157
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
155
158
  queued: (n) => `⏳ Queued (#${n}). Will run when the current task finishes.`,
156
159
  stopOk: "🛑 Task stopped.",
@@ -204,11 +207,11 @@ const STR = {
204
207
  remembered: "💾 Saved to memory.",
205
208
  rememberUsage: "Usage: /remember <text to remember>",
206
209
  memoryUsage: "Usage: /memory · /memory clear",
207
- reserveHint: "\n\nTo retry when the limit resets, send `/reserve` (or `/reserve <different message>`).",
208
- reserveOk: (time) => `⏰ Retry scheduled for ${time}. Cancel with /reserve rm.`,
209
- reserveRm: "🚫 Scheduled retry canceled.",
210
+ rateLimitQueued: (n, time) => `⏳ Queued (#${n}). Will retry at ${time}. /reserve rm to cancel.`,
211
+ reserveStatus: (n, time) => `⏳ ${n} message(s) queued. Retrying at ${time}. /reserve rm to cancel.`,
212
+ reserveAuto: (time) => `⏰ Auto-retry scheduled for ${time}. Cancel with /reserve rm.`,
213
+ reserveRm: "🚫 Queue cleared. No retry scheduled.",
210
214
  reserveNone: "No retry is scheduled.",
211
- reserveNoLimit: "No recent usage limit error. Send a message first.",
212
215
  contextTooLong: "⚠️ Prompt is too long. Use `/compact` to compress context, or `/new` to start fresh.",
213
216
  },
214
217
  ko: {
@@ -217,6 +220,7 @@ const STR = {
217
220
  "• 그냥 메시지를 보내면 Claude가 프로젝트에서 작업합니다.\n" +
218
221
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
219
222
  "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
223
+ "• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
220
224
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
221
225
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
222
226
  "• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
@@ -280,17 +284,19 @@ const STR = {
280
284
  remembered: "💾 메모리에 저장했습니다.",
281
285
  rememberUsage: "사용법: /remember <기억할 내용>",
282
286
  memoryUsage: "사용법: /memory · /memory clear",
283
- reserveHint: "\n\n리셋 재시도하려면 `/reserve` (또는 `/reserve <다른 메시지>`)를 입력하세요.",
284
- reserveOk: (time) => `⏰ ${time}에 재시도 예약됨. 취소: /reserve rm`,
285
- reserveRm: "🚫 예약된 재시도를 취소했습니다.",
287
+ rateLimitQueued: (n, time) => `⏳ 대기열에 추가됨 (${n}번째). ${time}에 자동 재시도. 취소: /reserve rm`,
288
+ reserveStatus: (n, time) => `⏳ 대기 중인 메시지 ${n}개. ${time}에 재시도 예약됨. 취소: /reserve rm`,
289
+ reserveAuto: (time) => `⏰ ${time}에 자동 재시도 예약됨. 취소: /reserve rm`,
290
+ reserveRm: "🚫 대기열을 비웠습니다. 예약 취소됨.",
286
291
  reserveNone: "예약된 재시도가 없습니다.",
287
- reserveNoLimit: "최근 한도 초과 에러가 없습니다. 먼저 메시지를 보내주세요.",
288
292
  compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
289
293
  compactFail: (m) => `⚠️ compact 실패: ${m}`,
290
294
  compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
291
295
  contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
292
296
  testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
293
297
  testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
298
+ ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
299
+ ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
294
300
  },
295
301
  };
296
302
  const t = (l, key, ...a) => {
@@ -306,6 +312,7 @@ const COMMANDS = {
306
312
  en: [
307
313
  { command: "new", description: "Reset context (new session)" },
308
314
  { command: "compact", description: "Compress context to free up space (keeps session)" },
315
+ { command: "ollama", description: "Toggle Ollama chat mode (bypass Claude, use local LLM)" },
309
316
  { command: "stop", description: "Stop the current task (--reset to roll back session)" },
310
317
  { command: "remember", description: "Save to persistent memory (survives /new)" },
311
318
  { command: "memory", description: "View or clear persistent memory" },
@@ -320,6 +327,7 @@ const COMMANDS = {
320
327
  ko: [
321
328
  { command: "new", description: "대화 맥락 초기화 (새 세션)" },
322
329
  { command: "compact", description: "컨텍스트 압축 (세션 유지, 공간 확보)" },
330
+ { command: "ollama", description: "Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM)" },
323
331
  { command: "stop", description: "작업 중단 (--reset 으로 세션 되돌리기)" },
324
332
  { command: "remember", description: "퍼시스턴트 메모리에 저장 (/new 후에도 유지)" },
325
333
  { command: "memory", description: "메모리 보기·삭제" },
@@ -527,8 +535,8 @@ function parseResetTime(raw) {
527
535
  if (inMin) return new Date(Date.now() + parseInt(inMin[1]) * 60000);
528
536
  const inHour = raw.match(/in (\d+)\s*hour/i);
529
537
  if (inHour) return new Date(Date.now() + parseInt(inHour[1]) * 3600000);
530
- // "resets at HH:MM" or "available at HH:MM"
531
- const atTime = raw.match(/(?:resets?|reset|available|retry)\s+at\s+(\d{1,2}):(\d{2})(?:\s*(AM|PM))?/i);
538
+ // "resets at HH:MM" / "resets HH:MM" / "available at HH:MM"
539
+ const atTime = raw.match(/(?:resets?|available|retry)(?:\s+at)?\s+(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
532
540
  if (atTime) {
533
541
  let h = parseInt(atTime[1]);
534
542
  const m = parseInt(atTime[2]);
@@ -544,7 +552,7 @@ function isFallbackError(raw, code) {
544
552
  const t = (raw || "").toLowerCase();
545
553
  return t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment")
546
554
  || t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
547
- || t.includes("usage limit") || t.includes("monthly limit")
555
+ || t.includes("usage limit") || t.includes("monthly limit") || t.includes("session limit")
548
556
  || t.includes("overloaded") || code === 529;
549
557
  }
550
558
 
@@ -553,7 +561,7 @@ function classifyClaudeError(raw, code) {
553
561
  if (t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment"))
554
562
  return "💳 API 크레딧이 부족합니다. console.anthropic.com 에서 충전해주세요.";
555
563
  if (t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
556
- || t.includes("usage limit") || t.includes("monthly limit"))
564
+ || t.includes("usage limit") || t.includes("monthly limit") || t.includes("session limit"))
557
565
  return "⏱️ 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.";
558
566
  if (t.includes("overloaded") || code === 529)
559
567
  return "🔄 Claude 서버가 일시적으로 과부하 상태입니다. 잠시 후 다시 시도해주세요.";
@@ -566,8 +574,7 @@ function classifyClaudeError(raw, code) {
566
574
  function runClaude(prompt, sessionId, opts = {}) {
567
575
  return new Promise((resolve) => {
568
576
  const args = [
569
- "-p",
570
- prompt,
577
+ `--print=${prompt}`,
571
578
  "--output-format",
572
579
  "json",
573
580
  "--permission-mode",
@@ -626,20 +633,25 @@ function runClaude(prompt, sessionId, opts = {}) {
626
633
  }
627
634
 
628
635
  // ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
629
- async function runOllama(prompt, lang = "en") {
630
- const header = lang === "ko"
636
+ async function runOllama(prompt, lang = "en", opts = {}) {
637
+ const header = opts.noHeader ? "" : (lang === "ko"
631
638
  ? "🌙 Claude가 잠시 쉬고 있어요. 제가 대신 도와드릴게요. (세션은 이어지지 않아요)\n\n"
632
- : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n";
639
+ : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n");
633
640
  const model = cfg.ollamaModel || "phi3:mini";
634
- const r = await fetch("http://localhost:11434/api/generate", {
641
+ const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
642
+ const systemParts = [cfg.persona, brevity].filter(Boolean);
643
+ const messages = [];
644
+ if (systemParts.length) messages.push({ role: "system", content: systemParts.join("\n\n") });
645
+ messages.push({ role: "user", content: prompt });
646
+ const r = await fetch("http://localhost:11434/api/chat", {
635
647
  method: "POST",
636
648
  headers: { "Content-Type": "application/json" },
637
- body: JSON.stringify({ model, prompt, stream: false }),
649
+ body: JSON.stringify({ model, messages, stream: false }),
638
650
  signal: AbortSignal.timeout(60_000),
639
651
  });
640
652
  if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
641
653
  const j = await r.json();
642
- const text = (j.response || "").trim();
654
+ const text = (j.message?.content || "").trim();
643
655
  return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
644
656
  }
645
657
 
@@ -923,8 +935,8 @@ let currentChild = null; // 실행 중인 claude child process (/stop 용)
923
935
  let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
924
936
  let prevSessionId; // /stop --reset 복원 대상
925
937
  let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
926
- let rateLimitState = null; // 마지막 레이트 리밋 { prompt, resetAt }/reserve
927
- let pendingRetry = null; // /reserve 예약된 재시도 { timer, resetAt }
938
+ let rateLimitUntil = null; // 레이트 리밋 활성 리셋 Date 시간까지 메시지를 큐에 쌓음
939
+ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
928
940
 
929
941
  async function handle(msg) {
930
942
  const chatId = msg.chat?.id;
@@ -944,6 +956,14 @@ async function handle(msg) {
944
956
  return;
945
957
  }
946
958
 
959
+ // 레이트리밋 활성 중: 일반 메시지는 큐에 추가, 명령어는 통과
960
+ if (rateLimitUntil && Date.now() < rateLimitUntil && !text.startsWith("/")) {
961
+ msgQueue.push({ msg, receivedAt: Date.now() });
962
+ const timeStr = rateLimitUntil.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
963
+ await send(chatId, t(l, "rateLimitQueued", msgQueue.length, timeStr));
964
+ return;
965
+ }
966
+
947
967
  // 명령어
948
968
  if (text === "/start" || text === "/help") {
949
969
  await send(chatId, t(l, "help"));
@@ -1027,6 +1047,13 @@ async function handle(msg) {
1027
1047
  }
1028
1048
  return;
1029
1049
  }
1050
+ if (text === "/ollama") {
1051
+ if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1052
+ state.ollamaMode = !state.ollamaMode;
1053
+ saveState(state);
1054
+ await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
1055
+ return;
1056
+ }
1030
1057
  if (text === "/testfallback") {
1031
1058
  if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1032
1059
  await send(chatId, "🧪 Ollama 연결 테스트 중…");
@@ -1083,27 +1110,16 @@ async function handle(msg) {
1083
1110
  if (text === "/reserve" || text.startsWith("/reserve ")) {
1084
1111
  const arg = text.slice(8).trim();
1085
1112
  if (arg === "rm") {
1086
- if (!pendingRetry) { await send(chatId, t(l, "reserveNone")); return; }
1087
- clearTimeout(pendingRetry.timer); pendingRetry = null;
1113
+ if (!rateLimitUntil && !rateLimitTimer) { await send(chatId, t(l, "reserveNone")); return; }
1114
+ if (rateLimitTimer) { clearTimeout(rateLimitTimer); rateLimitTimer = null; }
1115
+ rateLimitUntil = null;
1116
+ msgQueue.length = 0;
1088
1117
  await send(chatId, t(l, "reserveRm"));
1089
1118
  return;
1090
1119
  }
1091
- if (!rateLimitState) { await send(chatId, t(l, "reserveNoLimit")); return; }
1092
- const { resetAt } = rateLimitState;
1093
- const reservePrompt = arg || rateLimitState.prompt;
1094
- if (pendingRetry) clearTimeout(pendingRetry.timer);
1095
- const capturedPrompt = reservePrompt;
1096
- const capturedMsg = msg;
1097
- const delay = Math.max(resetAt - Date.now(), 1000);
1098
- pendingRetry = {
1099
- resetAt,
1100
- timer: setTimeout(() => {
1101
- pendingRetry = null;
1102
- handle({ ...capturedMsg, text: capturedPrompt, caption: undefined });
1103
- }, delay),
1104
- };
1105
- const timeStr = resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1106
- await send(chatId, t(l, "reserveOk", timeStr));
1120
+ if (!rateLimitUntil) { await send(chatId, t(l, "reserveNone")); return; }
1121
+ const timeStr = rateLimitUntil.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1122
+ await send(chatId, t(l, "reserveStatus", msgQueue.length, timeStr));
1107
1123
  return;
1108
1124
  }
1109
1125
 
@@ -1152,6 +1168,16 @@ async function handle(msg) {
1152
1168
  }
1153
1169
  const meta = buildMsgMeta(msg);
1154
1170
  if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
1171
+ if (state.ollamaMode) {
1172
+ try {
1173
+ const oRes = await runOllama(prompt, l, { noHeader: true });
1174
+ if (oRes.ok) await send(chatId, oRes.text);
1175
+ else await send(chatId, t(l, "testFallbackFail", oRes.text));
1176
+ } catch (e) {
1177
+ await send(chatId, t(l, "testFallbackFail", e.message));
1178
+ }
1179
+ return;
1180
+ }
1155
1181
  prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
1156
1182
  const res = await runClaude(prompt, state.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1157
1183
  if (res.sessionId) {
@@ -1160,9 +1186,6 @@ async function handle(msg) {
1160
1186
  }
1161
1187
  const secs = Math.round((Date.now() - started) / 1000);
1162
1188
  if (!res.ok) {
1163
- // 레이트 리밋이고 리셋 시간을 알면 /reserve 힌트 추가
1164
- const hint = res.resetAt ? t(l, "reserveHint") : "";
1165
- rateLimitState = res.resetAt ? { prompt, resetAt: res.resetAt } : null;
1166
1189
  // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1167
1190
  if (cfg.ollamaFallback && res.canFallback && !stopping) {
1168
1191
  try {
@@ -1170,10 +1193,23 @@ async function handle(msg) {
1170
1193
  if (oRes.ok) { await send(chatId, oRes.text); return; }
1171
1194
  } catch {}
1172
1195
  }
1173
- const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${hint}`;
1196
+ // 리셋 시간을 알면 현재 메시지를 앞에 다시 넣고 타이머 설정
1197
+ let autoRetryMsg = "";
1198
+ if (res.resetAt && !stopping) {
1199
+ msgQueue.unshift({ msg, receivedAt: Date.now() });
1200
+ rateLimitUntil = res.resetAt;
1201
+ if (rateLimitTimer) clearTimeout(rateLimitTimer);
1202
+ rateLimitTimer = setTimeout(() => {
1203
+ rateLimitTimer = null;
1204
+ rateLimitUntil = null;
1205
+ if (msgQueue.length > 0) handle(drainQueue());
1206
+ }, Math.max(res.resetAt - Date.now(), 1000));
1207
+ const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1208
+ autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1209
+ }
1210
+ const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1174
1211
  if (!stopping) await send(chatId, errMsg);
1175
1212
  } else {
1176
- rateLimitState = null;
1177
1213
  const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1178
1214
  if (!stopping) await send(chatId, res.text + footer);
1179
1215
  }
@@ -1184,7 +1220,7 @@ async function handle(msg) {
1184
1220
  currentTyping = null;
1185
1221
  stopping = false;
1186
1222
  busy = false;
1187
- if (msgQueue.length > 0) setImmediate(() => handle(drainQueue()));
1223
+ if (msgQueue.length > 0 && !rateLimitUntil) setImmediate(() => handle(drainQueue()));
1188
1224
  }
1189
1225
  }
1190
1226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.3.24",
3
+ "version": "0.3.28",
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": {