companionbot 0.3.0 → 0.4.1

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.
@@ -1,16 +1,24 @@
1
1
  /**
2
2
  * Cron Job Store
3
3
  *
4
- * Persists and manages cron jobs.
4
+ * Persists and manages cron jobs with race condition protection.
5
5
  */
6
6
  import * as fs from "fs/promises";
7
7
  import * as path from "path";
8
8
  import { getWorkspacePath } from "../workspace/paths.js";
9
9
  const CRON_FILE = "cron-jobs.json";
10
+ const LOCK_FILE = "cron-jobs.lock";
10
11
  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;
11
16
  function getCronFilePath() {
12
17
  return path.join(getWorkspacePath(), CRON_FILE);
13
18
  }
19
+ function getLockFilePath() {
20
+ return path.join(getWorkspacePath(), LOCK_FILE);
21
+ }
14
22
  /**
15
23
  * Generate a unique ID without uuid dependency
16
24
  */
@@ -18,9 +26,72 @@ function generateId() {
18
26
  return crypto.randomUUID();
19
27
  }
20
28
  /**
21
- * Load all cron jobs from storage
29
+ * Simple file-based lock for race condition prevention
22
30
  */
23
- export async function loadJobs() {
31
+ async function acquireLock() {
32
+ const lockPath = getLockFilePath();
33
+ const lockId = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
34
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
35
+ try {
36
+ // Try to create lock file exclusively
37
+ await fs.writeFile(lockPath, lockId, { flag: "wx" });
38
+ return true;
39
+ }
40
+ catch (error) {
41
+ if (error.code === "EEXIST") {
42
+ // Lock exists, check if it's stale
43
+ try {
44
+ const stat = await fs.stat(lockPath);
45
+ const age = Date.now() - stat.mtimeMs;
46
+ if (age > LOCK_TIMEOUT_MS) {
47
+ // Stale lock, remove it
48
+ await fs.unlink(lockPath).catch(() => { });
49
+ continue;
50
+ }
51
+ }
52
+ catch {
53
+ // Lock file gone, retry immediately
54
+ continue;
55
+ }
56
+ // Wait and retry
57
+ await sleep(LOCK_RETRY_MS);
58
+ }
59
+ else {
60
+ // Other error, assume we can proceed
61
+ return true;
62
+ }
63
+ }
64
+ }
65
+ console.warn("[Cron] Failed to acquire lock after max retries, proceeding anyway");
66
+ return false;
67
+ }
68
+ async function releaseLock() {
69
+ try {
70
+ await fs.unlink(getLockFilePath());
71
+ }
72
+ catch {
73
+ // Ignore errors on unlock
74
+ }
75
+ }
76
+ function sleep(ms) {
77
+ return new Promise((resolve) => setTimeout(resolve, ms));
78
+ }
79
+ /**
80
+ * Execute a function with file lock protection
81
+ */
82
+ async function withLock(fn) {
83
+ await acquireLock();
84
+ try {
85
+ return await fn();
86
+ }
87
+ finally {
88
+ await releaseLock();
89
+ }
90
+ }
91
+ /**
92
+ * Load all cron jobs from storage (internal, no lock)
93
+ */
94
+ async function loadJobsInternal() {
24
95
  try {
25
96
  const data = await fs.readFile(getCronFilePath(), "utf-8");
26
97
  const store = JSON.parse(data);
@@ -35,63 +106,92 @@ export async function loadJobs() {
35
106
  }
36
107
  }
37
108
  /**
38
- * Save all cron jobs to storage
109
+ * Save all cron jobs to storage (internal, no lock)
110
+ * Uses atomic write pattern: write to temp file, then rename
39
111
  */
40
- export async function saveJobs(jobs) {
112
+ async function saveJobsInternal(jobs) {
41
113
  const store = {
42
114
  version: STORE_VERSION,
43
115
  jobs,
44
116
  };
45
- await fs.writeFile(getCronFilePath(), JSON.stringify(store, null, 2), "utf-8");
117
+ const filePath = getCronFilePath();
118
+ const tempPath = `${filePath}.tmp.${process.pid}`;
119
+ try {
120
+ await fs.writeFile(tempPath, JSON.stringify(store, null, 2), "utf-8");
121
+ await fs.rename(tempPath, filePath);
122
+ }
123
+ catch (error) {
124
+ // Clean up temp file on error
125
+ await fs.unlink(tempPath).catch(() => { });
126
+ throw error;
127
+ }
128
+ }
129
+ /**
130
+ * Load all cron jobs from storage
131
+ */
132
+ export async function loadJobs() {
133
+ return withLock(() => loadJobsInternal());
134
+ }
135
+ /**
136
+ * Save all cron jobs to storage
137
+ */
138
+ export async function saveJobs(jobs) {
139
+ return withLock(() => saveJobsInternal(jobs));
46
140
  }
47
141
  /**
48
142
  * Add a new cron job
49
143
  */
50
144
  export async function addJob(newJob) {
51
- const jobs = await loadJobs();
52
- const job = {
53
- ...newJob,
54
- id: generateId(),
55
- createdAt: new Date().toISOString(),
56
- runCount: newJob.runCount ?? 0,
57
- };
58
- // Calculate initial nextRun
59
- if (job.schedule) {
60
- job.nextRun = calculateNextRun(job.schedule);
61
- }
62
- else {
63
- // Use cronExpr to calculate next run
64
- job.nextRun = calculateCronNextRun(job.cronExpr, job.timezone);
65
- }
66
- jobs.push(job);
67
- await saveJobs(jobs);
68
- return job;
145
+ return withLock(async () => {
146
+ const jobs = await loadJobsInternal();
147
+ const job = {
148
+ ...newJob,
149
+ id: generateId(),
150
+ createdAt: new Date().toISOString(),
151
+ runCount: newJob.runCount ?? 0,
152
+ };
153
+ // Calculate initial nextRun
154
+ if (job.schedule) {
155
+ job.nextRun = calculateNextRun(job.schedule);
156
+ }
157
+ else {
158
+ // Use cronExpr to calculate next run
159
+ job.nextRun = calculateCronNextRun(job.cronExpr, job.timezone);
160
+ }
161
+ jobs.push(job);
162
+ await saveJobsInternal(jobs);
163
+ return job;
164
+ });
69
165
  }
70
166
  /**
71
167
  * Remove a cron job by ID
72
168
  */
73
169
  export async function removeJob(jobId) {
74
- const jobs = await loadJobs();
75
- const index = jobs.findIndex((j) => j.id === jobId);
76
- if (index === -1) {
77
- return false;
78
- }
79
- jobs.splice(index, 1);
80
- await saveJobs(jobs);
81
- return true;
170
+ return withLock(async () => {
171
+ const jobs = await loadJobsInternal();
172
+ const index = jobs.findIndex((j) => j.id === jobId);
173
+ if (index === -1) {
174
+ return false;
175
+ }
176
+ jobs.splice(index, 1);
177
+ await saveJobsInternal(jobs);
178
+ return true;
179
+ });
82
180
  }
83
181
  /**
84
- * Update a cron job
182
+ * Update a cron job (atomic)
85
183
  */
86
184
  export async function updateJob(jobId, updates) {
87
- const jobs = await loadJobs();
88
- const index = jobs.findIndex((j) => j.id === jobId);
89
- if (index === -1) {
90
- return null;
91
- }
92
- jobs[index] = { ...jobs[index], ...updates };
93
- await saveJobs(jobs);
94
- return jobs[index];
185
+ return withLock(async () => {
186
+ const jobs = await loadJobsInternal();
187
+ const index = jobs.findIndex((j) => j.id === jobId);
188
+ if (index === -1) {
189
+ return null;
190
+ }
191
+ jobs[index] = { ...jobs[index], ...updates };
192
+ await saveJobsInternal(jobs);
193
+ return jobs[index];
194
+ });
95
195
  }
96
196
  /**
97
197
  * Get jobs that are due to run
@@ -112,30 +212,32 @@ export async function getDueJobs() {
112
212
  });
113
213
  }
114
214
  /**
115
- * Mark a job as executed and update nextRun
215
+ * Mark a job as executed and update nextRun (atomic)
116
216
  */
117
217
  export async function markJobExecuted(jobId) {
118
- const jobs = await loadJobs();
119
- const job = jobs.find((j) => j.id === jobId);
120
- if (!job)
121
- return;
122
- job.lastRun = new Date().toISOString();
123
- job.runCount = (job.runCount || 0) + 1;
124
- // Check if job should be disabled (one-time jobs)
125
- if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
126
- job.enabled = false;
127
- job.nextRun = undefined;
128
- }
129
- else {
130
- // Calculate next run
131
- if (job.schedule) {
132
- job.nextRun = calculateNextRun(job.schedule);
218
+ await withLock(async () => {
219
+ const jobs = await loadJobsInternal();
220
+ const job = jobs.find((j) => j.id === jobId);
221
+ if (!job)
222
+ return;
223
+ job.lastRun = new Date().toISOString();
224
+ job.runCount = (job.runCount || 0) + 1;
225
+ // Check if job should be disabled (one-time jobs)
226
+ if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
227
+ job.enabled = false;
228
+ job.nextRun = undefined;
133
229
  }
134
230
  else {
135
- job.nextRun = calculateCronNextRun(job.cronExpr, job.timezone);
231
+ // Calculate next run
232
+ if (job.schedule) {
233
+ job.nextRun = calculateNextRun(job.schedule);
234
+ }
235
+ else {
236
+ job.nextRun = calculateCronNextRun(job.cronExpr, job.timezone);
237
+ }
136
238
  }
137
- }
138
- await saveJobs(jobs);
239
+ await saveJobsInternal(jobs);
240
+ });
139
241
  }
140
242
  /**
141
243
  * Calculate the next run time for a schedule
@@ -164,49 +266,198 @@ export function calculateNextRun(schedule) {
164
266
  }
165
267
  }
166
268
  /**
167
- * Parse and calculate next run for cron expression
269
+ * Get current time components in a specific timezone
270
+ */
271
+ function getTimeInTimezone(date, timezone) {
272
+ try {
273
+ // Use Intl.DateTimeFormat to get time in target timezone
274
+ const formatter = new Intl.DateTimeFormat("en-US", {
275
+ timeZone: timezone,
276
+ year: "numeric",
277
+ month: "2-digit",
278
+ day: "2-digit",
279
+ hour: "2-digit",
280
+ minute: "2-digit",
281
+ weekday: "short",
282
+ hour12: false,
283
+ });
284
+ const parts = formatter.formatToParts(date);
285
+ const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
286
+ const dayOfWeekMap = {
287
+ Sun: 0,
288
+ Mon: 1,
289
+ Tue: 2,
290
+ Wed: 3,
291
+ Thu: 4,
292
+ Fri: 5,
293
+ Sat: 6,
294
+ };
295
+ return {
296
+ year: parseInt(getValue("year"), 10),
297
+ month: parseInt(getValue("month"), 10),
298
+ day: parseInt(getValue("day"), 10),
299
+ hour: parseInt(getValue("hour"), 10),
300
+ minute: parseInt(getValue("minute"), 10),
301
+ dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
302
+ };
303
+ }
304
+ catch {
305
+ // Fallback to local time if timezone is invalid
306
+ return {
307
+ year: date.getFullYear(),
308
+ month: date.getMonth() + 1,
309
+ day: date.getDate(),
310
+ hour: date.getHours(),
311
+ minute: date.getMinutes(),
312
+ dayOfWeek: date.getDay(),
313
+ };
314
+ }
315
+ }
316
+ /**
317
+ * Create a Date object for a specific time in a timezone
318
+ */
319
+ function createDateInTimezone(year, month, day, hour, minute, timezone) {
320
+ try {
321
+ // Create a date string and parse it in the target timezone
322
+ const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
323
+ // Get the offset for this date/time in the target timezone
324
+ const testDate = new Date(dateStr + "Z");
325
+ const formatter = new Intl.DateTimeFormat("en-US", {
326
+ timeZone: timezone,
327
+ year: "numeric",
328
+ month: "2-digit",
329
+ day: "2-digit",
330
+ hour: "2-digit",
331
+ minute: "2-digit",
332
+ hour12: false,
333
+ });
334
+ // Binary search for the correct UTC time that gives us the target local time
335
+ // Start with a guess assuming no offset
336
+ let guess = new Date(dateStr);
337
+ for (let i = 0; i < 3; i++) {
338
+ const parts = formatter.formatToParts(guess);
339
+ const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
340
+ const guessHour = getValue("hour");
341
+ const guessMinute = getValue("minute");
342
+ const guessDay = getValue("day");
343
+ // Calculate difference in minutes
344
+ let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
345
+ // Handle day wrap
346
+ if (day !== guessDay) {
347
+ if (day > guessDay || (day === 1 && guessDay > 20)) {
348
+ diffMinutes += 24 * 60;
349
+ }
350
+ else {
351
+ diffMinutes -= 24 * 60;
352
+ }
353
+ }
354
+ if (diffMinutes === 0)
355
+ break;
356
+ guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
357
+ }
358
+ return guess;
359
+ }
360
+ catch {
361
+ // Fallback: create local date
362
+ return new Date(year, month - 1, day, hour, minute, 0, 0);
363
+ }
364
+ }
365
+ /**
366
+ * Parse and calculate next run for cron expression with timezone support
168
367
  * Supports: minute hour day month weekday
169
368
  */
170
- function calculateCronNextRun(expression, _timezone) {
171
- // Basic implementation - for production, use node-cron or cron-parser
369
+ function calculateCronNextRun(expression, timezone) {
172
370
  const parts = expression.split(" ");
173
371
  if (parts.length !== 5) {
174
372
  console.error("[Cron] Invalid cron expression:", expression);
175
373
  return undefined;
176
374
  }
177
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
375
+ const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts;
178
376
  const now = new Date();
179
- // Simple case: specific minute and hour (e.g., "0 9 * * *" = 9:00 every day)
180
- if (minute !== "*" && hour !== "*" && dayOfMonth === "*" && month === "*") {
181
- const targetMinute = parseInt(minute, 10);
182
- const targetHour = parseInt(hour, 10);
183
- const next = new Date(now);
184
- next.setHours(targetHour, targetMinute, 0, 0);
185
- // If already passed today, schedule for tomorrow
186
- if (next <= now) {
187
- next.setDate(next.getDate() + 1);
188
- }
189
- // Check day of week constraint
190
- if (dayOfWeek !== "*") {
191
- const targetDays = parseCronField(dayOfWeek, 0, 6);
192
- while (!targetDays.includes(next.getDay())) {
193
- next.setDate(next.getDate() + 1);
377
+ const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
378
+ // Get current time in target timezone
379
+ const current = getTimeInTimezone(now, tz);
380
+ // Parse cron fields
381
+ const targetMinutes = parseCronField(minuteExpr, 0, 59);
382
+ const targetHours = parseCronField(hourExpr, 0, 23);
383
+ const targetDaysOfMonth = parseCronField(dayOfMonthExpr, 1, 31);
384
+ const targetMonths = parseCronField(monthExpr, 1, 12);
385
+ const targetDaysOfWeek = parseCronField(dayOfWeekExpr, 0, 6);
386
+ // Search for next valid time (up to 366 days ahead)
387
+ let searchDate = { ...current };
388
+ const maxIterations = 366 * 24 * 60; // Max 1 year of minutes
389
+ for (let i = 0; i < maxIterations; i++) {
390
+ // Advance by one minute each iteration
391
+ if (i > 0) {
392
+ searchDate.minute++;
393
+ if (searchDate.minute > 59) {
394
+ searchDate.minute = 0;
395
+ searchDate.hour++;
396
+ if (searchDate.hour > 23) {
397
+ searchDate.hour = 0;
398
+ searchDate.day++;
399
+ searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
400
+ // Handle month overflow (simplified - assumes 31 days)
401
+ const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
402
+ if (searchDate.day > daysInMonth) {
403
+ searchDate.day = 1;
404
+ searchDate.month++;
405
+ if (searchDate.month > 12) {
406
+ searchDate.month = 1;
407
+ searchDate.year++;
408
+ }
409
+ }
410
+ }
194
411
  }
195
412
  }
196
- return next.toISOString();
413
+ // Check if this time matches all constraints
414
+ if (!targetMonths.includes(searchDate.month))
415
+ continue;
416
+ if (!targetDaysOfMonth.includes(searchDate.day))
417
+ continue;
418
+ if (!targetDaysOfWeek.includes(searchDate.dayOfWeek))
419
+ continue;
420
+ if (!targetHours.includes(searchDate.hour))
421
+ continue;
422
+ if (!targetMinutes.includes(searchDate.minute))
423
+ continue;
424
+ // Skip if this is the current minute or in the past
425
+ if (i === 0) {
426
+ // First iteration - must be in the future
427
+ continue;
428
+ }
429
+ // Found a match - create the date in the target timezone
430
+ const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
431
+ // Verify it's in the future
432
+ if (nextRun > now) {
433
+ return nextRun.toISOString();
434
+ }
197
435
  }
198
- // Default: next minute for complex expressions
199
- const next = new Date(now.getTime() + 60000);
200
- next.setSeconds(0, 0);
201
- return next.toISOString();
436
+ console.warn("[Cron] Could not find next run time for:", expression);
437
+ return undefined;
438
+ }
439
+ /**
440
+ * Get number of days in a month
441
+ */
442
+ function getDaysInMonth(year, month) {
443
+ return new Date(year, month, 0).getDate();
202
444
  }
203
445
  /**
204
- * Parse cron field like "1,3,5" or "1-5" into array of numbers
446
+ * Parse cron field like "1,3,5" or "1-5" or step values into array of numbers
205
447
  */
206
448
  function parseCronField(field, min, max) {
207
449
  if (field === "*") {
208
450
  return Array.from({ length: max - min + 1 }, (_, i) => i + min);
209
451
  }
452
+ // Handle step values like */5
453
+ if (field.startsWith("*/")) {
454
+ const step = parseInt(field.slice(2), 10);
455
+ const values = [];
456
+ for (let i = min; i <= max; i += step) {
457
+ values.push(i);
458
+ }
459
+ return values;
460
+ }
210
461
  const values = [];
211
462
  const parts = field.split(",");
212
463
  for (const part of parts) {
@@ -216,11 +467,24 @@ function parseCronField(field, min, max) {
216
467
  values.push(i);
217
468
  }
218
469
  }
470
+ else if (part.includes("/")) {
471
+ // Handle range with step like 0-30/5
472
+ const [range, stepStr] = part.split("/");
473
+ const step = parseInt(stepStr, 10);
474
+ let rangeStart = min;
475
+ let rangeEnd = max;
476
+ if (range.includes("-")) {
477
+ [rangeStart, rangeEnd] = range.split("-").map(Number);
478
+ }
479
+ for (let i = rangeStart; i <= rangeEnd; i += step) {
480
+ values.push(i);
481
+ }
482
+ }
219
483
  else {
220
484
  values.push(parseInt(part, 10));
221
485
  }
222
486
  }
223
- return values;
487
+ return values.filter((v) => v >= min && v <= max);
224
488
  }
225
489
  /**
226
490
  * Get all jobs for a specific chat
@@ -1,8 +1,11 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
1
2
  // 세션 설정
2
3
  const MAX_SESSIONS = 100;
3
4
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
4
5
  // 세션별 상태 저장
5
6
  const sessions = new Map();
7
+ // AsyncLocalStorage for chatId context
8
+ const chatIdStorage = new AsyncLocalStorage();
6
9
  function getSession(chatId) {
7
10
  const existing = sessions.get(chatId);
8
11
  const now = Date.now();
@@ -50,13 +53,19 @@ export function getModel(chatId) {
50
53
  export function setModel(chatId, modelId) {
51
54
  getSession(chatId).model = modelId;
52
55
  }
53
- // 현재 활성 chatId (도구에서 사용)
54
- let currentChatId = null;
55
- export function setCurrentChatId(chatId) {
56
- currentChatId = chatId;
56
+ /**
57
+ * Run a function with chatId context using AsyncLocalStorage.
58
+ * All code inside the callback can access the chatId via getCurrentChatId().
59
+ */
60
+ export function runWithChatId(chatId, fn) {
61
+ return chatIdStorage.run(chatId, fn);
57
62
  }
63
+ /**
64
+ * Get the current chatId from AsyncLocalStorage context.
65
+ * Returns null if called outside of runWithChatId().
66
+ */
58
67
  export function getCurrentChatId() {
59
- return currentChatId;
68
+ return chatIdStorage.getStore() ?? null;
60
69
  }
61
70
  // 세션 정리 (수동 호출용)
62
71
  export function cleanupExpiredSessions() {
@@ -1,4 +1,5 @@
1
1
  import { Bot } from "grammy";
2
+ import { limit } from "@grammyjs/ratelimiter";
2
3
  import { setBotInstance, restoreReminders } from "../reminders/index.js";
3
4
  import { setBriefingBot, restoreBriefings } from "../briefing/index.js";
4
5
  import { setHeartbeatBot, restoreHeartbeats } from "../heartbeat/index.js";
@@ -26,6 +27,14 @@ export function createBot(token) {
26
27
  // Cron 시스템 초기화
27
28
  setCronBot(bot);
28
29
  restoreCronJobs().catch((err) => console.error("Failed to restore cron jobs:", err));
30
+ // Rate limiting - 1분에 10개 메시지
31
+ bot.use(limit({
32
+ timeFrame: 60000, // 1분
33
+ limit: 10,
34
+ onLimitExceeded: async (ctx) => {
35
+ await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 잠시 후 다시 시도해주세요.");
36
+ },
37
+ }));
29
38
  // 에러 핸들링
30
39
  bot.catch((err) => {
31
40
  console.error("Bot error:", err);