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.
- package/README.md +214 -60
- package/dist/agents/manager.js +60 -4
- package/dist/cron/store.js +350 -86
- package/dist/session/state.js +14 -5
- package/dist/telegram/bot.js +9 -0
- package/dist/telegram/handlers/commands.js +89 -30
- package/dist/telegram/handlers/messages.js +92 -91
- package/dist/tools/index.js +75 -4
- package/dist/tools/pathCheck.js +79 -0
- package/package.json +6 -2
- package/templates/AGENTS.md +100 -23
- package/templates/BOOTSTRAP.md +16 -35
- package/templates/HEARTBEAT.md +25 -10
- package/templates/IDENTITY.md +7 -10
- package/templates/MEMORY.md +19 -2
- package/templates/SOUL.md +31 -9
- package/templates/TOOLS.md +18 -15
- package/templates/USER.md +7 -11
- package/dist/cron/storage.js +0 -59
package/dist/cron/store.js
CHANGED
|
@@ -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
|
-
*
|
|
29
|
+
* Simple file-based lock for race condition prevention
|
|
22
30
|
*/
|
|
23
|
-
|
|
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
|
-
|
|
112
|
+
async function saveJobsInternal(jobs) {
|
|
41
113
|
const store = {
|
|
42
114
|
version: STORE_VERSION,
|
|
43
115
|
jobs,
|
|
44
116
|
};
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
job.
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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 [
|
|
375
|
+
const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts;
|
|
178
376
|
const now = new Date();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
package/dist/session/state.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
68
|
+
return chatIdStorage.getStore() ?? null;
|
|
60
69
|
}
|
|
61
70
|
// 세션 정리 (수동 호출용)
|
|
62
71
|
export function cleanupExpiredSessions() {
|
package/dist/telegram/bot.js
CHANGED
|
@@ -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);
|