claude-telegram-bot 0.3.36 → 0.4.0
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/CHANGELOG.md +326 -0
- package/README.ko.md +95 -25
- package/README.md +108 -31
- package/bot.mjs +456 -58
- package/config.example.json +11 -1
- package/ctb.mjs +84 -29
- package/package.json +5 -2
package/bot.mjs
CHANGED
|
@@ -84,6 +84,7 @@ const BOT_DIR = join(DATA_DIR, ".claude-bot");
|
|
|
84
84
|
const STATE_PATH = join(BOT_DIR, stateFile);
|
|
85
85
|
const ATTACH_DIR = join(BOT_DIR, "attachments");
|
|
86
86
|
const MEMORY_PATH = join(BOT_DIR, "memory.md"); // /new 로 초기화해도 유지되는 퍼시스턴트 메모리
|
|
87
|
+
const CODEX_HANDOFF_PATH = join(BOT_DIR, "codex-handoff.md"); // Codex fallback 작업을 Claude에 넘길 요약
|
|
87
88
|
const LEGACY_STATE_PATH = join(DATA_DIR, stateFile); // 구버전(루트 직하) 호환
|
|
88
89
|
const LEGACY_ATTACH_DIR = join(DATA_DIR, "attachments");
|
|
89
90
|
|
|
@@ -112,6 +113,11 @@ if (!existsSync(CONFIG_PATH)) {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
116
|
+
const DEFAULT_PROVIDER = cfg.provider || "claude";
|
|
117
|
+
if (!["claude", "codex"].includes(DEFAULT_PROVIDER)) {
|
|
118
|
+
console.error(`Invalid provider: ${DEFAULT_PROVIDER} (expected claude or codex)`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
115
121
|
console.log({ ...cfg, token: cfg.token ? "<redacted>" : "(none)" });
|
|
116
122
|
const TG = `https://api.telegram.org/bot${cfg.token}`;
|
|
117
123
|
// allowedChatId 는 문자열 또는 배열 모두 허용 (하위 호환)
|
|
@@ -136,6 +142,7 @@ const STR = {
|
|
|
136
142
|
"• /new — reset conversation context (new session)\n" +
|
|
137
143
|
"• /compact — compress context to free up space (keeps the session)\n" +
|
|
138
144
|
"• /plan <request> — plan only (no edits), then approve/cancel to run for real\n" +
|
|
145
|
+
"• Codex fallback can run automatically when Claude hits a limit (if enabled)\n" +
|
|
139
146
|
"• /ollama — toggle Ollama chat mode (bypass Claude, use local LLM)\n" +
|
|
140
147
|
"• /stop — stop the current task · /stop --reset to also roll back the session\n" +
|
|
141
148
|
"• /cron — list tasks · /cron add <natural language> to add · /cron rm <id> to remove\n" +
|
|
@@ -144,21 +151,26 @@ const STR = {
|
|
|
144
151
|
"• /reserve — show retry queue status at usage-limit reset · /reserve rm to cancel\n" +
|
|
145
152
|
"• /restart — restart the bot (after a syntax check)\n" +
|
|
146
153
|
"• /status — bot status & version\n" +
|
|
154
|
+
"• /provider — view / switch the default provider\n" +
|
|
147
155
|
"• /model — view / switch the model\n" +
|
|
156
|
+
"• /autocompact — view / set the auto-compact token threshold\n" +
|
|
148
157
|
"• /id — show this chat ID\n" +
|
|
149
158
|
`\nWorking dir: ${cfg.projectDir}\nPermission mode: ${cfg.permissionMode}`,
|
|
150
159
|
newSession: "🆕 Started a new conversation (previous context cleared).",
|
|
151
160
|
compactOk: "🗜️ Context compacted. The conversation continues with a summary.",
|
|
152
161
|
compactFail: (m) => `⚠️ Compact failed: ${m}`,
|
|
153
162
|
compactNoSession: "No active session to compact. Just send a message to start one.",
|
|
163
|
+
compactProviderUnsupported: "/compact is currently available only with provider=claude.",
|
|
154
164
|
autoCompact: "🗜️ Auto-compacted context (conversation was getting long).",
|
|
155
165
|
planUsage: "Usage: `/plan <request>` — e.g. `/plan add input validation to the signup form`",
|
|
156
166
|
planApprove: "✅ Proceed",
|
|
157
167
|
planCancel: "❌ Cancel",
|
|
158
168
|
planCancelled: "❌ Plan cancelled. No changes were made.",
|
|
159
169
|
planNoPending: "No pending plan to approve (it may have expired after /new). Send /plan again.",
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
planProviderUnsupported: "/plan approval flow currently requires provider=claude.",
|
|
171
|
+
testFallbackDisabled: "⚠️ No fallback is enabled. Set `\"codexFallback\": true` (recommended) or `\"ollamaFallback\": true` in config.json.",
|
|
172
|
+
testFallbackFail: (m) => `⚠️ Fallback test failed: ${m}`,
|
|
173
|
+
ollamaDisabled: "⚠️ Ollama mode is not enabled. Set `\"ollamaFallback\": true` in config.json.",
|
|
162
174
|
ollamaOn: "🌙 Ollama mode on. Messages will now go to Ollama. Your Claude session is preserved.",
|
|
163
175
|
ollamaOff: "✅ Ollama mode off. Back to Claude.",
|
|
164
176
|
busy: "⏳ A previous task is still running. Please try again when it finishes.",
|
|
@@ -166,7 +178,7 @@ const STR = {
|
|
|
166
178
|
stopOk: "🛑 Task stopped.",
|
|
167
179
|
stopReset: "🛑 Task stopped and session rolled back to before the task.",
|
|
168
180
|
stopNoop: "No task is running.",
|
|
169
|
-
localBusy: "💻 A local `ctb
|
|
181
|
+
localBusy: "💻 A local `ctb` session is active. Send a message when it's done.",
|
|
170
182
|
needChatId: (id) => `Add this chat ID to "allowedChatId" in config.json:\n${id}`,
|
|
171
183
|
cronEmpty:
|
|
172
184
|
"No scheduled tasks yet.\nAdd one in plain language, e.g. `/cron add summarize open issues every weekday at 9am`.",
|
|
@@ -197,7 +209,10 @@ const STR = {
|
|
|
197
209
|
status: (i) =>
|
|
198
210
|
`🤖 ${i.name}\n` +
|
|
199
211
|
`• Version: ${i.version}\n` +
|
|
212
|
+
`• Provider: ${i.provider}\n` +
|
|
213
|
+
`• CLIs: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
|
|
200
214
|
`• Model: ${i.model}\n` +
|
|
215
|
+
`• Fallback: ${i.fallback}\n` +
|
|
201
216
|
`• Session: ${i.hasSession ? "active" : "none (fresh)"}\n` +
|
|
202
217
|
`• Scheduled jobs: ${i.jobs}\n` +
|
|
203
218
|
`• Project: ${i.projectDir}\n` +
|
|
@@ -208,6 +223,17 @@ const STR = {
|
|
|
208
223
|
`/model default — clear the override`,
|
|
209
224
|
modelSet: (m) => `🧠 Model set to: ${m}`,
|
|
210
225
|
modelReset: (def) => `🧠 Model reset to default (${def}).`,
|
|
226
|
+
providerStatus: (cur, def) => `🤖 Provider: ${cur}${cur === def ? " (config default)" : ` (config default: ${def})`}\nSwitch: /provider claude · /provider codex · /provider default`,
|
|
227
|
+
providerSet: (provider) => `🤖 Default provider set to ${provider}. Existing Claude and Codex sessions are preserved separately.`,
|
|
228
|
+
providerReset: (provider) => `🤖 Provider reset to the config default (${provider}).`,
|
|
229
|
+
providerUsage: "Usage: /provider claude · /provider codex · /provider default",
|
|
230
|
+
autoCompactStatus: (cur, def) =>
|
|
231
|
+
`🗜️ Auto-compact threshold: ${cur} tokens${cur === def ? " (default)" : ""}\n` +
|
|
232
|
+
"Set: `/autocompact <number>` · `/autocompact off` to disable · `/autocompact default` to reset",
|
|
233
|
+
autoCompactSet: (n) => `🗜️ Auto-compact threshold set to ${n} tokens.`,
|
|
234
|
+
autoCompactOff: "🗜️ Auto-compact disabled.",
|
|
235
|
+
autoCompactReset: (def) => `🗜️ Auto-compact threshold reset to default (${def}).`,
|
|
236
|
+
autoCompactUsage: "Usage: `/autocompact <number>` · `/autocompact off` · `/autocompact default`",
|
|
211
237
|
memoryEmpty: "No memory yet. Use `/remember <text>` to add.",
|
|
212
238
|
memoryShow: (m) => `💾 Memory:\n\`\`\`\n${m}\n\`\`\``,
|
|
213
239
|
memoryCleared: "🧹 Memory cleared.",
|
|
@@ -228,6 +254,7 @@ const STR = {
|
|
|
228
254
|
"• /new — 대화 맥락 초기화 (새 세션)\n" +
|
|
229
255
|
"• /compact — 컨텍스트 압축 (세션 유지, 공간 확보)\n" +
|
|
230
256
|
"• /plan <요청> — 계획만 세우기 (편집 없음) → 승인/취소로 실제 실행\n" +
|
|
257
|
+
"• Codex 폴백 활성화 시 Claude 한도 도달 때 자동으로 대신 실행\n" +
|
|
231
258
|
"• /ollama — Ollama 채팅 모드 토글 (Claude 우회, 로컬 LLM 사용)\n" +
|
|
232
259
|
"• /stop — 진행 중인 작업 중단 · /stop --reset 으로 세션도 되돌리기\n" +
|
|
233
260
|
"• /cron — 예약 작업 보기 · /cron add <자연어>로 추가 · /cron rm <번호>로 삭제\n" +
|
|
@@ -236,7 +263,9 @@ const STR = {
|
|
|
236
263
|
"• /reserve — 한도 리셋 시 대기열 상태 확인 · /reserve rm 으로 취소\n" +
|
|
237
264
|
"• /restart — 봇 재시작 (문법 검사 후 안전하게)\n" +
|
|
238
265
|
"• /status — 봇 상태·버전 보기\n" +
|
|
266
|
+
"• /provider — 기본 provider 보기·전환\n" +
|
|
239
267
|
"• /model — 모델 보기·전환\n" +
|
|
268
|
+
"• /autocompact — 자동 압축 임계값 보기·설정\n" +
|
|
240
269
|
"• /id — 이 채팅 ID 확인\n" +
|
|
241
270
|
`\n작업 폴더: ${cfg.projectDir}\n권한 모드: ${cfg.permissionMode}`,
|
|
242
271
|
newSession: "🆕 새 대화를 시작합니다 (이전 맥락 초기화).",
|
|
@@ -245,7 +274,7 @@ const STR = {
|
|
|
245
274
|
stopOk: "🛑 작업을 중단했습니다.",
|
|
246
275
|
stopReset: "🛑 작업을 중단하고 세션을 작업 이전으로 되돌렸습니다.",
|
|
247
276
|
stopNoop: "실행 중인 작업이 없습니다.",
|
|
248
|
-
localBusy: "💻 로컬 `ctb
|
|
277
|
+
localBusy: "💻 로컬 `ctb` 세션이 활성화되어 있습니다. 종료 후 메시지를 보내주세요.",
|
|
249
278
|
needChatId: (id) => `이 채팅 ID를 config.json 의 allowedChatId 에 넣으세요:\n${id}`,
|
|
250
279
|
cronEmpty:
|
|
251
280
|
"등록된 예약 작업이 없습니다.\n`/cron add 매일 아침 9시에 …` 처럼 자연어로 추가해 보세요.",
|
|
@@ -275,7 +304,10 @@ const STR = {
|
|
|
275
304
|
status: (i) =>
|
|
276
305
|
`🤖 ${i.name}\n` +
|
|
277
306
|
`• 버전: ${i.version}\n` +
|
|
307
|
+
`• 메인 provider: ${i.provider}\n` +
|
|
308
|
+
`• CLI: Claude ${i.cliVersions.claude} · Codex ${i.cliVersions.codex} · Ollama ${i.cliVersions.ollama}\n` +
|
|
278
309
|
`• 모델: ${i.model}\n` +
|
|
310
|
+
`• 폴백: ${i.fallback}\n` +
|
|
279
311
|
`• 세션: ${i.hasSession ? "이어가는 중" : "없음 (새 세션)"}\n` +
|
|
280
312
|
`• 예약 작업: ${i.jobs}개\n` +
|
|
281
313
|
`• 작업 폴더: ${i.projectDir}\n` +
|
|
@@ -286,6 +318,17 @@ const STR = {
|
|
|
286
318
|
`/model default — 오버라이드 해제`,
|
|
287
319
|
modelSet: (m) => `🧠 모델을 ${m} (으)로 설정했습니다.`,
|
|
288
320
|
modelReset: (def) => `🧠 모델을 기본값(${def})으로 되돌렸습니다.`,
|
|
321
|
+
providerStatus: (cur, def) => `🤖 현재 provider: ${cur}${cur === def ? " (config 기본값)" : ` (config 기본값: ${def})`}\n전환: /provider claude · /provider codex · /provider default`,
|
|
322
|
+
providerSet: (provider) => `🤖 기본 provider를 ${provider}(으)로 변경했습니다. Claude와 Codex의 기존 세션은 각각 유지됩니다.`,
|
|
323
|
+
providerReset: (provider) => `🤖 provider를 config 기본값(${provider})으로 되돌렸습니다.`,
|
|
324
|
+
providerUsage: "사용법: /provider claude · /provider codex · /provider default",
|
|
325
|
+
autoCompactStatus: (cur, def) =>
|
|
326
|
+
`🗜️ 자동 압축 임계값: ${cur} 토큰${cur === def ? " (기본값)" : ""}\n` +
|
|
327
|
+
"설정: `/autocompact <숫자>` · `/autocompact off` 로 끄기 · `/autocompact default` 로 초기화",
|
|
328
|
+
autoCompactSet: (n) => `🗜️ 자동 압축 임계값을 ${n} 토큰으로 설정했습니다.`,
|
|
329
|
+
autoCompactOff: "🗜️ 자동 압축을 껐습니다.",
|
|
330
|
+
autoCompactReset: (def) => `🗜️ 자동 압축 임계값을 기본값(${def})으로 되돌렸습니다.`,
|
|
331
|
+
autoCompactUsage: "사용법: `/autocompact <숫자>` · `/autocompact off` · `/autocompact default`",
|
|
289
332
|
memoryEmpty: "저장된 메모리가 없습니다. `/remember <내용>`으로 추가하세요.",
|
|
290
333
|
memoryShow: (m) => `💾 메모리:\n\`\`\`\n${m}\n\`\`\``,
|
|
291
334
|
memoryCleared: "🧹 메모리를 삭제했습니다.",
|
|
@@ -300,15 +343,18 @@ const STR = {
|
|
|
300
343
|
compactOk: "🗜️ 컨텍스트를 압축했습니다. 대화가 요약본으로 이어집니다.",
|
|
301
344
|
compactFail: (m) => `⚠️ compact 실패: ${m}`,
|
|
302
345
|
compactNoSession: "압축할 활성 세션이 없습니다. 메시지를 보내 세션을 시작하세요.",
|
|
346
|
+
compactProviderUnsupported: "/compact는 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
303
347
|
autoCompact: "🗜️ 대화가 길어져 컨텍스트를 자동 압축했습니다.",
|
|
304
348
|
planUsage: "사용법: `/plan <요청>` — 예: `/plan 회원가입 폼에 입력값 검증 추가해줘`",
|
|
305
349
|
planApprove: "✅ 진행",
|
|
306
350
|
planCancel: "❌ 취소",
|
|
307
351
|
planCancelled: "❌ 계획을 취소했습니다. 아무 변경도 없습니다.",
|
|
308
352
|
planNoPending: "승인할 계획이 없습니다 (/new 이후 만료됐을 수 있음). /plan 을 다시 보내세요.",
|
|
353
|
+
planProviderUnsupported: "/plan 승인 흐름은 현재 provider=claude에서만 사용할 수 있습니다.",
|
|
309
354
|
contextTooLong: "⚠️ 프롬프트가 너무 깁니다. `/compact` 로 컨텍스트를 압축하거나 `/new` 로 새 세션을 시작하세요.",
|
|
310
|
-
testFallbackDisabled: "⚠️
|
|
311
|
-
testFallbackFail: (m) => `⚠️
|
|
355
|
+
testFallbackDisabled: "⚠️ 폴백이 비활성화 상태입니다. config.json에 `\"codexFallback\": true`(권장) 또는 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
356
|
+
testFallbackFail: (m) => `⚠️ 폴백 테스트 실패: ${m}`,
|
|
357
|
+
ollamaDisabled: "⚠️ Ollama 모드가 비활성화 상태입니다. config.json에 `\"ollamaFallback\": true` 를 추가하세요.",
|
|
312
358
|
ollamaOn: "🌙 Ollama 모드 켜짐. 이제 메시지는 Ollama로 처리됩니다. Claude 세션은 유지됩니다.",
|
|
313
359
|
ollamaOff: "✅ Ollama 모드 꺼짐. 다시 Claude로 처리합니다.",
|
|
314
360
|
},
|
|
@@ -334,7 +380,9 @@ const COMMANDS = {
|
|
|
334
380
|
{ command: "cron", description: "List / add / remove scheduled tasks" },
|
|
335
381
|
{ command: "restart", description: "Restart the bot (after syntax check)" },
|
|
336
382
|
{ command: "status", description: "Bot status / version" },
|
|
383
|
+
{ command: "provider", description: "View / switch the default provider" },
|
|
337
384
|
{ command: "model", description: "View / switch the model" },
|
|
385
|
+
{ command: "autocompact", description: "View / set the auto-compact token threshold" },
|
|
338
386
|
{ command: "reserve", description: "Schedule retry when usage limit resets · /reserve rm to cancel" },
|
|
339
387
|
{ command: "id", description: "Show this chat ID" },
|
|
340
388
|
{ command: "help", description: "Help" },
|
|
@@ -350,7 +398,9 @@ const COMMANDS = {
|
|
|
350
398
|
{ command: "cron", description: "예약 작업 보기·추가·삭제" },
|
|
351
399
|
{ command: "restart", description: "봇 재시작 (문법 검사 후)" },
|
|
352
400
|
{ command: "status", description: "봇 상태·버전 보기" },
|
|
401
|
+
{ command: "provider", description: "기본 provider 보기·전환" },
|
|
353
402
|
{ command: "model", description: "모델 보기·전환" },
|
|
403
|
+
{ command: "autocompact", description: "자동 압축 임계값 보기·설정" },
|
|
354
404
|
{ command: "reserve", description: "한도 리셋 시 재시도 예약 · /reserve rm 으로 취소" },
|
|
355
405
|
{ command: "id", description: "이 채팅 ID 확인" },
|
|
356
406
|
{ command: "help", description: "도움말" },
|
|
@@ -424,6 +474,28 @@ function saveMemory(content) {
|
|
|
424
474
|
writeFileSync(MEMORY_PATH, content);
|
|
425
475
|
}
|
|
426
476
|
|
|
477
|
+
function loadCodexHandoff(maxChars = 12000) {
|
|
478
|
+
try {
|
|
479
|
+
const text = readFileSync(CODEX_HANDOFF_PATH, "utf8").trim();
|
|
480
|
+
return text.length > maxChars ? text.slice(-maxChars) : text;
|
|
481
|
+
} catch {
|
|
482
|
+
return "";
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function appendCodexHandoff({ prompt, response, sessionId }) {
|
|
487
|
+
const entry =
|
|
488
|
+
`\n\n## ${new Date().toISOString()}\n` +
|
|
489
|
+
`Codex session: ${sessionId || "(unknown)"}\n\n` +
|
|
490
|
+
`### Telegram prompt\n${String(prompt || "").trim() || "(empty)"}\n\n` +
|
|
491
|
+
`### Codex response\n${String(response || "").trim() || "(empty)"}\n`;
|
|
492
|
+
try {
|
|
493
|
+
writeFileSync(CODEX_HANDOFF_PATH, loadCodexHandoff(80000) + entry);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
console.error("Failed to append Codex handoff", e.message);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
427
499
|
// ── 상태 (세션 이어가기용) ────────────────────────────────────────────────
|
|
428
500
|
function loadState() {
|
|
429
501
|
// 새 경로(.claude-bot/) 우선, 없으면 구버전 루트 경로로 폴백(이주 실패 시 안전망).
|
|
@@ -442,7 +514,12 @@ function saveState(s) {
|
|
|
442
514
|
}
|
|
443
515
|
}
|
|
444
516
|
migrateData(); // 루트 직하 → .claude-bot/ 1회 이주(있으면) 후 state 로드
|
|
445
|
-
let state = loadState(); // { sessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
517
|
+
let state = loadState(); // { sessionId?, codexSessionId?, cron?: [{ id, cron, prompt, label? }], restartNotify?, model? }
|
|
518
|
+
if (state.provider && !["claude", "codex"].includes(state.provider)) {
|
|
519
|
+
console.warn(`Ignoring invalid provider override in state: ${state.provider}`);
|
|
520
|
+
state.provider = undefined;
|
|
521
|
+
}
|
|
522
|
+
const currentProvider = () => state.provider || DEFAULT_PROVIDER;
|
|
446
523
|
|
|
447
524
|
// ── 텔레그램 헬퍼 ─────────────────────────────────────────────────────────
|
|
448
525
|
async function tg(method, body) {
|
|
@@ -637,7 +714,11 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
637
714
|
const mem = opts.injectMemory ? loadMemory() : "";
|
|
638
715
|
// 메모리는 persona보다 앞에 배치하고 헤더를 강화 → persona가 덮어쓰는 것 방지
|
|
639
716
|
const memoryBlock = mem ? `## RULES (must follow before anything else)\n${mem}` : null;
|
|
640
|
-
const
|
|
717
|
+
const handoff = opts.injectHandoff !== false ? loadCodexHandoff() : "";
|
|
718
|
+
const handoffBlock = handoff
|
|
719
|
+
? `## CODEX FALLBACK HANDOFF\nClaude and Codex sessions are separate. The notes below summarize work Codex handled while Claude was unavailable; use them as context, not as your own prior messages.\n${handoff}`
|
|
720
|
+
: null;
|
|
721
|
+
const appendSys = [memoryBlock, handoffBlock, cfg.persona, brevity, modelHint].filter(Boolean).join("\n\n");
|
|
641
722
|
if (appendSys) args.push("--append-system-prompt", appendSys);
|
|
642
723
|
if (model) args.push("--model", model);
|
|
643
724
|
if (sessionId) args.push("--resume", sessionId);
|
|
@@ -681,27 +762,224 @@ function runClaude(prompt, sessionId, opts = {}) {
|
|
|
681
762
|
});
|
|
682
763
|
}
|
|
683
764
|
|
|
765
|
+
function extractCodexTextFromJsonl(out) {
|
|
766
|
+
let sessionId;
|
|
767
|
+
let text = "";
|
|
768
|
+
for (const line of String(out || "").split("\n")) {
|
|
769
|
+
if (!line.trim()) continue;
|
|
770
|
+
try {
|
|
771
|
+
const j = JSON.parse(line);
|
|
772
|
+
// Current Codex CLI JSONL format.
|
|
773
|
+
if (j.type === "thread.started" && j.thread_id) sessionId = j.thread_id;
|
|
774
|
+
if (j.type === "item.completed" && j.item?.type === "agent_message" && j.item.text) {
|
|
775
|
+
text = j.item.text;
|
|
776
|
+
}
|
|
777
|
+
// Older app-server event format, kept for compatibility.
|
|
778
|
+
if (j.type === "session_meta" && j.payload?.id) sessionId = j.payload.id;
|
|
779
|
+
const p = j.payload;
|
|
780
|
+
if (p?.type === "message" && p.role === "assistant" && Array.isArray(p.content)) {
|
|
781
|
+
const parts = p.content
|
|
782
|
+
.filter((c) => c.type === "output_text" && c.text)
|
|
783
|
+
.map((c) => c.text);
|
|
784
|
+
if (parts.length) text = parts.join("\n");
|
|
785
|
+
}
|
|
786
|
+
} catch {}
|
|
787
|
+
}
|
|
788
|
+
return { sessionId, text: text.trim() };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function resolveCodexBin() {
|
|
792
|
+
if (cfg.codexBin) return cfg.codexBin;
|
|
793
|
+
const candidates = [
|
|
794
|
+
join(dirname(process.execPath), "codex"),
|
|
795
|
+
join(process.env.HOME || "", ".local", "bin", "codex"),
|
|
796
|
+
"/opt/homebrew/bin/codex",
|
|
797
|
+
"/usr/local/bin/codex",
|
|
798
|
+
];
|
|
799
|
+
return candidates.find((p) => p && existsSync(p)) || "codex";
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── Codex 폴백 실행 ──────────────────────────────────────────────────────
|
|
803
|
+
// Claude와 Codex 세션은 호환되지 않는다. Codex는 별도 session id를 state.codexSessionId에
|
|
804
|
+
// 저장하고, Claude 복귀 시 codex-handoff.md 요약을 시스템 프롬프트로 넘겨 맥락을 연결한다.
|
|
805
|
+
function runCodex(prompt, lang = "en", opts = {}) {
|
|
806
|
+
return new Promise((resolve) => {
|
|
807
|
+
const header = opts.noHeader ? "" : (lang === "ko" ? "🤖 Codex 폴백\n\n" : "🤖 Codex fallback\n\n");
|
|
808
|
+
const lastPath = join(BOT_DIR, `codex-last-message-${process.pid}.txt`);
|
|
809
|
+
const model = state.codexModel || cfg.codexModel;
|
|
810
|
+
const timeoutMs = cfg.codexTimeout || 600_000;
|
|
811
|
+
const args = ["exec"];
|
|
812
|
+
const resumeSessionId = Object.prototype.hasOwnProperty.call(opts, "sessionId")
|
|
813
|
+
? opts.sessionId
|
|
814
|
+
: state.codexSessionId;
|
|
815
|
+
let codexPrompt = prompt;
|
|
816
|
+
if (!resumeSessionId && opts.injectMemory) {
|
|
817
|
+
const context = [loadMemory(), cfg.persona, cfg.appendSystemPrompt].filter(Boolean).join("\n\n");
|
|
818
|
+
if (context) codexPrompt = `Project instructions and persistent context:\n${context}\n\nUser request:\n${prompt}`;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (resumeSessionId) {
|
|
822
|
+
args.push("resume", "--json", "-o", lastPath);
|
|
823
|
+
if (model) args.push("--model", model);
|
|
824
|
+
args.push(resumeSessionId, "-");
|
|
825
|
+
} else {
|
|
826
|
+
args.push("--json", "-o", lastPath, "-C", cfg.projectDir, "--sandbox", cfg.codexSandbox || "workspace-write");
|
|
827
|
+
if (model) args.push("--model", model);
|
|
828
|
+
args.push("-");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const child = spawn(resolveCodexBin(), args, {
|
|
832
|
+
cwd: cfg.projectDir,
|
|
833
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
834
|
+
});
|
|
835
|
+
if (opts.trackChild) currentChild = child;
|
|
836
|
+
|
|
837
|
+
let settled = false;
|
|
838
|
+
const finish = (value) => {
|
|
839
|
+
if (settled) return;
|
|
840
|
+
settled = true;
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
currentChild = null;
|
|
843
|
+
resolve(value);
|
|
844
|
+
};
|
|
845
|
+
const timer = setTimeout(() => {
|
|
846
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
847
|
+
finish({ ok: false, text: `Codex timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
848
|
+
}, timeoutMs);
|
|
849
|
+
|
|
850
|
+
let out = "", err = "";
|
|
851
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
852
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
853
|
+
child.stdin.on("error", () => {});
|
|
854
|
+
child.on("error", (e) => finish({ ok: false, text: `Failed to start codex: ${e.message}` }));
|
|
855
|
+
child.on("close", (code) => {
|
|
856
|
+
const parsed = extractCodexTextFromJsonl(out);
|
|
857
|
+
let finalText = parsed.text;
|
|
858
|
+
if (!finalText) {
|
|
859
|
+
try { finalText = readFileSync(lastPath, "utf8").trim(); } catch {}
|
|
860
|
+
}
|
|
861
|
+
const sessionId = parsed.sessionId || resumeSessionId;
|
|
862
|
+
if (code === 0 && finalText) {
|
|
863
|
+
if (opts.recordHandoff !== false) appendCodexHandoff({ prompt, response: finalText, sessionId });
|
|
864
|
+
return finish({ ok: true, text: header + finalText, sessionId });
|
|
865
|
+
}
|
|
866
|
+
const raw = (err || out || finalText || "no output").slice(0, 1000);
|
|
867
|
+
finish({ ok: false, text: `Codex failed (exit ${code}):\n${raw}`, canFallback: isFallbackError(raw, code) });
|
|
868
|
+
});
|
|
869
|
+
child.stdin.end(codexPrompt);
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function runPrimary(prompt, opts = {}) {
|
|
874
|
+
if (currentProvider() === "codex") {
|
|
875
|
+
return runCodex(prompt, opts.lang || BOT_LANG, {
|
|
876
|
+
noHeader: true,
|
|
877
|
+
trackChild: opts.trackChild,
|
|
878
|
+
injectMemory: opts.injectMemory,
|
|
879
|
+
recordHandoff: opts.recordHandoff,
|
|
880
|
+
...(Object.prototype.hasOwnProperty.call(opts, "sessionId") ? { sessionId: opts.sessionId } : {}),
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return runClaude(prompt, opts.sessionId, opts);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// launchd 데몬은 로그인 셸(zsh)을 거치지 않아 .zshrc/brew shellenv의 PATH를 상속받지 못한다
|
|
887
|
+
// (claudeBin과 동일한 이유). ollamaBin 미지정 시 흔한 설치 경로를 순서대로 탐색한다.
|
|
888
|
+
function resolveOllamaBin() {
|
|
889
|
+
if (cfg.ollamaBin) return cfg.ollamaBin;
|
|
890
|
+
const candidates = ["/opt/homebrew/bin/ollama", "/usr/local/bin/ollama", "/usr/bin/ollama"];
|
|
891
|
+
return candidates.find(existsSync) || "ollama";
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function getCliVersion(bin, kind) {
|
|
895
|
+
return new Promise((resolve) => {
|
|
896
|
+
const child = spawn(bin, ["--version"], {
|
|
897
|
+
cwd: cfg.projectDir,
|
|
898
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
899
|
+
});
|
|
900
|
+
let output = "", settled = false;
|
|
901
|
+
const finish = (value) => {
|
|
902
|
+
if (settled) return;
|
|
903
|
+
settled = true;
|
|
904
|
+
clearTimeout(timer);
|
|
905
|
+
resolve(value);
|
|
906
|
+
};
|
|
907
|
+
child.stdout.on("data", (d) => { output += d; });
|
|
908
|
+
child.stderr.on("data", (d) => { output += d; });
|
|
909
|
+
child.on("error", () => finish("unavailable"));
|
|
910
|
+
child.on("close", () => {
|
|
911
|
+
const patterns = {
|
|
912
|
+
claude: /([0-9]+\.[0-9]+\.[0-9]+)/,
|
|
913
|
+
codex: /codex(?:-cli)?\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
914
|
+
ollama: /(?:client\s+version\s+is|ollama\s+version\s+is)\s+v?([0-9]+\.[0-9]+\.[0-9]+)/i,
|
|
915
|
+
};
|
|
916
|
+
finish(output.match(patterns[kind])?.[1] || "unknown");
|
|
917
|
+
});
|
|
918
|
+
const timer = setTimeout(() => {
|
|
919
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
920
|
+
finish("unavailable");
|
|
921
|
+
}, 3000);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function getCliVersions() {
|
|
926
|
+
const [claude, codex, ollama] = await Promise.all([
|
|
927
|
+
getCliVersion(cfg.claudeBin || "claude", "claude"),
|
|
928
|
+
getCliVersion(resolveCodexBin(), "codex"),
|
|
929
|
+
getCliVersion(resolveOllamaBin(), "ollama"),
|
|
930
|
+
]);
|
|
931
|
+
return { claude, codex, ollama };
|
|
932
|
+
}
|
|
933
|
+
|
|
684
934
|
// ── Ollama 폴백 실행 ──────────────────────────────────────────────────────
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
935
|
+
// `ollama launch claude` 로 Claude Code CLI를 로컬 모델로 구동한다. opts.sessionId가
|
|
936
|
+
// 있으면 `--resume`으로 기존 대화 맥락을 그대로 이어받는다(HTTP api/chat 방식과 달리 세션 유지).
|
|
937
|
+
// 터미널 검증: ollama launch claude --model <m> --yes -- -p -- <prompt> --resume <id>
|
|
938
|
+
function runOllama(prompt, lang = "en", opts = {}) {
|
|
939
|
+
return new Promise((resolve) => {
|
|
940
|
+
const header = opts.noHeader ? "" : (lang === "ko"
|
|
941
|
+
? "🌙 Claude가 잠시 쉬고 있어요. 그동안 저(로컬 모델)는 복귀하면 Claude에게 넘길 내용을 정리하는 걸 도와드릴게요 — 코딩·파일 작업은 Claude가 돌아온 뒤에요.\n\n"
|
|
942
|
+
: "🌙 Claude is resting right now. Meanwhile I (a local model) can help you jot down and organize what to hand off once it's back — coding and file work waits for Claude.\n\n");
|
|
943
|
+
const model = cfg.ollamaModel || "qwen3.5:4b";
|
|
944
|
+
const brevity = "This reply is delivered over Telegram. Be concise — short paragraphs and lists, no filler intro/summary. Reply in the user's language.";
|
|
945
|
+
// 폴백 경로에서만: Claude가 막혀 소형 로컬 모델로 대응 중임을 알리고, 코딩·도구 작업을 시도하는
|
|
946
|
+
// 대신 사용자가 Claude 복귀 후 이어갈 수 있도록 요청 정리·요약을 돕는 조수 역할을 지시한다.
|
|
947
|
+
const fallbackRole = opts.fallback
|
|
948
|
+
? "You are a small local model standing in because Claude is temporarily unavailable (rate-limited or out of credits). Do NOT attempt coding, file edits, or tool use — you can't do those reliably. Instead, act as a note-taker: help the user capture, organize, and draft what they'll ask Claude to do once it's back. Summaries, request drafts, and tidy notes are your job."
|
|
949
|
+
: "";
|
|
950
|
+
const appendSys = [cfg.persona, brevity, fallbackRole].filter(Boolean).join("\n\n");
|
|
951
|
+
// `--` 앞은 ollama launch 플래그, 뒤는 claude 플래그로 전달된다.
|
|
952
|
+
const claudeArgs = ["--output-format", "json"];
|
|
953
|
+
if (appendSys) claudeArgs.push("--append-system-prompt", appendSys);
|
|
954
|
+
if (opts.sessionId) claudeArgs.push("--resume", opts.sessionId);
|
|
955
|
+
claudeArgs.push("-p", "--", prompt);
|
|
956
|
+
const args = ["launch", "claude", "--model", model, "--yes", "--", ...claudeArgs];
|
|
957
|
+
|
|
958
|
+
const child = spawn(resolveOllamaBin(), args, {
|
|
959
|
+
cwd: cfg.projectDir,
|
|
960
|
+
env: { ...process.env, ...(cfg.env || {}) },
|
|
961
|
+
});
|
|
962
|
+
// 로컬 4B 모델 콜드스타트는 첫 응답까지 수 분이 걸릴 수 있어 기본 타임아웃을 넉넉히 잡는다.
|
|
963
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), cfg.ollamaTimeout || 360_000);
|
|
964
|
+
let out = "", err = "";
|
|
965
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
966
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
967
|
+
child.on("error", (e) => { clearTimeout(timer); resolve({ ok: false, text: `Failed to start ollama: ${e.message}` }); });
|
|
968
|
+
child.on("close", () => {
|
|
969
|
+
clearTimeout(timer);
|
|
970
|
+
try {
|
|
971
|
+
const j = JSON.parse(out);
|
|
972
|
+
const text = (j.result ?? "").trim();
|
|
973
|
+
if (j.is_error || !text) return resolve({ ok: false, text: text || "no response" });
|
|
974
|
+
resolve({ ok: true, text: header + text, sessionId: j.session_id });
|
|
975
|
+
} catch {
|
|
976
|
+
// JSON 파싱 실패 시 원시 출력으로 폴백 (구버전 ollama·비-JSON 출력 대비)
|
|
977
|
+
const text = (out || "").trim();
|
|
978
|
+
if (text) return resolve({ ok: true, text: header + text });
|
|
979
|
+
resolve({ ok: false, text: (err || "no output").slice(0, 500) });
|
|
980
|
+
}
|
|
981
|
+
});
|
|
700
982
|
});
|
|
701
|
-
if (!r.ok) return { ok: false, text: `Ollama HTTP ${r.status}` };
|
|
702
|
-
const j = await r.json();
|
|
703
|
-
const text = (j.message?.content || "").trim();
|
|
704
|
-
return text ? { ok: true, text: header + text } : { ok: false, text: "no response" };
|
|
705
983
|
}
|
|
706
984
|
|
|
707
985
|
// ── 크론 스케줄러 ─────────────────────────────────────────────────────────
|
|
@@ -775,7 +1053,7 @@ async function runScheduled(job) {
|
|
|
775
1053
|
busy = true;
|
|
776
1054
|
const started = Date.now();
|
|
777
1055
|
try {
|
|
778
|
-
const res = await
|
|
1056
|
+
const res = await runPrimary(job.prompt, { sessionId: null, lang: BOT_LANG, recordHandoff: false }); // 새 세션 (state 미저장)
|
|
779
1057
|
// 조용한 예약 작업: 출력이 비었거나 정확히 "SKIP"이면 전송하지 않는다.
|
|
780
1058
|
// (예: "조건 충족 시에만 알리고, 아니면 SKIP만 출력해" 식의 조건부 알림용)
|
|
781
1059
|
if (res.ok) {
|
|
@@ -832,7 +1110,7 @@ async function extractCron(input, l) {
|
|
|
832
1110
|
"Reply with ONLY one line of JSON, no prose or code block: " +
|
|
833
1111
|
'{"cron":"0 9 * * *","prompt":"the task","label":"short name","human":"every day at 09:00"}\n\n' +
|
|
834
1112
|
`request: ${input}`;
|
|
835
|
-
const res = await
|
|
1113
|
+
const res = await runPrimary(ask, { sessionId: null, lang: l, recordHandoff: false }); // 새 세션 (대화 맥락과 분리)
|
|
836
1114
|
const m = res.text && res.text.match(/\{[\s\S]*\}/);
|
|
837
1115
|
if (!res.ok || !m) return { error: res.text || t(l, "extractFail") };
|
|
838
1116
|
let obj;
|
|
@@ -873,8 +1151,8 @@ async function handleCron(chatId, rest, l) {
|
|
|
873
1151
|
return;
|
|
874
1152
|
}
|
|
875
1153
|
busy = true;
|
|
876
|
-
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
877
1154
|
try {
|
|
1155
|
+
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
878
1156
|
const r = await extractCron(input, l);
|
|
879
1157
|
if (r.error) {
|
|
880
1158
|
await send(chatId, `⚠️ ${r.error}`);
|
|
@@ -993,11 +1271,30 @@ let rateLimitTimer = null; // 리셋 시간에 큐를 드레인하는 타이머
|
|
|
993
1271
|
async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
994
1272
|
const secs = Math.round((Date.now() - started) / 1000);
|
|
995
1273
|
if (!res.ok) {
|
|
996
|
-
//
|
|
1274
|
+
// Codex 폴백: 레이트리밋·크레딧 에러이고 codexFallback 켜져 있으면 reserve 전에 Codex로 재시도
|
|
1275
|
+
if (currentProvider() === "claude" && cfg.codexFallback && res.canFallback && !stopping) {
|
|
1276
|
+
try {
|
|
1277
|
+
const cRes = await runCodex(prompt, l, { trackChild: true });
|
|
1278
|
+
if (cRes.ok) {
|
|
1279
|
+
if (cRes.sessionId) { state.codexSessionId = cRes.sessionId; saveState(state); }
|
|
1280
|
+
await send(chatId, cRes.text); return;
|
|
1281
|
+
}
|
|
1282
|
+
console.error(cRes.text);
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
console.error("Codex fallback failed:", e.message);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// Ollama 폴백: Codex 미사용/실패 시, 레이트리밋·크레딧 에러이고 ollamaFallback 켜져 있으면 Ollama로 재시도
|
|
997
1288
|
if (cfg.ollamaFallback && res.canFallback && !stopping) {
|
|
998
1289
|
try {
|
|
999
|
-
const oRes = await runOllama(prompt, l
|
|
1000
|
-
|
|
1290
|
+
const oRes = await runOllama(prompt, l, {
|
|
1291
|
+
fallback: true,
|
|
1292
|
+
sessionId: currentProvider() === "claude" ? state.sessionId : undefined,
|
|
1293
|
+
});
|
|
1294
|
+
if (oRes.ok) {
|
|
1295
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1296
|
+
await send(chatId, oRes.text); return;
|
|
1297
|
+
}
|
|
1001
1298
|
} catch {}
|
|
1002
1299
|
}
|
|
1003
1300
|
// 리셋 시간을 알면 현재 메시지를 큐 앞에 다시 넣고 타이머 설정
|
|
@@ -1020,8 +1317,8 @@ async function replyWithClaudeResult(chatId, l, prompt, msg, res, started) {
|
|
|
1020
1317
|
const footer = `\n\n— ${secs}s${res.cost ? ` · $${res.cost.toFixed(4)}` : ""}`;
|
|
1021
1318
|
if (!stopping) await send(chatId, res.text + footer);
|
|
1022
1319
|
// 자동 컴팩션: cache_read_input_tokens 가 임계값 초과 시 자동 /compact
|
|
1023
|
-
const compactThreshold = cfg.autoCompactThreshold ?? 100000;
|
|
1024
|
-
if (compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1320
|
+
const compactThreshold = state.autoCompactThreshold ?? cfg.autoCompactThreshold ?? 100000;
|
|
1321
|
+
if (currentProvider() === "claude" && compactThreshold > 0 && res.cacheTokens > compactThreshold && state.sessionId && !stopping) {
|
|
1025
1322
|
try {
|
|
1026
1323
|
const cr = await runClaude("/compact", state.sessionId);
|
|
1027
1324
|
if (cr.sessionId) { state.sessionId = cr.sessionId; saveState(state); }
|
|
@@ -1050,7 +1347,6 @@ async function runApprovedPlan(chatId, l) {
|
|
|
1050
1347
|
return;
|
|
1051
1348
|
}
|
|
1052
1349
|
busy = true;
|
|
1053
|
-
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
1054
1350
|
const started = Date.now();
|
|
1055
1351
|
currentTyping = setInterval(
|
|
1056
1352
|
() => tg("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}),
|
|
@@ -1058,6 +1354,7 @@ async function runApprovedPlan(chatId, l) {
|
|
|
1058
1354
|
);
|
|
1059
1355
|
const syntheticMsg = { chat: { id: chatId }, text: PLAN_PROCEED_PROMPT };
|
|
1060
1356
|
try {
|
|
1357
|
+
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
1061
1358
|
prevSessionId = state.sessionId;
|
|
1062
1359
|
const res = await runClaude(PLAN_PROCEED_PROMPT, pending.sessionId, { modelHint: true, trackChild: true, injectMemory: true });
|
|
1063
1360
|
if (res.sessionId) {
|
|
@@ -1135,17 +1432,27 @@ async function handle(msg) {
|
|
|
1135
1432
|
return;
|
|
1136
1433
|
}
|
|
1137
1434
|
if (text === "/status") {
|
|
1138
|
-
const latest = await fetchLatestVersion();
|
|
1139
|
-
const versionStr = !latest || latest
|
|
1435
|
+
const [latest, cliVersions] = await Promise.all([fetchLatestVersion(), getCliVersions()]);
|
|
1436
|
+
const versionStr = !latest || !isNewer(latest, VERSION)
|
|
1140
1437
|
? VERSION
|
|
1141
1438
|
: `${VERSION} → ${latest} ✨`;
|
|
1142
1439
|
await send(
|
|
1143
1440
|
chatId,
|
|
1144
1441
|
t(l, "status", {
|
|
1145
1442
|
version: versionStr,
|
|
1443
|
+
provider: currentProvider(),
|
|
1444
|
+
cliVersions,
|
|
1146
1445
|
name: cfg.name || "claude-telegram-bot",
|
|
1147
|
-
model:
|
|
1148
|
-
|
|
1446
|
+
model: (currentProvider() === "codex" ? state.codexModel || cfg.codexModel : state.model || cfg.model)
|
|
1447
|
+
|| (l === "ko" ? "(기본값)" : "(default)"),
|
|
1448
|
+
fallback: currentProvider() === "codex"
|
|
1449
|
+
? (cfg.ollamaFallback ? "Ollama" : (l === "ko" ? "꺼짐" : "off"))
|
|
1450
|
+
: cfg.codexFallback
|
|
1451
|
+
? `Codex${state.codexSessionId ? (l === "ko" ? " (세션 있음)" : " (session active)") : ""}`
|
|
1452
|
+
: cfg.ollamaFallback
|
|
1453
|
+
? "Ollama"
|
|
1454
|
+
: (l === "ko" ? "꺼짐" : "off"),
|
|
1455
|
+
hasSession: Boolean(currentProvider() === "codex" ? state.codexSessionId : state.sessionId),
|
|
1149
1456
|
jobs: schedule.length,
|
|
1150
1457
|
projectDir: cfg.projectDir,
|
|
1151
1458
|
permissionMode: cfg.permissionMode || "acceptEdits",
|
|
@@ -1153,24 +1460,82 @@ async function handle(msg) {
|
|
|
1153
1460
|
);
|
|
1154
1461
|
return;
|
|
1155
1462
|
}
|
|
1463
|
+
if (text === "/provider" || text.startsWith("/provider ")) {
|
|
1464
|
+
if (busy) {
|
|
1465
|
+
await send(chatId, t(l, "busy"));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const arg = text.slice(9).trim().toLowerCase();
|
|
1469
|
+
if (!arg) {
|
|
1470
|
+
await send(chatId, t(l, "providerStatus", currentProvider(), DEFAULT_PROVIDER));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (arg === "default" || arg === "reset") {
|
|
1474
|
+
state.provider = undefined;
|
|
1475
|
+
saveState(state);
|
|
1476
|
+
await send(chatId, t(l, "providerReset", DEFAULT_PROVIDER));
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (!["claude", "codex"].includes(arg)) {
|
|
1480
|
+
await send(chatId, t(l, "providerUsage"));
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
state.provider = arg;
|
|
1484
|
+
state.ollamaMode = false;
|
|
1485
|
+
saveState(state);
|
|
1486
|
+
await send(chatId, t(l, "providerSet", arg));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1156
1489
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1157
1490
|
const arg = text.slice(6).trim();
|
|
1491
|
+
const modelStateKey = currentProvider() === "codex" ? "codexModel" : "model";
|
|
1492
|
+
const configuredModel = currentProvider() === "codex" ? cfg.codexModel : cfg.model;
|
|
1158
1493
|
if (!arg) {
|
|
1159
|
-
const cur = state
|
|
1494
|
+
const cur = state[modelStateKey] || configuredModel || (l === "ko" ? "(기본값)" : "(default)");
|
|
1160
1495
|
await send(chatId, t(l, "modelStatus", cur, MODEL_SUGGESTIONS));
|
|
1161
1496
|
return;
|
|
1162
1497
|
}
|
|
1163
1498
|
if (arg === "default" || arg === "reset") {
|
|
1164
|
-
state
|
|
1499
|
+
state[modelStateKey] = undefined;
|
|
1165
1500
|
saveState(state);
|
|
1166
|
-
await send(chatId, t(l, "modelReset",
|
|
1501
|
+
await send(chatId, t(l, "modelReset", configuredModel || (l === "ko" ? "기본값" : "default")));
|
|
1167
1502
|
return;
|
|
1168
1503
|
}
|
|
1169
|
-
state
|
|
1504
|
+
state[modelStateKey] = arg;
|
|
1170
1505
|
saveState(state);
|
|
1171
1506
|
await send(chatId, t(l, "modelSet", arg));
|
|
1172
1507
|
return;
|
|
1173
1508
|
}
|
|
1509
|
+
if (text === "/autocompact" || text.startsWith("/autocompact ")) {
|
|
1510
|
+
const arg = text.slice(13).trim();
|
|
1511
|
+
const def = cfg.autoCompactThreshold ?? 100000;
|
|
1512
|
+
if (!arg) {
|
|
1513
|
+
const cur = state.autoCompactThreshold ?? def;
|
|
1514
|
+
await send(chatId, t(l, "autoCompactStatus", cur, def));
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (arg === "default" || arg === "reset") {
|
|
1518
|
+
state.autoCompactThreshold = undefined;
|
|
1519
|
+
saveState(state);
|
|
1520
|
+
await send(chatId, t(l, "autoCompactReset", def));
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (arg === "off") {
|
|
1524
|
+
state.autoCompactThreshold = 0;
|
|
1525
|
+
saveState(state);
|
|
1526
|
+
await send(chatId, t(l, "autoCompactOff"));
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const n = Number(arg);
|
|
1530
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
1531
|
+
await send(chatId, t(l, "autoCompactUsage"));
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
state.autoCompactThreshold = n;
|
|
1535
|
+
saveState(state);
|
|
1536
|
+
await send(chatId, t(l, "autoCompactSet", n));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1174
1539
|
if (text === "/cron" || text.startsWith("/cron ")) {
|
|
1175
1540
|
await handleCron(chatId, text.slice(5).trim(), l);
|
|
1176
1541
|
return;
|
|
@@ -1195,6 +1560,7 @@ async function handle(msg) {
|
|
|
1195
1560
|
return;
|
|
1196
1561
|
}
|
|
1197
1562
|
if (text === "/compact") {
|
|
1563
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "compactProviderUnsupported")); return; }
|
|
1198
1564
|
if (!state.sessionId) { await send(chatId, t(l, "compactNoSession")); return; }
|
|
1199
1565
|
try {
|
|
1200
1566
|
const res = await runClaude("/compact", state.sessionId);
|
|
@@ -1209,18 +1575,26 @@ async function handle(msg) {
|
|
|
1209
1575
|
return;
|
|
1210
1576
|
}
|
|
1211
1577
|
if (text === "/ollama") {
|
|
1212
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "
|
|
1578
|
+
if (!cfg.ollamaFallback) { await send(chatId, t(l, "ollamaDisabled")); return; }
|
|
1213
1579
|
state.ollamaMode = !state.ollamaMode;
|
|
1214
1580
|
saveState(state);
|
|
1215
1581
|
await send(chatId, t(l, state.ollamaMode ? "ollamaOn" : "ollamaOff"));
|
|
1216
1582
|
return;
|
|
1217
1583
|
}
|
|
1218
1584
|
if (text === "/testfallback") {
|
|
1219
|
-
if (!cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1220
|
-
await send(chatId, "🧪 Ollama 연결 테스트 중…");
|
|
1585
|
+
if (!cfg.codexFallback && !cfg.ollamaFallback) { await send(chatId, t(l, "testFallbackDisabled")); return; }
|
|
1586
|
+
await send(chatId, cfg.codexFallback ? "🧪 Codex fallback 연결 테스트 중…" : "🧪 Ollama fallback 연결 테스트 중…");
|
|
1221
1587
|
try {
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1588
|
+
const prompt = cfg.codexFallback
|
|
1589
|
+
? "Reply with exactly one sentence: Codex fallback is working."
|
|
1590
|
+
: "Reply with exactly one sentence: Ollama fallback is working.";
|
|
1591
|
+
const res = cfg.codexFallback
|
|
1592
|
+
? await runCodex(prompt, l, { trackChild: true, recordHandoff: false })
|
|
1593
|
+
: await runOllama(prompt, l);
|
|
1594
|
+
if (res.ok) {
|
|
1595
|
+
if (cfg.codexFallback && res.sessionId) { state.codexSessionId = res.sessionId; saveState(state); }
|
|
1596
|
+
await send(chatId, res.text);
|
|
1597
|
+
}
|
|
1224
1598
|
else await send(chatId, t(l, "testFallbackFail", res.text));
|
|
1225
1599
|
} catch (e) {
|
|
1226
1600
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
@@ -1228,7 +1602,8 @@ async function handle(msg) {
|
|
|
1228
1602
|
return;
|
|
1229
1603
|
}
|
|
1230
1604
|
if (text === "/new") {
|
|
1231
|
-
state.
|
|
1605
|
+
if (currentProvider() === "codex") state.codexSessionId = undefined;
|
|
1606
|
+
else state.sessionId = undefined;
|
|
1232
1607
|
saveState(state);
|
|
1233
1608
|
await send(chatId, t(l, "newSession"));
|
|
1234
1609
|
return;
|
|
@@ -1243,7 +1618,8 @@ async function handle(msg) {
|
|
|
1243
1618
|
msgQueue.length = 0; // 대기 메시지도 취소
|
|
1244
1619
|
currentChild.kill();
|
|
1245
1620
|
if (reset) {
|
|
1246
|
-
|
|
1621
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1622
|
+
state[primarySessionKey] = prevSessionId;
|
|
1247
1623
|
saveState(state);
|
|
1248
1624
|
}
|
|
1249
1625
|
await send(chatId, t(l, reset ? "stopReset" : "stopOk"));
|
|
@@ -1310,7 +1686,6 @@ async function handle(msg) {
|
|
|
1310
1686
|
return;
|
|
1311
1687
|
}
|
|
1312
1688
|
busy = true;
|
|
1313
|
-
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
1314
1689
|
const started = Date.now();
|
|
1315
1690
|
// 긴 작업 동안 타이핑 표시 유지
|
|
1316
1691
|
currentTyping = setInterval(
|
|
@@ -1322,9 +1697,11 @@ async function handle(msg) {
|
|
|
1322
1697
|
);
|
|
1323
1698
|
|
|
1324
1699
|
try {
|
|
1700
|
+
await tg("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
1325
1701
|
// /plan <요청> — permission-mode를 강제로 plan으로 실행해 편집 없이 계획만 받고,
|
|
1326
1702
|
// 승인 버튼을 눌러야 실제 permissionMode로 이어서 실행 (runApprovedPlan).
|
|
1327
1703
|
if (text === "/plan" || text.startsWith("/plan ")) {
|
|
1704
|
+
if (currentProvider() !== "claude") { await send(chatId, t(l, "planProviderUnsupported")); return; }
|
|
1328
1705
|
const planReq = text.slice(5).trim();
|
|
1329
1706
|
if (!planReq) { await send(chatId, t(l, "planUsage")); return; }
|
|
1330
1707
|
prevSessionId = state.sessionId;
|
|
@@ -1373,18 +1750,28 @@ async function handle(msg) {
|
|
|
1373
1750
|
if (meta) prompt = prompt ? `${meta}\n\n${prompt}` : meta;
|
|
1374
1751
|
if (state.ollamaMode) {
|
|
1375
1752
|
try {
|
|
1376
|
-
const oRes = await runOllama(prompt, l, { noHeader: true });
|
|
1377
|
-
if (oRes.ok)
|
|
1753
|
+
const oRes = await runOllama(prompt, l, { noHeader: true, sessionId: state.sessionId });
|
|
1754
|
+
if (oRes.ok) {
|
|
1755
|
+
if (oRes.sessionId) { state.sessionId = oRes.sessionId; saveState(state); }
|
|
1756
|
+
await send(chatId, oRes.text);
|
|
1757
|
+
}
|
|
1378
1758
|
else await send(chatId, t(l, "testFallbackFail", oRes.text));
|
|
1379
1759
|
} catch (e) {
|
|
1380
1760
|
await send(chatId, t(l, "testFallbackFail", e.message));
|
|
1381
1761
|
}
|
|
1382
1762
|
return;
|
|
1383
1763
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1764
|
+
const primarySessionKey = currentProvider() === "codex" ? "codexSessionId" : "sessionId";
|
|
1765
|
+
prevSessionId = state[primarySessionKey]; // /stop --reset 복원 대상 저장
|
|
1766
|
+
const res = await runPrimary(prompt, {
|
|
1767
|
+
sessionId: state[primarySessionKey],
|
|
1768
|
+
lang: l,
|
|
1769
|
+
modelHint: true,
|
|
1770
|
+
trackChild: true,
|
|
1771
|
+
injectMemory: true,
|
|
1772
|
+
});
|
|
1386
1773
|
if (res.sessionId) {
|
|
1387
|
-
state
|
|
1774
|
+
state[primarySessionKey] = res.sessionId;
|
|
1388
1775
|
saveState(state);
|
|
1389
1776
|
}
|
|
1390
1777
|
await replyWithClaudeResult(chatId, l, prompt, msg, res, started);
|
|
@@ -1410,7 +1797,18 @@ function drainQueue() {
|
|
|
1410
1797
|
return i === 0 ? `[1] ${text}` : `[${i + 1}, +${dt}s] ${text}`;
|
|
1411
1798
|
})
|
|
1412
1799
|
.join("\n");
|
|
1413
|
-
|
|
1800
|
+
// 마지막 메시지 필드만 남기면 앞서 온 메시지의 사진/첨부가 유실되므로, 전체 첨부를 순서대로 모아 둠
|
|
1801
|
+
const fileIds = group.flatMap((item) => {
|
|
1802
|
+
if (item.msg._mediaGroup?.length) return item.msg._mediaGroup;
|
|
1803
|
+
const att = pickAttachment(item.msg);
|
|
1804
|
+
return att ? [att.fileId] : [];
|
|
1805
|
+
});
|
|
1806
|
+
return {
|
|
1807
|
+
...group[group.length - 1].msg,
|
|
1808
|
+
text: merged,
|
|
1809
|
+
caption: undefined,
|
|
1810
|
+
_mediaGroup: fileIds.length ? fileIds : undefined,
|
|
1811
|
+
};
|
|
1414
1812
|
}
|
|
1415
1813
|
|
|
1416
1814
|
// 미디어 그룹(여러 장 동시 전송) — 1초 대기 후 일괄 처리
|