claude-telegram-bot 0.3.18 → 0.3.23

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 +122 -5
  2. package/package.json +1 -1
package/bot.mjs CHANGED
@@ -134,6 +134,7 @@ const STR = {
134
134
  `${cfg.name || "Claude Code Telegram bot"}\n\n` +
135
135
  "• Just send a message and Claude works in the project.\n" +
136
136
  "• /new — reset conversation context (new session)\n" +
137
+ "• /compact — compress context to free up space (keeps the session)\n" +
137
138
  "• /stop — stop the current task · /stop --reset to also roll back the session\n" +
138
139
  "• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
139
140
  "• /remember <text> — save to persistent memory (survives /new)\n" +
@@ -145,6 +146,11 @@ const STR = {
145
146
  "• /id — show this chat ID\n" +
146
147
  `\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
147
148
  newSession: "🆕 Started a new conversation (previous context cleared).",
149
+ compactOk: "🗜️ Context compacted. The conversation continues with a summary.",
150
+ compactFail: (m) => `⚠️ Compact failed: ${m}`,
151
+ compactNoSession: "No active session to compact. Just send a message to start one.",
152
+ testFallbackDisabled: "⚠️ Ollama fallback is not enabled. Set `\"ollamaFallback\": true` in config.json.",
153
+ testFallbackFail: (m) => `⚠️ Ollama test failed: ${m}`,
148
154
  busy: "⏳ A previous task is still running. Please try again when it finishes.",
149
155
  queued: (n) => `⏳ Queued (#${n}). Will run when the current task finishes.`,
150
156
  stopOk: "🛑 Task stopped.",
@@ -203,12 +209,14 @@ const STR = {
203
209
  reserveRm: "🚫 Scheduled retry canceled.",
204
210
  reserveNone: "No retry is scheduled.",
205
211
  reserveNoLimit: "No recent usage limit error. Send a message first.",
212
+ contextTooLong: "⚠️ Prompt is too long. Use `/compact` to compress context, or `/new` to start fresh.",
206
213
  },
207
214
  ko: {
208
215
  help: () =>
209
216
  `${cfg.name || "Claude Code 텔레그램 봇"}\n\n` +
210
217
  "• 그냥 메시지를 보내면 Claude가 프로젝트에서 작업합니다.\n" +
211
218
  "• /new — 대화 맥락 초기화 (새 세션)\n" +
219
+ "• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
212
220
  "• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
213
221
  "• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
214
222
  "• /remember <내용> — 퍼시스턴트 메모리에 저장 (/new 로 초기화해도 유지)\n" +
@@ -277,6 +285,12 @@ const STR = {
277
285
  reserveRm: "🚫 예약된 재시도를 취소했습니다.",
278
286
  reserveNone: "예약된 재시도가 없습니다.",
279
287
  reserveNoLimit: "최근 한도 초과 에러가 없습니다. 먼저 메시지를 보내주세요.",
288
+ compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
289
+ compactFail: (m) => `⚠️ compact 실패: ${m}`,
290
+ compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
291
+ contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
292
+ testFallbackDisabled: "⚠️ Ollama 폴백이 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
293
+ testFallbackFail: (m) => `⚠️ Ollama 테스트 실패: ${m}`,
280
294
  },
281
295
  };
282
296
  const t = (l, key, ...a) => {
@@ -291,6 +305,7 @@ const MODEL_SUGGESTIONS = ["fable", "opus", "sonnet", "haiku"];
291
305
  const COMMANDS = {
292
306
  en: [
293
307
  { command: "new", description: "Reset context (new session)" },
308
+ { command: "compact", description: "Compress context to free up space (keeps session)" },
294
309
  { command: "stop", description: "Stop the current task (--reset to roll back session)" },
295
310
  { command: "remember", description: "Save to persistent memory (survives /new)" },
296
311
  { command: "memory", description: "View or clear persistent memory" },
@@ -304,6 +319,7 @@ const COMMANDS = {
304
319
  ],
305
320
  ko: [
306
321
  { command: "new", description: "대화 맥락 초기화 (새 세션)" },
322
+ { command: "compact", description: "컨텍스트 압축 (세션 유지, 공간 확보)" },
307
323
  { command: "stop", description: "작업 중단 (--reset 으로 세션 되돌리기)" },
308
324
  { command: "remember", description: "퍼시스턴트 메모리에 저장 (/new 후에도 유지)" },
309
325
  { command: "memory", description: "메모리 보기·삭제" },
@@ -524,6 +540,14 @@ function parseResetTime(raw) {
524
540
  return null;
525
541
  }
526
542
 
543
+ function isFallbackError(raw, code) {
544
+ const t = (raw || "").toLowerCase();
545
+ return t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment")
546
+ || t.includes("rate_limit") || t.includes("rate limit") || t.includes("too many requests") || code === 429
547
+ || t.includes("usage limit") || t.includes("monthly limit")
548
+ || t.includes("overloaded") || code === 529;
549
+ }
550
+
527
551
  function classifyClaudeError(raw, code) {
528
552
  const t = raw.toLowerCase();
529
553
  if (t.includes("credit") || t.includes("balance") || t.includes("billing") || t.includes("payment"))
@@ -533,8 +557,8 @@ function classifyClaudeError(raw, code) {
533
557
  return "⏱️ 요청이 너무 많습니다. 잠시 후 다시 시도해주세요.";
534
558
  if (t.includes("overloaded") || code === 529)
535
559
  return "🔄 Claude 서버가 일시적으로 과부하 상태입니다. 잠시 후 다시 시도해주세요.";
536
- if (t.includes("context") && (t.includes("length") || t.includes("limit") || t.includes("window")))
537
- return "📏 대화 맥락이 너무 길어졌습니다. `/new` 로 새 세션을 시작해주세요.";
560
+ if (t.includes("prompt is too long") || (t.includes("context") && (t.includes("length") || t.includes("limit") || t.includes("window"))))
561
+ return "contextTooLong";
538
562
  return `Execution error (exit ${code}):\n${raw}`;
539
563
  }
540
564
 
@@ -591,15 +615,34 @@ function runClaude(prompt, sessionId, opts = {}) {
591
615
  const rawErr = j.result ?? "";
592
616
  const text = j.is_error ? classifyClaudeError(rawErr, code) : (rawErr || "(empty response)");
593
617
  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 });
618
+ const canFallback = j.is_error && isFallbackError(rawErr, code);
619
+ resolve({ ok: !j.is_error, text, sessionId: j.session_id, cost: j.total_cost_usd, resetAt, canFallback });
595
620
  } catch {
596
621
  const raw = (err || out || "no output").slice(0, 3500);
597
- resolve({ ok: false, text: classifyClaudeError(raw, code), resetAt: parseResetTime(raw) });
622
+ resolve({ ok: false, text: classifyClaudeError(raw, code), resetAt: parseResetTime(raw), canFallback: isFallbackError(raw, code) });
598
623
  }
599
624
  });
600
625
  });
601
626
  }
602
627
 
628
+ // ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
629
+ async function runOllama(prompt, lang = "en") {
630
+ const header = lang === "ko"
631
+ ? "🌙 Claude가 잠시 쉬고 있어요. 제가 대신 도와드릴게요. (세션은 이어지지 않아요)\n\n"
632
+ : "🌙 Claude is resting right now. I'll help in the meantime. (Session won't continue)\n\n";
633
+ const model = cfg.ollamaModel || "phi3:mini";
634
+ const r = await fetch("http://localhost:11434/api/generate", {
635
+ method: "POST",
636
+ headers: { "Content-Type": "application/json" },
637
+ body: JSON.stringify({ model, prompt, stream: false }),
638
+ signal: AbortSignal.timeout(60_000),
639
+ });
640
+ if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
641
+ const j = await r.json();
642
+ const text = (j.response || "").trim();
643
+ return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
644
+ }
645
+
603
646
  // ── 크론 스케줄러 ─────────────────────────────────────────────────────────
604
647
  // 표준 cron 5필드 "분 시 일 월 요일"을 의존성 0 유지를 위해 최소 파서로 직접 구현.
605
648
  // 지원: * · 목록(1,3,5) · 범위(1-5) · 스텝(*/15, 9-17/2). 요일 0·7 = 일요일.
@@ -834,6 +877,44 @@ async function downloadAttachment(att) {
834
877
  return { dest, name };
835
878
  }
836
879
 
880
+ // ── 텔레그램 메시지 메타데이터 추출 ──────────────────────────────────────
881
+ function buildMsgMeta(msg) {
882
+ const parts = [];
883
+
884
+ // 포워드 출처 (신규 API: forward_origin, 구버전 폴백: forward_from / forward_from_chat)
885
+ const fo = msg.forward_origin;
886
+ if (fo) {
887
+ if (fo.type === "user" && fo.sender_user)
888
+ parts.push(`[Forwarded from: ${fo.sender_user.first_name}${fo.sender_user.username ? ` (@${fo.sender_user.username})` : ""}]`);
889
+ else if (fo.type === "channel" && fo.chat)
890
+ parts.push(`[Forwarded from channel: ${fo.chat.title}${fo.chat.username ? ` (@${fo.chat.username})` : ""}]`);
891
+ else if (fo.type === "chat" && fo.sender_chat)
892
+ parts.push(`[Forwarded from: ${fo.sender_chat.title}]`);
893
+ else if (fo.type === "hidden_user" && fo.sender_user_name)
894
+ parts.push(`[Forwarded from: ${fo.sender_user_name} (hidden)]`);
895
+ else
896
+ parts.push(`[Forwarded message]`);
897
+ } else if (msg.forward_from) {
898
+ const u = msg.forward_from;
899
+ parts.push(`[Forwarded from: ${u.first_name}${u.username ? ` (@${u.username})` : ""}]`);
900
+ } else if (msg.forward_from_chat) {
901
+ const c = msg.forward_from_chat;
902
+ parts.push(`[Forwarded from: ${c.title}${c.username ? ` (@${c.username})` : ""}]`);
903
+ }
904
+
905
+ // 리플라이 컨텍스트
906
+ const reply = msg.reply_to_message;
907
+ if (reply) {
908
+ const replyText = (reply.text || reply.caption || "").trim().slice(0, 300);
909
+ const replyFrom = reply.from?.first_name || "unknown";
910
+ parts.push(replyText
911
+ ? `[Replying to ${replyFrom}: "${replyText}${replyText.length >= 300 ? "…" : ""}"]`
912
+ : `[Replying to ${replyFrom}'s message]`);
913
+ }
914
+
915
+ return parts.join("\n");
916
+ }
917
+
837
918
  // ── 메시지 처리 ───────────────────────────────────────────────────────────
838
919
  let busy = false;
839
920
  const msgQueue = []; // { msg, receivedAt } — busy 중 수신 메시지 대기열
@@ -932,6 +1013,32 @@ async function handle(msg) {
932
1013
  });
933
1014
  return;
934
1015
  }
1016
+ if (text === "/compact") {
1017
+ if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
1018
+ try {
1019
+ const res = await runClaude("/compact", state.sessionId);
1020
+ if (res.ok !== false) {
1021
+ await send(chatId, t(l, "compactOk"));
1022
+ } else {
1023
+ await send(chatId, t(l, "compactFail", res.text));
1024
+ }
1025
+ } catch (e) {
1026
+ await send(chatId, t(l, "compactFail", e.message));
1027
+ }
1028
+ return;
1029
+ }
1030
+ if (text === "/testfallback") {
1031
+ if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
1032
+ await send(chatId, "🧪 Ollama 연결 테스트 중…");
1033
+ try {
1034
+ const res = await runOllama("Reply with exactly one sentence: Ollama fallback is working.", l);
1035
+ if (res.ok) await send(chatId, res.text);
1036
+ else await send(chatId, t(l, "testFallbackFail", res.text));
1037
+ } catch (e) {
1038
+ await send(chatId, t(l, "testFallbackFail", e.message));
1039
+ }
1040
+ return;
1041
+ }
935
1042
  if (text === "/new") {
936
1043
  state.sessionId = undefined;
937
1044
  saveState(state);
@@ -1043,6 +1150,8 @@ async function handle(msg) {
1043
1150
  await send(chatId, t(l, "attachFail", e.message));
1044
1151
  }
1045
1152
  }
1153
+ const meta = buildMsgMeta(msg);
1154
+ if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
1046
1155
  prevSessionId = state.sessionId; // /stop --reset 복원 대상 저장
1047
1156
  const res = await runClaude(prompt, state.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
1048
1157
  if (res.sessionId) {
@@ -1054,7 +1163,15 @@ async function handle(msg) {
1054
1163
  // 레이트 리밋이고 리셋 시간을 알면 /reserve 힌트 추가
1055
1164
  const hint = res.resetAt ? t(l, "reserveHint") : "";
1056
1165
  rateLimitState = res.resetAt ? { prompt, resetAt: res.resetAt } : null;
1057
- if (!stopping) await send(chatId, `⚠️ ${res.text}${hint}`);
1166
+ // Ollama 폴백: 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
1167
+ if (cfg.ollamaFallback && res.canFallback && !stopping) {
1168
+ try {
1169
+ const oRes = await runOllama(prompt, l);
1170
+ if (oRes.ok) { await send(chatId, oRes.text); return; }
1171
+ } catch {}
1172
+ }
1173
+ const errMsg = res.text === "contextTooLong" ? t(l, "contextTooLong") : `⚠️ ${res.text}${hint}`;
1174
+ if (!stopping) await send(chatId, errMsg);
1058
1175
  } else {
1059
1176
  rateLimitState = null;
1060
1177
  const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-telegram-bot",
3
- "version": "0.3.18",
3
+ "version": "0.3.23",
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": {