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 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.3.0 (현재)
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
- try {
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
- try {
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
- try {
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
  }
@@ -1,4 +1,5 @@
1
- import { google } from "googleapis";
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 google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
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 google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
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 google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
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
- return google.calendar({ version: "v3", auth });
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 calendar = await getCalendar();
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 calendar.events.list({
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 calendar = await getCalendar();
194
- const response = await calendar.events.list({
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 calendar = await getCalendar();
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 calendar.events.insert({
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 calendar = await getCalendar();
231
- await calendar.events.delete({
232
+ const cal = await getCalendar();
233
+ await cal.events.delete({
232
234
  calendarId: "primary",
233
235
  eventId,
234
236
  });
@@ -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
- const parsed = parseCronExpression(expression);
137
- const from = new Date(fromDate);
138
- // Start from the next minute
139
- from.setSeconds(0);
140
- from.setMilliseconds(0);
141
- from.setMinutes(from.getMinutes() + 1);
142
- // Search for the next matching time (max 2 years ahead)
143
- const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
144
- for (let i = 0; i < maxIterations; i++) {
145
- const candidate = new Date(from.getTime() + i * 60000);
146
- const minute = candidate.getMinutes();
147
- const hour = candidate.getHours();
148
- const dayOfMonth = candidate.getDate();
149
- const month = candidate.getMonth() + 1;
150
- const dayOfWeek = candidate.getDay();
151
- if (parsed.minute.values.includes(minute) &&
152
- parsed.hour.values.includes(hour) &&
153
- parsed.month.values.includes(month) &&
154
- (parsed.dayOfMonth.values.includes(dayOfMonth) ||
155
- parsed.dayOfWeek.values.includes(dayOfWeek))) {
156
- return candidate;
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 / schedule.everyMs);
172
- return startMs + (intervals + 1) * schedule.everyMs;
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
- throw new Error(`Unknown schedule kind: ${schedule.kind}`);
208
+ console.warn(`[Cron] Unknown schedule kind: ${schedule.kind}`);
209
+ return NaN;
178
210
  }
179
211
  }
180
212
  /**
@@ -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
- }, 60 * 1000); // 1 minute
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
- if (response && response.trim()) {
172
+ const trimmedResponse = response?.trim();
173
+ if (trimmedResponse) {
171
174
  // Split long messages (Telegram limit is 4096 characters)
172
- const maxLength = 4000;
173
- if (response.length <= maxLength) {
174
- await bot.api.sendMessage(job.chatId, response, {
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(response, maxLength);
183
+ const chunks = splitMessage(trimmedResponse, maxLength);
181
184
  for (const chunk of chunks) {
182
- await bot.api.sendMessage(job.chatId, chunk, {
183
- parse_mode: "Markdown",
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) {
@@ -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
- return store.jobs || [];
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
- if (field === "*") {
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 (field.startsWith("*/")) {
454
- const step = parseInt(field.slice(2), 10);
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 = field.split(",");
492
+ const parts = trimmedField.split(",");
463
493
  for (const part of parts) {
464
- if (part.includes("-")) {
465
- const [start, end] = part.split("-").map(Number);
466
- for (let i = start; i <= end; i++) {
467
- values.push(i);
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 (part.includes("/")) {
507
+ else if (trimmedPart.includes("/")) {
471
508
  // Handle range with step like 0-30/5
472
- const [range, stepStr] = part.split("/");
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
- [rangeStart, rangeEnd] = range.split("-").map(Number);
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
- values.push(parseInt(part, 10));
527
+ const num = parseInt(trimmedPart, 10);
528
+ if (!isNaN(num)) {
529
+ values.push(num);
530
+ }
485
531
  }
486
532
  }
487
- return values.filter((v) => v >= min && v <= max);
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
  /**