companionbot 0.6.0 → 0.7.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/README.md +26 -2
- package/dist/ai/claude.js +50 -56
- package/dist/calendar/index.js +15 -13
- package/dist/cron/parser.js +60 -28
- package/dist/cron/scheduler.js +18 -9
- package/dist/cron/store.js +79 -22
- package/dist/health/index.js +66 -0
- package/dist/heartbeat/index.js +23 -2
- package/dist/memory/embeddings.js +22 -3
- package/dist/memory/vectorStore.js +39 -17
- package/dist/session/state.js +18 -1
- package/dist/telegram/handlers/commands.js +33 -6
- package/dist/telegram/handlers/messages.js +71 -4
- package/dist/telegram/utils/url.js +39 -8
- package/dist/tools/index.js +12 -10
- package/dist/updates/index.js +52 -0
- package/dist/utils/constants.js +44 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/time.js +51 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -29,12 +29,21 @@
|
|
|
29
29
|
- **일회성 예약** - "내일 오전 9시에 알려줘"
|
|
30
30
|
- **반복 작업** - "30분마다 주식 가격 확인해줘"
|
|
31
31
|
|
|
32
|
-
### 🤖 고급 기능 (v0.3.0)
|
|
32
|
+
### 🤖 고급 기능 (v0.3.0+)
|
|
33
33
|
- **서브 에이전트** - 복잡한 작업을 백그라운드에서 처리
|
|
34
34
|
- **백그라운드 실행** - 긴 명령어를 백그라운드에서 실행하고 결과 확인
|
|
35
35
|
- **파일 시스템** - 워크스페이스 내 파일 읽기/쓰기/편집
|
|
36
36
|
- **일일 메모리** - 대화 내용 자동 저장
|
|
37
37
|
|
|
38
|
+
### 🧠 시맨틱 메모리 (v0.6.0)
|
|
39
|
+
- **벡터 검색** - 임베딩 기반 관련 메모리 검색
|
|
40
|
+
- **자동 인덱싱** - 메모리 파일 자동 청크 분할 및 임베딩
|
|
41
|
+
- **유사도 매칭** - 의미 기반으로 관련 대화 기록 찾기
|
|
42
|
+
|
|
43
|
+
### 🔧 시스템 (v0.6.0)
|
|
44
|
+
- **헬스 체크** - 봇 상태 모니터링 (uptime, 메시지 수, 오류 수)
|
|
45
|
+
- **업데이트 알림** - 새 버전 출시 시 자동 알림
|
|
46
|
+
|
|
38
47
|
## 설치
|
|
39
48
|
|
|
40
49
|
### 사전 준비
|
|
@@ -273,7 +282,22 @@ npm test # 테스트 실행
|
|
|
273
282
|
|
|
274
283
|
## 버전 히스토리
|
|
275
284
|
|
|
276
|
-
### v0.
|
|
285
|
+
### v0.6.0 (현재)
|
|
286
|
+
- 🧠 시맨틱 메모리 검색 (임베딩 기반)
|
|
287
|
+
- 💚 헬스 체크 모니터링
|
|
288
|
+
- 🔄 자동 업데이트 알림
|
|
289
|
+
|
|
290
|
+
### v0.5.0
|
|
291
|
+
- 🔒 보안 강화 (SSRF 방지, 경로 검증)
|
|
292
|
+
- ✅ 테스트 추가 (vitest)
|
|
293
|
+
- 📖 문서 개선
|
|
294
|
+
|
|
295
|
+
### v0.4.0
|
|
296
|
+
- 🛡️ 보안 강화 (TOCTOU, 심볼릭 링크 검증)
|
|
297
|
+
- 🧹 세션 자동 정리
|
|
298
|
+
- 🔧 환경변수 기반 경로 설정
|
|
299
|
+
|
|
300
|
+
### v0.3.0
|
|
277
301
|
- 🔍 웹 검색 (Brave Search API)
|
|
278
302
|
- 🕐 Cron 스케줄링 (한국어 지원)
|
|
279
303
|
- 🤖 서브 에이전트 (백그라운드 작업)
|
package/dist/ai/claude.js
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
1
|
+
import Anthropic, { APIError } from "@anthropic-ai/sdk";
|
|
2
2
|
import { tools, executeTool } from "../tools/index.js";
|
|
3
|
+
import { sleep } from "../utils/time.js";
|
|
4
|
+
import { MAX_RETRIES, BASE_RETRY_DELAY_MS } from "../utils/constants.js";
|
|
5
|
+
async function withRetry(fn, retries = MAX_RETRIES) {
|
|
6
|
+
let lastError = null;
|
|
7
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
8
|
+
try {
|
|
9
|
+
return await fn();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
// APIError 타입 체크
|
|
13
|
+
if (error instanceof APIError) {
|
|
14
|
+
lastError = error;
|
|
15
|
+
// Rate limit (429)
|
|
16
|
+
if (error.status === 429) {
|
|
17
|
+
const retryAfter = error.headers?.["retry-after"];
|
|
18
|
+
const delay = retryAfter
|
|
19
|
+
? parseInt(retryAfter) * 1000
|
|
20
|
+
: BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
21
|
+
console.log(`[RateLimit] 429 received, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
|
|
22
|
+
await sleep(delay);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// 서버 에러 (500+)
|
|
26
|
+
if (error.status >= 500) {
|
|
27
|
+
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
28
|
+
console.log(`[ServerError] ${error.status}, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
|
|
29
|
+
await sleep(delay);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 일반 Error 처리
|
|
34
|
+
if (error instanceof Error) {
|
|
35
|
+
lastError = error;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lastError = new Error(String(error));
|
|
39
|
+
}
|
|
40
|
+
// 다른 에러는 바로 throw
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw lastError;
|
|
45
|
+
}
|
|
3
46
|
let anthropic = null;
|
|
4
47
|
function getClient() {
|
|
5
48
|
if (!anthropic) {
|
|
@@ -58,20 +101,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
58
101
|
return params;
|
|
59
102
|
};
|
|
60
103
|
let response;
|
|
61
|
-
|
|
62
|
-
response = await client.messages.create(buildRequestParams());
|
|
63
|
-
}
|
|
64
|
-
catch (error) {
|
|
65
|
-
if (error instanceof Anthropic.APIError) {
|
|
66
|
-
if (error.status === 429) {
|
|
67
|
-
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
68
|
-
}
|
|
69
|
-
if (error.status >= 500) {
|
|
70
|
-
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
throw error;
|
|
74
|
-
}
|
|
104
|
+
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
75
105
|
// Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복 (최대 10회)
|
|
76
106
|
const MAX_TOOL_ITERATIONS = 10;
|
|
77
107
|
let iterations = 0;
|
|
@@ -103,20 +133,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
103
133
|
content: toolResults,
|
|
104
134
|
});
|
|
105
135
|
// 다음 응답 요청 (도구 루프에서도 thinking 유지)
|
|
106
|
-
|
|
107
|
-
response = await client.messages.create(buildRequestParams());
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
if (error instanceof Anthropic.APIError) {
|
|
111
|
-
if (error.status === 429) {
|
|
112
|
-
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
113
|
-
}
|
|
114
|
-
if (error.status >= 500) {
|
|
115
|
-
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
136
|
+
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
120
137
|
}
|
|
121
138
|
// 반복 횟수 초과 시 경고
|
|
122
139
|
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
@@ -163,7 +180,9 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
163
180
|
// (도구 호출 폴백 시 chat()에서 thinking 사용됨)
|
|
164
181
|
let accumulated = "";
|
|
165
182
|
let stopReason = null;
|
|
166
|
-
|
|
183
|
+
// 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
|
|
184
|
+
return await withRetry(async () => {
|
|
185
|
+
accumulated = ""; // 재시도 시 초기화
|
|
167
186
|
const stream = client.messages.stream(params);
|
|
168
187
|
// 스트리밍 이벤트 처리
|
|
169
188
|
stream.on("text", async (text) => {
|
|
@@ -187,30 +206,5 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
187
206
|
}
|
|
188
207
|
// 성공적으로 스트리밍 완료
|
|
189
208
|
return { text: accumulated, usedTools: false };
|
|
190
|
-
}
|
|
191
|
-
catch (error) {
|
|
192
|
-
// 스트리밍 에러 핸들링
|
|
193
|
-
if (error instanceof Anthropic.APIError) {
|
|
194
|
-
if (error.status === 429) {
|
|
195
|
-
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
196
|
-
}
|
|
197
|
-
if (error.status >= 500) {
|
|
198
|
-
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
// 연결 끊김 등 스트리밍 에러 - 이미 받은 내용이 있으면 반환 시도
|
|
202
|
-
if (accumulated.length > 50) {
|
|
203
|
-
console.warn("[Stream] Connection error, returning partial response");
|
|
204
|
-
return { text: accumulated + "\n\n(연결이 끊겨서 응답이 잘렸을 수 있어)", usedTools: false };
|
|
205
|
-
}
|
|
206
|
-
// 응답이 거의 없으면 일반 chat으로 재시도
|
|
207
|
-
console.warn("[Stream] Connection error, retrying with chat()");
|
|
208
|
-
try {
|
|
209
|
-
const text = await chat(messages, systemPrompt, modelId);
|
|
210
|
-
return { text, usedTools: false };
|
|
211
|
-
}
|
|
212
|
-
catch (retryError) {
|
|
213
|
-
throw retryError;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
209
|
+
});
|
|
216
210
|
}
|
package/dist/calendar/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { calendar } from "@googleapis/calendar";
|
|
2
|
+
import { OAuth2Client } from "google-auth-library";
|
|
2
3
|
import * as fs from "fs/promises";
|
|
3
4
|
import * as path from "path";
|
|
4
5
|
import * as http from "http";
|
|
@@ -69,7 +70,7 @@ export async function getAuthUrl() {
|
|
|
69
70
|
const creds = await loadCredentials();
|
|
70
71
|
if (!creds)
|
|
71
72
|
return null;
|
|
72
|
-
const oauth2Client = new
|
|
73
|
+
const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
|
|
73
74
|
return oauth2Client.generateAuthUrl({
|
|
74
75
|
access_type: "offline",
|
|
75
76
|
scope: SCOPES,
|
|
@@ -120,7 +121,7 @@ export async function exchangeCodeForToken(code) {
|
|
|
120
121
|
const creds = await loadCredentials();
|
|
121
122
|
if (!creds)
|
|
122
123
|
return false;
|
|
123
|
-
const oauth2Client = new
|
|
124
|
+
const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
|
|
124
125
|
try {
|
|
125
126
|
const { tokens } = await oauth2Client.getToken(code);
|
|
126
127
|
if (tokens.access_token && tokens.refresh_token) {
|
|
@@ -145,7 +146,7 @@ async function getAuthClient() {
|
|
|
145
146
|
if (!creds || !token) {
|
|
146
147
|
throw new Error("Calendar not configured. Use /calendar_setup");
|
|
147
148
|
}
|
|
148
|
-
const oauth2Client = new
|
|
149
|
+
const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
|
|
149
150
|
oauth2Client.setCredentials({
|
|
150
151
|
access_token: token.access_token,
|
|
151
152
|
refresh_token: token.refresh_token,
|
|
@@ -169,17 +170,18 @@ async function getAuthClient() {
|
|
|
169
170
|
// 캘린더 API 인스턴스
|
|
170
171
|
async function getCalendar() {
|
|
171
172
|
const auth = await getAuthClient();
|
|
172
|
-
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
174
|
+
return calendar({ version: "v3", auth: auth });
|
|
173
175
|
}
|
|
174
176
|
// 오늘 일정 조회
|
|
175
177
|
export async function getTodayEvents() {
|
|
176
|
-
const
|
|
178
|
+
const cal = await getCalendar();
|
|
177
179
|
const now = new Date();
|
|
178
180
|
const startOfDay = new Date(now);
|
|
179
181
|
startOfDay.setHours(0, 0, 0, 0);
|
|
180
182
|
const endOfDay = new Date(now);
|
|
181
183
|
endOfDay.setHours(23, 59, 59, 999);
|
|
182
|
-
const response = await
|
|
184
|
+
const response = await cal.events.list({
|
|
183
185
|
calendarId: "primary",
|
|
184
186
|
timeMin: startOfDay.toISOString(),
|
|
185
187
|
timeMax: endOfDay.toISOString(),
|
|
@@ -190,8 +192,8 @@ export async function getTodayEvents() {
|
|
|
190
192
|
}
|
|
191
193
|
// 특정 기간 일정 조회
|
|
192
194
|
export async function getEvents(startDate, endDate) {
|
|
193
|
-
const
|
|
194
|
-
const response = await
|
|
195
|
+
const cal = await getCalendar();
|
|
196
|
+
const response = await cal.events.list({
|
|
195
197
|
calendarId: "primary",
|
|
196
198
|
timeMin: startDate.toISOString(),
|
|
197
199
|
timeMax: endDate.toISOString(),
|
|
@@ -203,7 +205,7 @@ export async function getEvents(startDate, endDate) {
|
|
|
203
205
|
}
|
|
204
206
|
// 일정 추가
|
|
205
207
|
export async function addEvent(summary, startTime, endTime, description) {
|
|
206
|
-
const
|
|
208
|
+
const cal = await getCalendar();
|
|
207
209
|
// 종료 시간이 없으면 1시간 후
|
|
208
210
|
const end = endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
|
|
209
211
|
const event = {
|
|
@@ -218,7 +220,7 @@ export async function addEvent(summary, startTime, endTime, description) {
|
|
|
218
220
|
timeZone: "Asia/Seoul",
|
|
219
221
|
},
|
|
220
222
|
};
|
|
221
|
-
const response = await
|
|
223
|
+
const response = await cal.events.insert({
|
|
222
224
|
calendarId: "primary",
|
|
223
225
|
requestBody: event,
|
|
224
226
|
});
|
|
@@ -227,8 +229,8 @@ export async function addEvent(summary, startTime, endTime, description) {
|
|
|
227
229
|
// 일정 삭제
|
|
228
230
|
export async function deleteEvent(eventId) {
|
|
229
231
|
try {
|
|
230
|
-
const
|
|
231
|
-
await
|
|
232
|
+
const cal = await getCalendar();
|
|
233
|
+
await cal.events.delete({
|
|
232
234
|
calendarId: "primary",
|
|
233
235
|
eventId,
|
|
234
236
|
});
|
package/dist/cron/parser.js
CHANGED
|
@@ -131,50 +131,82 @@ export function parseCronExpression(expr) {
|
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
133
133
|
* Calculate the next run time for a cron expression
|
|
134
|
+
* Returns null if no valid next run time is found (safer than throwing)
|
|
134
135
|
*/
|
|
135
136
|
export function getNextCronRun(expression, fromDate = new Date(), timezone) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
137
|
+
// null/undefined/빈 문자열 처리
|
|
138
|
+
if (!expression || expression.trim() === "") {
|
|
139
|
+
console.warn("[Cron] Empty cron expression provided");
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const parsed = parseCronExpression(expression);
|
|
144
|
+
const from = new Date(fromDate);
|
|
145
|
+
// Invalid date 체크
|
|
146
|
+
if (isNaN(from.getTime())) {
|
|
147
|
+
console.warn("[Cron] Invalid fromDate provided");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Start from the next minute
|
|
151
|
+
from.setSeconds(0);
|
|
152
|
+
from.setMilliseconds(0);
|
|
153
|
+
from.setMinutes(from.getMinutes() + 1);
|
|
154
|
+
// Search for the next matching time (max 2 years ahead)
|
|
155
|
+
const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
|
|
156
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
157
|
+
const candidate = new Date(from.getTime() + i * 60000);
|
|
158
|
+
const minute = candidate.getMinutes();
|
|
159
|
+
const hour = candidate.getHours();
|
|
160
|
+
const dayOfMonth = candidate.getDate();
|
|
161
|
+
const month = candidate.getMonth() + 1;
|
|
162
|
+
const dayOfWeek = candidate.getDay();
|
|
163
|
+
if (parsed.minute.values.includes(minute) &&
|
|
164
|
+
parsed.hour.values.includes(hour) &&
|
|
165
|
+
parsed.month.values.includes(month) &&
|
|
166
|
+
(parsed.dayOfMonth.values.includes(dayOfMonth) ||
|
|
167
|
+
parsed.dayOfWeek.values.includes(dayOfWeek))) {
|
|
168
|
+
return candidate;
|
|
169
|
+
}
|
|
157
170
|
}
|
|
171
|
+
console.warn(`[Cron] Could not find next run time for: ${expression}`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
console.error(`[Cron] Error parsing expression "${expression}":`, error);
|
|
176
|
+
return null;
|
|
158
177
|
}
|
|
159
|
-
throw new Error(`Could not find next run time for: ${expression}`);
|
|
160
178
|
}
|
|
161
179
|
/**
|
|
162
180
|
* Get the next run time for any schedule type
|
|
181
|
+
* Returns NaN if schedule is invalid (check with isNaN())
|
|
163
182
|
*/
|
|
164
183
|
export function getNextRun(schedule, now = new Date()) {
|
|
184
|
+
// null/undefined 처리
|
|
185
|
+
if (!schedule) {
|
|
186
|
+
console.warn("[Cron] No schedule provided to getNextRun");
|
|
187
|
+
return NaN;
|
|
188
|
+
}
|
|
165
189
|
switch (schedule.kind) {
|
|
166
190
|
case "at":
|
|
167
|
-
return schedule.atMs;
|
|
191
|
+
return schedule.atMs ?? NaN;
|
|
168
192
|
case "every": {
|
|
193
|
+
const everyMs = schedule.everyMs || schedule.intervalMs;
|
|
194
|
+
if (!everyMs || everyMs <= 0) {
|
|
195
|
+
console.warn("[Cron] Invalid everyMs in schedule");
|
|
196
|
+
return NaN;
|
|
197
|
+
}
|
|
169
198
|
const startMs = schedule.startMs ?? now.getTime();
|
|
170
199
|
const elapsed = now.getTime() - startMs;
|
|
171
|
-
const intervals = Math.floor(elapsed /
|
|
172
|
-
return startMs + (intervals + 1) *
|
|
200
|
+
const intervals = Math.floor(elapsed / everyMs);
|
|
201
|
+
return startMs + (intervals + 1) * everyMs;
|
|
202
|
+
}
|
|
203
|
+
case "cron": {
|
|
204
|
+
const nextRun = getNextCronRun(schedule.expression, now, schedule.timezone);
|
|
205
|
+
return nextRun ? nextRun.getTime() : NaN;
|
|
173
206
|
}
|
|
174
|
-
case "cron":
|
|
175
|
-
return getNextCronRun(schedule.expression, now, schedule.timezone).getTime();
|
|
176
207
|
default:
|
|
177
|
-
|
|
208
|
+
console.warn(`[Cron] Unknown schedule kind: ${schedule.kind}`);
|
|
209
|
+
return NaN;
|
|
178
210
|
}
|
|
179
211
|
}
|
|
180
212
|
/**
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { getDueJobs, markJobExecuted, loadJobs, addJob, removeJob, updateJob, getJobsByChat } from "./store.js";
|
|
8
8
|
import { chat } from "../ai/claude.js";
|
|
9
9
|
import { buildSystemPrompt } from "../telegram/utils/prompt.js";
|
|
10
|
+
import { INTERVAL_1_MINUTE } from "../utils/time.js";
|
|
11
|
+
import { TELEGRAM_SAFE_LIMIT } from "../utils/constants.js";
|
|
10
12
|
// Scheduler state
|
|
11
13
|
let schedulerInterval = null;
|
|
12
14
|
let botInstance = null;
|
|
@@ -36,7 +38,7 @@ export class CronScheduler {
|
|
|
36
38
|
// Then check every minute
|
|
37
39
|
this.interval = setInterval(() => {
|
|
38
40
|
this.checkAndRun().catch((err) => console.error("[CronScheduler] Check failed:", err));
|
|
39
|
-
},
|
|
41
|
+
}, INTERVAL_1_MINUTE);
|
|
40
42
|
console.log("[CronScheduler] Started - checking every minute");
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -167,21 +169,24 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
167
169
|
// Call Claude API
|
|
168
170
|
const response = await chat(messages, systemPrompt, "sonnet");
|
|
169
171
|
// Send the response to the chat
|
|
170
|
-
|
|
172
|
+
const trimmedResponse = response?.trim();
|
|
173
|
+
if (trimmedResponse) {
|
|
171
174
|
// Split long messages (Telegram limit is 4096 characters)
|
|
172
|
-
const maxLength =
|
|
173
|
-
if (
|
|
174
|
-
await bot.api.sendMessage(job.chatId,
|
|
175
|
+
const maxLength = TELEGRAM_SAFE_LIMIT;
|
|
176
|
+
if (trimmedResponse.length <= maxLength) {
|
|
177
|
+
await bot.api.sendMessage(job.chatId, trimmedResponse, {
|
|
175
178
|
parse_mode: "Markdown",
|
|
176
179
|
});
|
|
177
180
|
}
|
|
178
181
|
else {
|
|
179
182
|
// Split into multiple messages
|
|
180
|
-
const chunks = splitMessage(
|
|
183
|
+
const chunks = splitMessage(trimmedResponse, maxLength);
|
|
181
184
|
for (const chunk of chunks) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
if (chunk) {
|
|
186
|
+
await bot.api.sendMessage(job.chatId, chunk, {
|
|
187
|
+
parse_mode: "Markdown",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
192
|
}
|
|
@@ -202,6 +207,10 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
202
207
|
* Split a long message into chunks
|
|
203
208
|
*/
|
|
204
209
|
function splitMessage(text, maxLength) {
|
|
210
|
+
// null/undefined/빈 문자열 처리
|
|
211
|
+
if (!text || text.trim().length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
205
214
|
const chunks = [];
|
|
206
215
|
let remaining = text;
|
|
207
216
|
while (remaining.length > 0) {
|
package/dist/cron/store.js
CHANGED
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
import * as fs from "fs/promises";
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import { getWorkspacePath } from "../workspace/paths.js";
|
|
9
|
+
import { sleep } from "../utils/time.js";
|
|
10
|
+
import { LOCK_TIMEOUT_MS, LOCK_RETRY_MS, LOCK_MAX_RETRIES } from "../utils/constants.js";
|
|
9
11
|
const CRON_FILE = "cron-jobs.json";
|
|
10
12
|
const LOCK_FILE = "cron-jobs.lock";
|
|
11
13
|
const STORE_VERSION = 1;
|
|
12
|
-
// Lock configuration
|
|
13
|
-
const LOCK_TIMEOUT_MS = 5000;
|
|
14
|
-
const LOCK_RETRY_MS = 50;
|
|
15
|
-
const LOCK_MAX_RETRIES = 100;
|
|
16
14
|
function getCronFilePath() {
|
|
17
15
|
return path.join(getWorkspacePath(), CRON_FILE);
|
|
18
16
|
}
|
|
@@ -73,9 +71,6 @@ async function releaseLock() {
|
|
|
73
71
|
// Ignore errors on unlock
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
|
-
function sleep(ms) {
|
|
77
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
-
}
|
|
79
74
|
/**
|
|
80
75
|
* Execute a function with file lock protection
|
|
81
76
|
*/
|
|
@@ -94,13 +89,39 @@ async function withLock(fn) {
|
|
|
94
89
|
async function loadJobsInternal() {
|
|
95
90
|
try {
|
|
96
91
|
const data = await fs.readFile(getCronFilePath(), "utf-8");
|
|
92
|
+
// 빈 파일 체크
|
|
93
|
+
if (!data || data.trim() === "") {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
97
96
|
const store = JSON.parse(data);
|
|
98
|
-
|
|
97
|
+
// jobs 배열 유효성 검사
|
|
98
|
+
if (!store || !Array.isArray(store.jobs)) {
|
|
99
|
+
console.warn("[Cron] Invalid store format, returning empty array");
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return store.jobs;
|
|
99
103
|
}
|
|
100
104
|
catch (error) {
|
|
101
105
|
if (error.code === "ENOENT") {
|
|
102
106
|
return [];
|
|
103
107
|
}
|
|
108
|
+
// JSON 파싱 오류
|
|
109
|
+
if (error instanceof SyntaxError) {
|
|
110
|
+
console.error("[Cron] Corrupted cron-jobs.json file:", error.message);
|
|
111
|
+
// 백업 파일 생성 시도
|
|
112
|
+
try {
|
|
113
|
+
const backupPath = `${getCronFilePath()}.corrupted.${Date.now()}`;
|
|
114
|
+
const data = await fs.readFile(getCronFilePath(), "utf-8").catch(() => "");
|
|
115
|
+
if (data) {
|
|
116
|
+
await fs.writeFile(backupPath, data, "utf-8");
|
|
117
|
+
console.log(`[Cron] Corrupted file backed up to: ${backupPath}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// 백업 실패는 무시
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
104
125
|
console.error("[Cron] Failed to load jobs:", error);
|
|
105
126
|
return [];
|
|
106
127
|
}
|
|
@@ -446,12 +467,21 @@ function getDaysInMonth(year, month) {
|
|
|
446
467
|
* Parse cron field like "1,3,5" or "1-5" or step values into array of numbers
|
|
447
468
|
*/
|
|
448
469
|
function parseCronField(field, min, max) {
|
|
449
|
-
|
|
470
|
+
// 빈 문자열/null/undefined 처리
|
|
471
|
+
if (!field || field.trim() === "") {
|
|
472
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min); // wildcard로 처리
|
|
473
|
+
}
|
|
474
|
+
const trimmedField = field.trim();
|
|
475
|
+
if (trimmedField === "*") {
|
|
450
476
|
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
451
477
|
}
|
|
452
478
|
// Handle step values like */5
|
|
453
|
-
if (
|
|
454
|
-
const step = parseInt(
|
|
479
|
+
if (trimmedField.startsWith("*/")) {
|
|
480
|
+
const step = parseInt(trimmedField.slice(2), 10);
|
|
481
|
+
if (isNaN(step) || step <= 0) {
|
|
482
|
+
console.warn(`[Cron] Invalid step value in field: ${field}, using default`);
|
|
483
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
484
|
+
}
|
|
455
485
|
const values = [];
|
|
456
486
|
for (let i = min; i <= max; i += step) {
|
|
457
487
|
values.push(i);
|
|
@@ -459,39 +489,66 @@ function parseCronField(field, min, max) {
|
|
|
459
489
|
return values;
|
|
460
490
|
}
|
|
461
491
|
const values = [];
|
|
462
|
-
const parts =
|
|
492
|
+
const parts = trimmedField.split(",");
|
|
463
493
|
for (const part of parts) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
494
|
+
const trimmedPart = part.trim();
|
|
495
|
+
if (!trimmedPart)
|
|
496
|
+
continue;
|
|
497
|
+
if (trimmedPart.includes("-") && !trimmedPart.includes("/")) {
|
|
498
|
+
const [startStr, endStr] = trimmedPart.split("-");
|
|
499
|
+
const start = Number(startStr);
|
|
500
|
+
const end = Number(endStr);
|
|
501
|
+
if (!isNaN(start) && !isNaN(end)) {
|
|
502
|
+
for (let i = start; i <= end; i++) {
|
|
503
|
+
values.push(i);
|
|
504
|
+
}
|
|
468
505
|
}
|
|
469
506
|
}
|
|
470
|
-
else if (
|
|
507
|
+
else if (trimmedPart.includes("/")) {
|
|
471
508
|
// Handle range with step like 0-30/5
|
|
472
|
-
const [range, stepStr] =
|
|
509
|
+
const [range, stepStr] = trimmedPart.split("/");
|
|
473
510
|
const step = parseInt(stepStr, 10);
|
|
511
|
+
if (isNaN(step) || step <= 0)
|
|
512
|
+
continue;
|
|
474
513
|
let rangeStart = min;
|
|
475
514
|
let rangeEnd = max;
|
|
476
|
-
if (range.includes("-")) {
|
|
477
|
-
[
|
|
515
|
+
if (range && range.includes("-")) {
|
|
516
|
+
const [rs, re] = range.split("-").map(Number);
|
|
517
|
+
if (!isNaN(rs))
|
|
518
|
+
rangeStart = rs;
|
|
519
|
+
if (!isNaN(re))
|
|
520
|
+
rangeEnd = re;
|
|
478
521
|
}
|
|
479
522
|
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
|
480
523
|
values.push(i);
|
|
481
524
|
}
|
|
482
525
|
}
|
|
483
526
|
else {
|
|
484
|
-
|
|
527
|
+
const num = parseInt(trimmedPart, 10);
|
|
528
|
+
if (!isNaN(num)) {
|
|
529
|
+
values.push(num);
|
|
530
|
+
}
|
|
485
531
|
}
|
|
486
532
|
}
|
|
487
|
-
|
|
533
|
+
// 유효한 값이 없으면 wildcard로 폴백
|
|
534
|
+
const filtered = values.filter((v) => v >= min && v <= max);
|
|
535
|
+
return filtered.length > 0 ? filtered : Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
488
536
|
}
|
|
489
537
|
/**
|
|
490
538
|
* Get all jobs for a specific chat
|
|
491
539
|
*/
|
|
492
540
|
export async function getJobsByChat(chatId) {
|
|
541
|
+
// null/undefined 처리
|
|
542
|
+
if (chatId == null) {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
493
545
|
const jobs = await loadJobs();
|
|
494
546
|
const numericChatId = typeof chatId === "string" ? parseInt(chatId, 10) : chatId;
|
|
547
|
+
// NaN 체크
|
|
548
|
+
if (isNaN(numericChatId)) {
|
|
549
|
+
console.warn(`[Cron] Invalid chatId: ${chatId}`);
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
495
552
|
return jobs.filter((j) => j.chatId === numericChatId);
|
|
496
553
|
}
|
|
497
554
|
/**
|