claude-telegram-bot 0.3.23 → 0.3.27

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 (3) hide show
  1. package/bot.mjs +81 -44
  2. package/ctb.mjs +2 -2
  3. 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 서버가 일시적으로 과부하 상태입니다. 잠시 후 다시 시도해주세요.";
@@ -626,20 +634,25 @@ function runClaude(prompt, sessionId, opts = {}) {
626
634
  }
627
635
 
628
636
  // ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
629
- async function runOllama(prompt, lang = "en") {
630
- const header = lang === "ko"
637
+ async function runOllama(prompt, lang = "en", opts = {}) {
638
+ const header = opts.noHeader ? "" : (lang === "ko"
631
639
  ? "🌙 Claude가 잠시 쉬고 있어요. 제가 대신 도와드릴게요. (세션은 이어지지 않아요)\n\n"
632
- : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n";
640
+ : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n");
633
641
  const model = cfg.ollamaModel || "phi3:mini";
634
- const r = await fetch("http://localhost:11434/api/generate", {
642
+ const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
643
+ const systemParts = [cfg.persona, brevity].filter(Boolean);
644
+ const messages = [];
645
+ if (systemParts.length) messages.push({ role: "system", content: systemParts.join("\n\n") });
646
+ messages.push({ role: "user", content: prompt });
647
+ const r = await fetch("http://localhost:11434/api/chat", {
635
648
  method: "POST",
636
649
  headers: { "Content-Type": "application/json" },
637
- body: JSON.stringify({ model, prompt, stream: false }),
650
+ body: JSON.stringify({ model, messages, stream: false }),
638
651
  signal: AbortSignal.timeout(60_000),
639
652
  });
640
653
  if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
641
654
  const j = await r.json();
642
- const text = (j.response || "").trim();
655
+ const text = (j.message?.content || "").trim();
643
656
  return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
644
657
  }
645
658
 
@@ -923,8 +936,8 @@ let currentChild = null; // 실행 중인 claude child process (/stop 용)
923
936
  let currentTyping = null; // 타이핑 인터벌 (/stop 시 정리용)
924
937
  let prevSessionId; // /stop --reset 복원 대상
925
938
  let stopping = false; // /stop 처리 중 오류 메시지 억제 플래그
926
- let rateLimitState = null; // 마지막 레이트 리밋 { prompt, resetAt }/reserve
927
- let pendingRetry = null; // /reserve 예약된 재시도 { timer, resetAt }
939
+ let rateLimitUntil = null; // 레이트 리밋 활성 리셋 Date 시간까지 메시지를 큐에 쌓음
940
+ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
928
941
 
929
942
  async function handle(msg) {
930
943
  const chatId = msg.chat?.id;
@@ -944,6 +957,14 @@ async function handle(msg) {
944
957
  return;
945
958
  }
946
959
 
960
+ // 레이트리밋 활성 중: 일반 메시지는 큐에 추가, 명령어는 통과
961
+ if (rateLimitUntil && Date.now() < rateLimitUntil && !text.startsWith("/")) {
962
+ msgQueue.push({ msg, receivedAt: Date.now() });
963
+ const timeStr = rateLimitUntil.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
964
+ await send(chatId, t(l, "rateLimitQueued", msgQueue.length, timeStr));
965
+ return;
966
+ }
967
+
947
968
  // 명령어
948
969
  if (text === "/start" || text === "/help") {
949
970
  await send(chatId, t(l, "help"));
@@ -1027,6 +1048,13 @@ async function handle(msg) {
1027
1048
  }
1028
1049
  return;
1029
1050
  }
1051
+ if (text === "/ollama") {
1052
+ if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1053
+ state.ollamaMode = !state.ollamaMode;
1054
+ saveState(state);
1055
+ await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
1056
+ return;
1057
+ }
1030
1058
  if (text === "/testfallback") {
1031
1059
  if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1032
1060
  await send(chatId, "🧪 Ollama 연결 테스트 중…");
@@ -1083,27 +1111,16 @@ async function handle(msg) {
1083
1111
  if (text === "/reserve" || text.startsWith("/reserve ")) {
1084
1112
  const arg = text.slice(8).trim();
1085
1113
  if (arg === "rm") {
1086
- if (!pendingRetry) { await send(chatId, t(l, "reserveNone")); return; }
1087
- clearTimeout(pendingRetry.timer); pendingRetry = null;
1114
+ if (!rateLimitUntil && !rateLimitTimer) { await send(chatId, t(l, "reserveNone")); return; }
1115
+ if (rateLimitTimer) { clearTimeout(rateLimitTimer); rateLimitTimer = null; }
1116
+ rateLimitUntil = null;
1117
+ msgQueue.length = 0;
1088
1118
  await send(chatId, t(l, "reserveRm"));
1089
1119
  return;
1090
1120
  }
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));
1121
+ if (!rateLimitUntil) { await send(chatId, t(l, "reserveNone")); return; }
1122
+ const timeStr = rateLimitUntil.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1123
+ await send(chatId, t(l, "reserveStatus", msgQueue.length, timeStr));
1107
1124
  return;
1108
1125
  }
1109
1126
 
@@ -1152,6 +1169,16 @@ async function handle(msg) {
1152
1169
  }
1153
1170
  const meta = buildMsgMeta(msg);
1154
1171
  if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
1172
+ if (state.ollamaMode) {
1173
+ try {
1174
+ const oRes = await runOllama(prompt, l, { noHeader: true });
1175
+ if (oRes.ok) await send(chatId, oRes.text);
1176
+ else await send(chatId, t(l, "testFallbackFail", oRes.text));
1177
+ } catch (e) {
1178
+ await send(chatId, t(l, "testFallbackFail", e.message));
1179
+ }
1180
+ return;
1181
+ }
1155
1182
  prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
1156
1183
  const res = await runClaude(prompt, state.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1157
1184
  if (res.sessionId) {
@@ -1160,9 +1187,6 @@ async function handle(msg) {
1160
1187
  }
1161
1188
  const secs = Math.round((Date.now() - started) / 1000);
1162
1189
  if (!res.ok) {
1163
- // 레이트 리밋이고 리셋 시간을 알면 /reserve 힌트 추가
1164
- const hint = res.resetAt ? t(l, "reserveHint") : "";
1165
- rateLimitState = res.resetAt ? { prompt, resetAt: res.resetAt } : null;
1166
1190
  // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1167
1191
  if (cfg.ollamaFallback && res.canFallback && !stopping) {
1168
1192
  try {
@@ -1170,10 +1194,23 @@ async function handle(msg) {
1170
1194
  if (oRes.ok) { await send(chatId, oRes.text); return; }
1171
1195
  } catch {}
1172
1196
  }
1173
- const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${hint}`;
1197
+ // 리셋 시간을 알면 현재 메시지를 앞에 다시 넣고 타이머 설정
1198
+ let autoRetryMsg = "";
1199
+ if (res.resetAt && !stopping) {
1200
+ msgQueue.unshift({ msg, receivedAt: Date.now() });
1201
+ rateLimitUntil = res.resetAt;
1202
+ if (rateLimitTimer) clearTimeout(rateLimitTimer);
1203
+ rateLimitTimer = setTimeout(() => {
1204
+ rateLimitTimer = null;
1205
+ rateLimitUntil = null;
1206
+ if (msgQueue.length > 0) handle(drainQueue());
1207
+ }, Math.max(res.resetAt - Date.now(), 1000));
1208
+ const timeStr = res.resetAt.toLocaleTimeString(l === "ko" ? "ko-KR" : "en-US", { hour: "2-digit", minute: "2-digit" });
1209
+ autoRetryMsg = "\n\n" + t(l, "reserveAuto", timeStr);
1210
+ }
1211
+ const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${autoRetryMsg}`;
1174
1212
  if (!stopping) await send(chatId, errMsg);
1175
1213
  } else {
1176
- rateLimitState = null;
1177
1214
  const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
1178
1215
  if (!stopping) await send(chatId, res.text + footer);
1179
1216
  }
@@ -1184,7 +1221,7 @@ async function handle(msg) {
1184
1221
  currentTyping = null;
1185
1222
  stopping = false;
1186
1223
  busy = false;
1187
- if (msgQueue.length > 0) setImmediate(() => handle(drainQueue()));
1224
+ if (msgQueue.length > 0 && !rateLimitUntil) setImmediate(() => handle(drainQueue()));
1188
1225
  }
1189
1226
  }
1190
1227
 
package/ctb.mjs CHANGED
@@ -151,8 +151,8 @@ async function main() {
151
151
 
152
152
  async function summarizeSession(sid, lang) {
153
153
  const langInstruction = lang && lang.startsWith("ko")
154
- ? "---ctb:start--- 마커 이후에터미널 세션에서 작업을 한국어로 짧은 구문(10단어 이내)으로 요약해줘. 마크다운 없이 텍스트만. 마커 이후 중요한 작업이 없으면 정확히 이렇게만 답해: SKIP"
155
- : "After the ---ctb:start--- marker in this conversation, what was accomplished in this terminal session? One short phrase, 10 words max, plain text. If nothing significant after the marker, reply: SKIP";
154
+ ? "방금 로컬 터미널 코딩 세션이 끝났어. 대화에서 가장 최근에 나눈 내용(이 메시지 직전까지)을 바탕으로, 그 세션에서 무엇을 했는지 한국어로 짧은 구문(10단어 이내)으로 요약해줘. 마크다운 없이 텍스트만. 중요한 작업이 없었으면 정확히 이렇게만 답해: SKIP"
155
+ : "A local terminal coding session just ended. Based on the most recent exchanges in this conversation (just before this message), summarize in one short phrase (10 words max) what was accomplished. Plain text only. If nothing significant was done, reply exactly: SKIP";
156
156
  return new Promise((resolve) => {
157
157
  const child = spawn("claude", [
158
158
  "--resume", sid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.3.23",
3
+ "version": "0.3.27",
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": {