companionbot 0.5.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/agents/index.js +1 -1
- package/dist/agents/manager.js +0 -30
- package/dist/ai/claude.js +151 -46
- package/dist/calendar/index.js +15 -13
- package/dist/cron/index.js +1 -1
- package/dist/cron/parser.js +58 -159
- package/dist/cron/scheduler.js +52 -16
- package/dist/cron/store.js +79 -22
- package/dist/health/index.js +66 -0
- package/dist/heartbeat/index.js +24 -7
- package/dist/memory/embeddings.js +113 -0
- package/dist/memory/index.js +4 -0
- package/dist/memory/indexer.js +39 -0
- package/dist/memory/vectorStore.js +216 -0
- package/dist/session/state.js +29 -1
- package/dist/telegram/bot.js +3 -3
- package/dist/telegram/handlers/commands.js +91 -49
- package/dist/telegram/handlers/messages.js +127 -118
- package/dist/telegram/utils/index.js +0 -2
- package/dist/telegram/utils/prompt.js +76 -13
- package/dist/telegram/utils/url.js +39 -8
- package/dist/tools/index.js +77 -353
- package/dist/updates/index.js +52 -0
- package/dist/utils/constants.js +44 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/time.js +51 -0
- package/dist/utils/tokens.js +30 -0
- package/dist/workspace/index.js +1 -3
- package/dist/workspace/load.js +7 -251
- package/package.json +4 -2
- package/templates/AGENTS.md +98 -77
- package/templates/MEMORY.md +4 -58
- package/dist/telegram/utils/secrets.js +0 -64
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/agents/index.js
CHANGED
package/dist/agents/manager.js
CHANGED
|
@@ -198,14 +198,6 @@ export function cleanupOldAgents() {
|
|
|
198
198
|
// running 상태도 1시간 지나면 정리 (stuck agent 방지)
|
|
199
199
|
if (agent.status === "running" && agent.createdAt.getTime() < oneHourAgo) {
|
|
200
200
|
console.log(`[Agent ${id}] Cleaning up stuck agent (running > 1h)`);
|
|
201
|
-
// 실행 중인 API 호출 취소
|
|
202
|
-
const controller = abortControllers.get(id);
|
|
203
|
-
if (controller) {
|
|
204
|
-
controller.abort();
|
|
205
|
-
abortControllers.delete(id);
|
|
206
|
-
}
|
|
207
|
-
agent.status = "cancelled";
|
|
208
|
-
agent.completedAt = new Date();
|
|
209
201
|
agents.delete(id);
|
|
210
202
|
}
|
|
211
203
|
}
|
|
@@ -231,27 +223,5 @@ export function stopCleanup() {
|
|
|
231
223
|
console.log("[AgentManager] Cleanup interval stopped");
|
|
232
224
|
}
|
|
233
225
|
}
|
|
234
|
-
/**
|
|
235
|
-
* 모든 진행 중인 agent abort 및 정리 (shutdown 시 사용)
|
|
236
|
-
*/
|
|
237
|
-
export function abortAllAgents() {
|
|
238
|
-
console.log("[AgentManager] Aborting all running agents...");
|
|
239
|
-
// 모든 AbortController abort
|
|
240
|
-
for (const [id, controller] of abortControllers) {
|
|
241
|
-
console.log(`[Agent ${id}] Aborting`);
|
|
242
|
-
controller.abort();
|
|
243
|
-
}
|
|
244
|
-
abortControllers.clear();
|
|
245
|
-
// 모든 running agent 상태 업데이트
|
|
246
|
-
for (const [id, agent] of agents) {
|
|
247
|
-
if (agent.status === "running") {
|
|
248
|
-
agent.status = "cancelled";
|
|
249
|
-
agent.completedAt = new Date();
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
// cleanup interval 중지
|
|
253
|
-
stopCleanup();
|
|
254
|
-
console.log("[AgentManager] All agents aborted");
|
|
255
|
-
}
|
|
256
226
|
// 자동 시작
|
|
257
227
|
startCleanup();
|
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) {
|
|
@@ -7,41 +50,58 @@ function getClient() {
|
|
|
7
50
|
}
|
|
8
51
|
return anthropic;
|
|
9
52
|
}
|
|
53
|
+
// 모델별 max_tokens 및 thinking budget 설정
|
|
54
|
+
// 참고: Claude API에서 thinking + output이 모델 한도 초과하면 안 됨
|
|
10
55
|
export const MODELS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
56
|
+
haiku: {
|
|
57
|
+
id: "claude-haiku-3-5-20241022",
|
|
58
|
+
name: "Claude Haiku 3.5",
|
|
59
|
+
maxTokens: 4096, // 빠른 응답
|
|
60
|
+
thinkingBudget: 0, // Haiku는 thinking 미지원
|
|
61
|
+
},
|
|
62
|
+
sonnet: {
|
|
63
|
+
id: "claude-sonnet-4-20250514",
|
|
64
|
+
name: "Claude Sonnet 4",
|
|
65
|
+
maxTokens: 8192, // 일반 작업
|
|
66
|
+
thinkingBudget: 10000, // 적당한 thinking
|
|
67
|
+
},
|
|
68
|
+
opus: {
|
|
69
|
+
id: "claude-opus-4-20250514",
|
|
70
|
+
name: "Claude Opus 4",
|
|
71
|
+
maxTokens: 16384, // 복잡한 작업
|
|
72
|
+
thinkingBudget: 32000, // 깊은 thinking
|
|
73
|
+
},
|
|
14
74
|
};
|
|
15
|
-
export async function chat(messages, systemPrompt, modelId = "sonnet"
|
|
75
|
+
export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
16
76
|
const client = getClient();
|
|
17
|
-
const
|
|
18
|
-
const signal = options?.signal;
|
|
77
|
+
const modelConfig = MODELS[modelId];
|
|
19
78
|
// 메시지를 API 형식으로 변환
|
|
20
79
|
const apiMessages = messages.map((m) => ({
|
|
21
80
|
role: m.role,
|
|
22
81
|
content: m.content,
|
|
23
82
|
}));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
model,
|
|
28
|
-
max_tokens:
|
|
29
|
-
system: systemPrompt,
|
|
83
|
+
// API 요청 파라미터 빌드 (도구 루프에서도 동일하게 사용)
|
|
84
|
+
const buildRequestParams = () => {
|
|
85
|
+
const params = {
|
|
86
|
+
model: modelConfig.id,
|
|
87
|
+
max_tokens: modelConfig.maxTokens,
|
|
30
88
|
messages: apiMessages,
|
|
31
89
|
tools: tools,
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (error instanceof Anthropic.APIError) {
|
|
36
|
-
if (error.status === 429) {
|
|
37
|
-
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
38
|
-
}
|
|
39
|
-
if (error.status >= 500) {
|
|
40
|
-
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
41
|
-
}
|
|
90
|
+
};
|
|
91
|
+
if (systemPrompt) {
|
|
92
|
+
params.system = systemPrompt;
|
|
42
93
|
}
|
|
43
|
-
|
|
44
|
-
|
|
94
|
+
// thinking 활성화 (budget > 0인 경우)
|
|
95
|
+
if (modelConfig.thinkingBudget > 0) {
|
|
96
|
+
params.thinking = {
|
|
97
|
+
type: "enabled",
|
|
98
|
+
budget_tokens: modelConfig.thinkingBudget,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return params;
|
|
102
|
+
};
|
|
103
|
+
let response;
|
|
104
|
+
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
45
105
|
// Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복 (최대 10회)
|
|
46
106
|
const MAX_TOOL_ITERATIONS = 10;
|
|
47
107
|
let iterations = 0;
|
|
@@ -72,27 +132,8 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
|
|
|
72
132
|
role: "user",
|
|
73
133
|
content: toolResults,
|
|
74
134
|
});
|
|
75
|
-
// 다음 응답 요청
|
|
76
|
-
|
|
77
|
-
response = await client.messages.create({
|
|
78
|
-
model,
|
|
79
|
-
max_tokens: 4096,
|
|
80
|
-
system: systemPrompt,
|
|
81
|
-
messages: apiMessages,
|
|
82
|
-
tools: tools,
|
|
83
|
-
}, { signal });
|
|
84
|
-
}
|
|
85
|
-
catch (error) {
|
|
86
|
-
if (error instanceof Anthropic.APIError) {
|
|
87
|
-
if (error.status === 429) {
|
|
88
|
-
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
89
|
-
}
|
|
90
|
-
if (error.status >= 500) {
|
|
91
|
-
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
135
|
+
// 다음 응답 요청 (도구 루프에서도 thinking 유지)
|
|
136
|
+
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
96
137
|
}
|
|
97
138
|
// 반복 횟수 초과 시 경고
|
|
98
139
|
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
@@ -103,3 +144,67 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
|
|
|
103
144
|
const textBlock = response.content.find((block) => block.type === "text");
|
|
104
145
|
return textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?";
|
|
105
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
|
|
149
|
+
*
|
|
150
|
+
* 전략:
|
|
151
|
+
* - 먼저 스트리밍으로 시도
|
|
152
|
+
* - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
|
|
153
|
+
* - 스트리밍은 최종 텍스트 응답에만 사용
|
|
154
|
+
*/
|
|
155
|
+
export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
156
|
+
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
157
|
+
if (!onChunk) {
|
|
158
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
159
|
+
return { text, usedTools: false };
|
|
160
|
+
}
|
|
161
|
+
const client = getClient();
|
|
162
|
+
const modelConfig = MODELS[modelId];
|
|
163
|
+
// 메시지를 API 형식으로 변환
|
|
164
|
+
const apiMessages = messages.map((m) => ({
|
|
165
|
+
role: m.role,
|
|
166
|
+
content: m.content,
|
|
167
|
+
}));
|
|
168
|
+
// 스트리밍 요청 파라미터
|
|
169
|
+
const params = {
|
|
170
|
+
model: modelConfig.id,
|
|
171
|
+
max_tokens: modelConfig.maxTokens,
|
|
172
|
+
messages: apiMessages,
|
|
173
|
+
tools: tools,
|
|
174
|
+
stream: true,
|
|
175
|
+
};
|
|
176
|
+
if (systemPrompt) {
|
|
177
|
+
params.system = systemPrompt;
|
|
178
|
+
}
|
|
179
|
+
// Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
|
|
180
|
+
// (도구 호출 폴백 시 chat()에서 thinking 사용됨)
|
|
181
|
+
let accumulated = "";
|
|
182
|
+
let stopReason = null;
|
|
183
|
+
// 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
|
|
184
|
+
return await withRetry(async () => {
|
|
185
|
+
accumulated = ""; // 재시도 시 초기화
|
|
186
|
+
const stream = client.messages.stream(params);
|
|
187
|
+
// 스트리밍 이벤트 처리
|
|
188
|
+
stream.on("text", async (text) => {
|
|
189
|
+
accumulated += text;
|
|
190
|
+
try {
|
|
191
|
+
await onChunk(text, accumulated);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// editMessageText 실패 등은 무시하고 계속
|
|
195
|
+
console.warn("[Stream] Chunk callback error (ignored):", err);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// 스트림 완료 대기
|
|
199
|
+
const finalMessage = await stream.finalMessage();
|
|
200
|
+
stopReason = finalMessage.stop_reason;
|
|
201
|
+
// 도구 호출이 필요한 경우 - 일반 chat으로 폴백
|
|
202
|
+
if (stopReason === "tool_use") {
|
|
203
|
+
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
204
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
205
|
+
return { text, usedTools: true };
|
|
206
|
+
}
|
|
207
|
+
// 성공적으로 스트리밍 완료
|
|
208
|
+
return { text: accumulated, usedTools: false };
|
|
209
|
+
});
|
|
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/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { isValidCronExpression, parseCronExpression, getNextCronRun, getNextRun,
|
|
|
8
8
|
// Storage functions
|
|
9
9
|
export { loadJobs, saveJobs, addJob, removeJob, updateJob, getDueJobs, markJobExecuted, getJobsByChat, getJob, calculateNextRun, } from "./store.js";
|
|
10
10
|
// Scheduler functions
|
|
11
|
-
export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, } from "./scheduler.js";
|
|
11
|
+
export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, ensureDefaultCronJobs, } from "./scheduler.js";
|
|
12
12
|
// Command handlers (for tools)
|
|
13
13
|
export { addCronJob, removeCronJob, setCronJobEnabled, listCronJobs, getCronStatus, } from "./commands.js";
|
|
14
14
|
// Aliases for backward compatibility
|
package/dist/cron/parser.js
CHANGED
|
@@ -130,184 +130,83 @@ export function parseCronExpression(expr) {
|
|
|
130
130
|
};
|
|
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
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
month: "2-digit",
|
|
141
|
-
day: "2-digit",
|
|
142
|
-
hour: "2-digit",
|
|
143
|
-
minute: "2-digit",
|
|
144
|
-
weekday: "short",
|
|
145
|
-
hour12: false,
|
|
146
|
-
});
|
|
147
|
-
const parts = formatter.formatToParts(date);
|
|
148
|
-
const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
|
|
149
|
-
const dayOfWeekMap = {
|
|
150
|
-
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
151
|
-
};
|
|
152
|
-
return {
|
|
153
|
-
year: parseInt(getValue("year"), 10),
|
|
154
|
-
month: parseInt(getValue("month"), 10),
|
|
155
|
-
day: parseInt(getValue("day"), 10),
|
|
156
|
-
hour: parseInt(getValue("hour"), 10),
|
|
157
|
-
minute: parseInt(getValue("minute"), 10),
|
|
158
|
-
dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
catch {
|
|
162
|
-
// Fallback to local time if timezone is invalid
|
|
163
|
-
return {
|
|
164
|
-
year: date.getFullYear(),
|
|
165
|
-
month: date.getMonth() + 1,
|
|
166
|
-
day: date.getDate(),
|
|
167
|
-
hour: date.getHours(),
|
|
168
|
-
minute: date.getMinutes(),
|
|
169
|
-
dayOfWeek: date.getDay(),
|
|
170
|
-
};
|
|
136
|
+
export function getNextCronRun(expression, fromDate = new Date(), timezone) {
|
|
137
|
+
// null/undefined/빈 문자열 처리
|
|
138
|
+
if (!expression || expression.trim() === "") {
|
|
139
|
+
console.warn("[Cron] Empty cron expression provided");
|
|
140
|
+
return null;
|
|
171
141
|
}
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Create a Date object for a specific time in a timezone
|
|
175
|
-
*/
|
|
176
|
-
function createDateInTimezone(year, month, day, hour, minute, timezone) {
|
|
177
142
|
try {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
hour: "2-digit",
|
|
185
|
-
minute: "2-digit",
|
|
186
|
-
hour12: false,
|
|
187
|
-
});
|
|
188
|
-
// Binary search for the correct UTC time
|
|
189
|
-
let guess = new Date(dateStr);
|
|
190
|
-
for (let i = 0; i < 3; i++) {
|
|
191
|
-
const parts = formatter.formatToParts(guess);
|
|
192
|
-
const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
|
|
193
|
-
const guessHour = getValue("hour");
|
|
194
|
-
const guessMinute = getValue("minute");
|
|
195
|
-
const guessDay = getValue("day");
|
|
196
|
-
let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
|
|
197
|
-
if (day !== guessDay) {
|
|
198
|
-
if (day > guessDay || (day === 1 && guessDay > 20)) {
|
|
199
|
-
diffMinutes += 24 * 60;
|
|
200
|
-
}
|
|
201
|
-
else {
|
|
202
|
-
diffMinutes -= 24 * 60;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (diffMinutes === 0)
|
|
206
|
-
break;
|
|
207
|
-
guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
|
|
208
|
-
}
|
|
209
|
-
return guess;
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
return new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Get number of days in a month
|
|
217
|
-
*/
|
|
218
|
-
function getDaysInMonth(year, month) {
|
|
219
|
-
return new Date(year, month, 0).getDate();
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Calculate the next run time for a cron expression with timezone support
|
|
223
|
-
*/
|
|
224
|
-
export function getNextCronRun(expression, fromDate = new Date(), timezone) {
|
|
225
|
-
const parsed = parseCronExpression(expression);
|
|
226
|
-
const now = fromDate;
|
|
227
|
-
const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
228
|
-
// Get current time in target timezone
|
|
229
|
-
const current = getTimeInTimezone(now, tz);
|
|
230
|
-
// Search for next valid time (up to 366 days ahead)
|
|
231
|
-
const searchDate = { ...current };
|
|
232
|
-
const maxIterations = 366 * 24 * 60;
|
|
233
|
-
for (let i = 0; i < maxIterations; i++) {
|
|
234
|
-
// Advance by one minute each iteration
|
|
235
|
-
if (i > 0) {
|
|
236
|
-
searchDate.minute++;
|
|
237
|
-
if (searchDate.minute > 59) {
|
|
238
|
-
searchDate.minute = 0;
|
|
239
|
-
searchDate.hour++;
|
|
240
|
-
if (searchDate.hour > 23) {
|
|
241
|
-
searchDate.hour = 0;
|
|
242
|
-
searchDate.day++;
|
|
243
|
-
searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
|
|
244
|
-
const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
|
|
245
|
-
if (searchDate.day > daysInMonth) {
|
|
246
|
-
searchDate.day = 1;
|
|
247
|
-
searchDate.month++;
|
|
248
|
-
if (searchDate.month > 12) {
|
|
249
|
-
searchDate.month = 1;
|
|
250
|
-
searchDate.year++;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
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;
|
|
255
149
|
}
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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;
|
|
268
169
|
}
|
|
269
170
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (!parsed.hour.values.includes(searchDate.hour))
|
|
279
|
-
continue;
|
|
280
|
-
if (!parsed.minute.values.includes(searchDate.minute))
|
|
281
|
-
continue;
|
|
282
|
-
// Skip if this is the current minute (must be in the future)
|
|
283
|
-
if (i === 0)
|
|
284
|
-
continue;
|
|
285
|
-
// Create the date in the target timezone
|
|
286
|
-
const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
|
|
287
|
-
// Verify it's in the future
|
|
288
|
-
if (nextRun > now) {
|
|
289
|
-
return nextRun;
|
|
290
|
-
}
|
|
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;
|
|
291
177
|
}
|
|
292
|
-
throw new Error(`Could not find next run time for: ${expression}`);
|
|
293
178
|
}
|
|
294
179
|
/**
|
|
295
180
|
* Get the next run time for any schedule type
|
|
181
|
+
* Returns NaN if schedule is invalid (check with isNaN())
|
|
296
182
|
*/
|
|
297
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
|
+
}
|
|
298
189
|
switch (schedule.kind) {
|
|
299
190
|
case "at":
|
|
300
|
-
return schedule.atMs;
|
|
191
|
+
return schedule.atMs ?? NaN;
|
|
301
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
|
+
}
|
|
302
198
|
const startMs = schedule.startMs ?? now.getTime();
|
|
303
199
|
const elapsed = now.getTime() - startMs;
|
|
304
|
-
const intervals = Math.floor(elapsed /
|
|
305
|
-
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;
|
|
306
206
|
}
|
|
307
|
-
case "cron":
|
|
308
|
-
return getNextCronRun(schedule.expression, now, schedule.timezone).getTime();
|
|
309
207
|
default:
|
|
310
|
-
|
|
208
|
+
console.warn(`[Cron] Unknown schedule kind: ${schedule.kind}`);
|
|
209
|
+
return NaN;
|
|
311
210
|
}
|
|
312
211
|
}
|
|
313
212
|
/**
|