companionbot 0.6.0 → 0.8.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.
@@ -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
  /**
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 봇 헬스 체크 모듈
3
+ * 봇의 상태를 추적하고 모니터링합니다.
4
+ * @module health
5
+ */
6
+ let startTime = Date.now();
7
+ let lastActivity = Date.now();
8
+ let messageCount = 0;
9
+ let errorCount = 0;
10
+ /**
11
+ * 활동을 기록합니다.
12
+ * 메시지 처리 시 호출하여 마지막 활동 시간과 메시지 카운트를 업데이트합니다.
13
+ */
14
+ export function recordActivity() {
15
+ lastActivity = Date.now();
16
+ messageCount++;
17
+ }
18
+ /**
19
+ * 오류 발생을 기록합니다.
20
+ */
21
+ export function recordError() {
22
+ errorCount++;
23
+ }
24
+ /**
25
+ * 현재 봇의 건강 상태를 조회합니다.
26
+ * @returns 건강 상태 정보
27
+ */
28
+ export function getHealthStatus() {
29
+ const now = Date.now();
30
+ const uptime = Math.floor((now - startTime) / 1000);
31
+ const inactiveTime = now - lastActivity;
32
+ // 30분 이상 활동 없으면 unhealthy
33
+ const isHealthy = inactiveTime < 30 * 60 * 1000;
34
+ return {
35
+ uptime,
36
+ lastActivity,
37
+ messageCount,
38
+ errorCount,
39
+ isHealthy
40
+ };
41
+ }
42
+ /**
43
+ * 가동 시간을 읽기 좋은 형태로 포맷합니다.
44
+ * @param seconds - 가동 시간 (초)
45
+ * @returns 포맷된 문자열 (예: "2일 3시간", "5시간 30분")
46
+ */
47
+ export function formatUptime(seconds) {
48
+ const days = Math.floor(seconds / 86400);
49
+ const hours = Math.floor((seconds % 86400) / 3600);
50
+ const mins = Math.floor((seconds % 3600) / 60);
51
+ if (days > 0)
52
+ return `${days}일 ${hours}시간`;
53
+ if (hours > 0)
54
+ return `${hours}시간 ${mins}분`;
55
+ return `${mins}분`;
56
+ }
57
+ /**
58
+ * 헬스 상태를 초기화합니다.
59
+ * 테스트나 재시작 시 사용합니다.
60
+ */
61
+ export function resetHealth() {
62
+ startTime = Date.now();
63
+ lastActivity = Date.now();
64
+ messageCount = 0;
65
+ errorCount = 0;
66
+ }
@@ -4,15 +4,21 @@ import { getWorkspacePath } from "../workspace/index.js";
4
4
  import { chat } from "../ai/claude.js";
5
5
  import { isCalendarConfigured, getTodayEvents, formatEvent } from "../calendar/index.js";
6
6
  import { getSecret } from "../config/secrets.js";
7
+ import { checkForUpdates } from "../updates/index.js";
8
+ import { INTERVAL_30_MINUTES, INTERVAL_24_HOURS, hoursToMs } from "../utils/time.js";
7
9
  // 활성 타이머
8
10
  const activeTimers = new Map();
9
11
  // 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
10
12
  // lastCheckAt, lastMessageAt은 디버깅 용도라 매번 저장할 필요 없음
11
13
  const timestampCache = new Map();
14
+ // 업데이트 체크 캐시 (하루에 한 번만)
15
+ let lastUpdateCheck = 0;
16
+ let cachedUpdateInfo = null;
17
+ const UPDATE_CHECK_INTERVAL = INTERVAL_24_HOURS;
12
18
  // 봇 인스턴스
13
19
  let botInstance = null;
14
20
  // 기본 간격: 30분
15
- const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
21
+ const DEFAULT_INTERVAL_MS = INTERVAL_30_MINUTES;
16
22
  export function setHeartbeatBot(bot) {
17
23
  botInstance = bot;
18
24
  }
@@ -79,6 +85,21 @@ async function gatherContext() {
79
85
  // 무시
80
86
  }
81
87
  }
88
+ // 업데이트 체크 (하루에 한 번)
89
+ const timeSinceLastCheck = Date.now() - lastUpdateCheck;
90
+ if (timeSinceLastCheck > UPDATE_CHECK_INTERVAL) {
91
+ try {
92
+ cachedUpdateInfo = await checkForUpdates();
93
+ lastUpdateCheck = Date.now();
94
+ console.log(`[Heartbeat] Update check: current=${cachedUpdateInfo.current}, latest=${cachedUpdateInfo.latest}`);
95
+ }
96
+ catch (error) {
97
+ console.error("[Heartbeat] Update check failed:", error);
98
+ }
99
+ }
100
+ if (cachedUpdateInfo?.hasUpdate) {
101
+ parts.push(`🆕 업데이트 알림: CompanionBot ${cachedUpdateInfo.latest} 버전이 출시됨! (현재: ${cachedUpdateInfo.current})`);
102
+ }
82
103
  return parts.join("\n");
83
104
  }
84
105
  // Heartbeat 실행 - 메시지를 보냈으면 true 반환
@@ -257,7 +278,7 @@ export async function runHeartbeatNow(chatId) {
257
278
  enabled: false,
258
279
  intervalMs: DEFAULT_INTERVAL_MS,
259
280
  lastCheckAt: Date.now(),
260
- lastMessageAt: Date.now() - (8 * 60 * 60 * 1000), // 8시간 전으로 설정
281
+ lastMessageAt: Date.now() - hoursToMs(8), // 8시간 전으로 설정
261
282
  };
262
283
  return await executeHeartbeat(defaultConfig);
263
284
  }
@@ -37,6 +37,10 @@ async function getEmbeddingPipeline() {
37
37
  * @returns 384차원 임베딩 벡터
38
38
  */
39
39
  export async function embed(text) {
40
+ // null/undefined 처리
41
+ if (text == null) {
42
+ return new Array(384).fill(0);
43
+ }
40
44
  const pipe = await getEmbeddingPipeline();
41
45
  // 텍스트 정규화
42
46
  const cleanText = text.trim().slice(0, 512); // 최대 512자
@@ -52,13 +56,25 @@ export async function embed(text) {
52
56
  }
53
57
  /**
54
58
  * 여러 텍스트를 배치로 임베딩합니다.
59
+ * 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
55
60
  * @param texts 변환할 텍스트 배열
56
61
  * @returns 임베딩 벡터 배열
57
62
  */
58
63
  export async function embedBatch(texts) {
59
- const results = [];
60
- for (const text of texts) {
61
- results.push(await embed(text));
64
+ // null/undefined 배열 처리
65
+ if (!texts || texts.length === 0)
66
+ return [];
67
+ if (texts.length === 1)
68
+ return [await embed(texts[0])];
69
+ // 동시성 제한 (메모리 보호)
70
+ const CONCURRENCY = 5;
71
+ const results = new Array(texts.length);
72
+ for (let i = 0; i < texts.length; i += CONCURRENCY) {
73
+ const batch = texts.slice(i, i + CONCURRENCY);
74
+ const batchResults = await Promise.all(batch.map(text => embed(text)));
75
+ for (let j = 0; j < batchResults.length; j++) {
76
+ results[i + j] = batchResults[j];
77
+ }
62
78
  }
63
79
  return results;
64
80
  }
@@ -70,6 +86,9 @@ export async function embedBatch(texts) {
70
86
  * normalized 파라미터가 true면 내적만 계산하여 성능 향상.
71
87
  */
72
88
  export function cosineSimilarity(a, b, normalized = true) {
89
+ // null/undefined 또는 빈 배열 처리
90
+ if (!a || !b || a.length === 0 || b.length === 0)
91
+ return 0;
73
92
  if (a.length !== b.length)
74
93
  return 0;
75
94
  let dotProduct = 0;
@@ -5,7 +5,7 @@
5
5
  import * as fs from "fs/promises";
6
6
  import * as path from "path";
7
7
  import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
8
- import { embed, cosineSimilarity } from "./embeddings.js";
8
+ import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
9
9
  // 캐시된 청크들 (임베딩 포함)
10
10
  let cachedChunks = [];
11
11
  let cacheTimestamp = 0;
@@ -103,10 +103,16 @@ export async function loadAllMemoryChunks() {
103
103
  try {
104
104
  const chunks = await loadingPromise;
105
105
  // 캐시 업데이트 (임베딩은 아직 없음)
106
+ // 빈 결과도 캐시하되 TTL을 짧게 (1분)
106
107
  cachedChunks = chunks;
107
- cacheTimestamp = Date.now();
108
+ cacheTimestamp = chunks.length > 0 ? Date.now() : Date.now() - CACHE_TTL_MS + 60000;
108
109
  return chunks;
109
110
  }
111
+ catch (error) {
112
+ // 로드 실패 시 캐시하지 않음
113
+ console.error("[VectorStore] Failed to load memory chunks:", error);
114
+ return [];
115
+ }
110
116
  finally {
111
117
  loadingPromise = null;
112
118
  }
@@ -122,25 +128,41 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
122
128
  if (chunks.length === 0) {
123
129
  return [];
124
130
  }
125
- // 청크에 대해 임베딩 생성 및 유사도 계산
126
- const results = [];
127
- for (const chunk of chunks) {
131
+ // 임베딩이 없는 청크들을 배치로 처리
132
+ const chunksNeedingEmbedding = chunks.filter(c => !c.embedding);
133
+ if (chunksNeedingEmbedding.length > 0) {
128
134
  try {
129
- // 캐시된 임베딩이 없으면 생성
130
- if (!chunk.embedding) {
131
- chunk.embedding = await embed(chunk.text);
132
- }
133
- const score = cosineSimilarity(queryEmbedding, chunk.embedding);
134
- if (score >= minScore) {
135
- results.push({
136
- text: chunk.text,
137
- source: chunk.source,
138
- score,
139
- });
135
+ const texts = chunksNeedingEmbedding.map(c => c.text);
136
+ const embeddings = await embedBatch(texts);
137
+ // 임베딩 할당
138
+ for (let i = 0; i < chunksNeedingEmbedding.length; i++) {
139
+ chunksNeedingEmbedding[i].embedding = embeddings[i];
140
140
  }
141
141
  }
142
142
  catch {
143
- // 임베딩 실패 무시
143
+ // 배치 실패 시 개별 처리 폴백
144
+ for (const chunk of chunksNeedingEmbedding) {
145
+ try {
146
+ chunk.embedding = await embed(chunk.text);
147
+ }
148
+ catch {
149
+ // 개별 실패 무시
150
+ }
151
+ }
152
+ }
153
+ }
154
+ // 유사도 계산 및 필터링
155
+ const results = [];
156
+ for (const chunk of chunks) {
157
+ if (!chunk.embedding)
158
+ continue;
159
+ const score = cosineSimilarity(queryEmbedding, chunk.embedding);
160
+ if (score >= minScore) {
161
+ results.push({
162
+ text: chunk.text,
163
+ source: chunk.source,
164
+ score,
165
+ });
144
166
  }
145
167
  }
146
168
  // 유사도 점수로 정렬하고 상위 K개 반환
@@ -9,6 +9,16 @@ const sessions = new Map();
9
9
  // AsyncLocalStorage for chatId context
10
10
  const chatIdStorage = new AsyncLocalStorage();
11
11
  function getSession(chatId) {
12
+ // chatId 유효성 검사
13
+ if (chatId == null || isNaN(chatId)) {
14
+ console.warn(`[Session] Invalid chatId: ${chatId}, using fallback session`);
15
+ // 임시 세션 반환 (저장하지 않음)
16
+ return {
17
+ history: [],
18
+ model: "sonnet",
19
+ lastAccessedAt: Date.now(),
20
+ };
21
+ }
12
22
  const existing = sessions.get(chatId);
13
23
  const now = Date.now();
14
24
  if (existing) {
@@ -44,13 +54,20 @@ function cleanupSessions() {
44
54
  }
45
55
  }
46
56
  export function getHistory(chatId) {
47
- return getSession(chatId).history;
57
+ const session = getSession(chatId);
58
+ // 참조 반환 (외부 수정 허용 - 의도적)
59
+ // 필요시 [...session.history]로 복사본 반환 가능
60
+ return session.history ?? [];
48
61
  }
49
62
  /**
50
63
  * 히스토리를 토큰 기반으로 트리밍한다.
51
64
  * 최대 토큰 한도를 초과하면 가장 오래된 메시지부터 제거 (최소 2개는 유지).
52
65
  */
53
66
  export function trimHistoryByTokens(history) {
67
+ // null/undefined/빈 배열 처리
68
+ if (!history || history.length === 0) {
69
+ return;
70
+ }
54
71
  while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > 2) {
55
72
  history.shift();
56
73
  }
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "crypto";
2
+ import { getHealthStatus, formatUptime } from "../../health/index.js";
2
3
  import { chat, MODELS } from "../../ai/claude.js";
3
4
  import { estimateMessagesTokens } from "../../utils/tokens.js";
4
5
  // 대화 요약 생성 함수
@@ -458,8 +459,12 @@ export function registerCommands(bot) {
458
459
  await ctx.reply("❌ 인증 실패. 다시 시도해주세요.");
459
460
  }
460
461
  })
461
- .catch(() => {
462
- // 타임아웃
462
+ .catch(async (error) => {
463
+ const errorMsg = error instanceof Error ? error.message : String(error);
464
+ console.error(`[Calendar] Auth server error for chatId=${ctx.chat.id}:`, errorMsg);
465
+ if (errorMsg.includes("timeout") || errorMsg.includes("Timeout")) {
466
+ await ctx.reply("⏰ 인증 시간이 만료됐어요. /calendar_setup 으로 다시 시도해주세요.");
467
+ }
463
468
  });
464
469
  }
465
470
  return;
@@ -512,8 +517,12 @@ export function registerCommands(bot) {
512
517
  await ctx.reply("❌ 인증 실패. 다시 시도해주세요.");
513
518
  }
514
519
  })
515
- .catch(() => {
516
- // 타임아웃
520
+ .catch(async (error) => {
521
+ const errorMsg = error instanceof Error ? error.message : String(error);
522
+ console.error("[Calendar] Auth server error:", errorMsg);
523
+ if (errorMsg.includes("timeout") || errorMsg.includes("Timeout")) {
524
+ await ctx.reply("⏰ 인증 시간이 만료됐어요. /calendar_setup 으로 다시 시도해주세요.");
525
+ }
517
526
  });
518
527
  }
519
528
  return;
@@ -540,8 +549,17 @@ export function registerCommands(bot) {
540
549
  await ctx.reply(message);
541
550
  }
542
551
  catch (error) {
543
- console.error("Calendar error:", error);
544
- await ctx.reply("캘린더 조회 오류가 발생했어요.");
552
+ const errorMsg = error instanceof Error ? error.message : String(error);
553
+ console.error(`[Calendar] chatId=${ctx.chat.id} getTodayEvents error:`, errorMsg);
554
+ if (errorMsg.includes("invalid_grant") || errorMsg.includes("Token")) {
555
+ await ctx.reply("캘린더 인증이 만료됐어요. /calendar_setup 으로 다시 연동해주세요.");
556
+ }
557
+ else if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) {
558
+ await ctx.reply("Google 서버 응답이 느려요. 잠시 후 다시 시도해주세요.");
559
+ }
560
+ else {
561
+ await ctx.reply("캘린더를 불러오지 못했어요. 잠시 후 다시 시도해주세요.");
562
+ }
545
563
  }
546
564
  });
547
565
  // /briefing 명령어 - 토글 방식
@@ -582,4 +600,13 @@ export function registerCommands(bot) {
582
600
  `"10분마다 체크해줘"로 간격 변경 가능`);
583
601
  }
584
602
  });
603
+ // /health 명령어 - 봇 상태 확인
604
+ bot.command("health", async (ctx) => {
605
+ const status = getHealthStatus();
606
+ await ctx.reply(`🏥 봇 상태\n\n` +
607
+ `⏱ 가동: ${formatUptime(status.uptime)}\n` +
608
+ `💬 메시지: ${status.messageCount}개\n` +
609
+ `❌ 에러: ${status.errorCount}개\n` +
610
+ `🔋 상태: ${status.isHealthy ? "정상 ✅" : "점검 필요 ⚠️"}`);
611
+ });
585
612
  }