obol-ai 0.3.39 → 0.3.41
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/claude/prompt.js +29 -11
- package/src/claude/router.js +8 -61
- package/src/claude/tools/scheduler.js +1 -1
- package/src/db/migrate.js +11 -0
- package/src/runtime/heartbeat.js +73 -36
- package/src/runtime/scheduler.js +8 -3
- package/src/telegram/handlers/media.js +7 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 0.3.41
|
|
2
|
+
- changelog
|
|
3
|
+
- fix scheduler silent failures, remove haiku routing, improve memory format
|
|
4
|
+
|
|
5
|
+
## 0.3.40
|
|
6
|
+
- changelog
|
|
7
|
+
- skip rate limiter for media group messages
|
|
8
|
+
|
|
1
9
|
## 0.3.39
|
|
2
10
|
- changelog
|
|
3
11
|
- sequential media downloads and image vision in read_file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obol-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.41",
|
|
4
4
|
"description": "Self-evolving AI assistant that learns, remembers, and acts on its own. Persistent vector memory, self-rewriting personality, proactive heartbeats.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/claude/prompt.js
CHANGED
|
@@ -239,20 +239,38 @@ function withRuntimeContext(msgs, runtimePrefix) {
|
|
|
239
239
|
return sanitizeMessages(copy);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
function formatMemoryBlock(topFacts
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
function formatMemoryBlock(topFacts) {
|
|
243
|
+
if (!topFacts.length) return null;
|
|
244
|
+
|
|
245
|
+
const parts = [];
|
|
246
|
+
|
|
247
|
+
parts.push(`## Memory recall
|
|
248
|
+
Retrieved from your persistent memory store. These facts were selected by a combination of recency (last 7 days) and semantic similarity to this conversation, then ranked by relevance, importance, and recency. Use them as context — they represent what you know about this person from past interactions.`);
|
|
249
|
+
|
|
250
|
+
const groups = {};
|
|
251
|
+
for (const m of topFacts) {
|
|
252
|
+
const cat = m.category || 'general';
|
|
253
|
+
if (!groups[cat]) groups[cat] = [];
|
|
254
|
+
groups[cat].push(m);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const order = ['person', 'preference', 'fact', 'goal', 'project', 'event', 'opinion', 'emotion', 'general'];
|
|
258
|
+
const sortedCats = Object.keys(groups).sort((a, b) => {
|
|
259
|
+
const ai = order.indexOf(a), bi = order.indexOf(b);
|
|
260
|
+
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
for (const cat of sortedCats) {
|
|
264
|
+
parts.push(`\n### ${cat}`);
|
|
265
|
+
const lines = groups[cat].map(m => {
|
|
246
266
|
const date = m.created_at ? new Date(m.created_at).toISOString().slice(0, 10) : '';
|
|
247
|
-
|
|
267
|
+
const src = m.source ? ` [via ${m.source}]` : '';
|
|
268
|
+
return `- ${m.content}${date ? ` (${date})` : ''}${src}`;
|
|
248
269
|
});
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
if (selfFacts.length > 0) {
|
|
252
|
-
const selfLines = selfFacts.slice(0, 8).map(m => `- [${m.category}] ${m.content}`);
|
|
253
|
-
block = (block || '') + `\n\n## Self-knowledge\n${selfLines.join('\n')}`;
|
|
270
|
+
parts.push(lines.join('\n'));
|
|
254
271
|
}
|
|
255
|
-
|
|
272
|
+
|
|
273
|
+
return parts.join('\n');
|
|
256
274
|
}
|
|
257
275
|
|
|
258
276
|
module.exports = { buildSystemPrompt, buildSystemBlock, buildRuntimePrefix, withRuntimeContext, formatMemoryBlock };
|
package/src/claude/router.js
CHANGED
|
@@ -24,36 +24,6 @@ function jaccardFromSets(setA, setB) {
|
|
|
24
24
|
return inter / (setA.size + setB.size - inter);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const TOOL_PATTERNS = [
|
|
28
|
-
/\b(?:list|read|write|create|edit|delete|find|grep)\b.*\b(?:files?|folders?|director(?:y|ies)|scripts?|codebase)\b/,
|
|
29
|
-
/\b(?:run|execute)\b.*\b(?:tests?|suite|script|command)\b/,
|
|
30
|
-
/\b(?:save|store|remember)\b.*\b(?:note|that|memory|secret|password|key)\b/,
|
|
31
|
-
/\b(?:remind|schedule|set a reminder|cancel.*(?:event|reminder))\b/,
|
|
32
|
-
/\b(?:search the web|look up online|google)\b/,
|
|
33
|
-
/\b(?:deploy|build|install)\b.*\b(?:app|site|dashboard|project|package)\b/,
|
|
34
|
-
/\b(?:create|generate|make)\b.*\b(?:pdf|chart|diagram|flowchart|image)\b/,
|
|
35
|
-
/\b(?:what|which)\b.*\b(?:api keys?|secrets?|credentials?|reminders?|events?)\b.*\b(?:stored|have|set|coming)\b/,
|
|
36
|
-
/\b(?:what have you been|what are you)\b.*\b(?:research|learn|curious|explor)\b/,
|
|
37
|
-
/\b(?:email|e-mail|inbox|gmail|outlook|mail)\b/,
|
|
38
|
-
/\b(?:meeting|flight|appointment|deadline|booking|reservation|calendar|event|conference|itinerary)\b/,
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
function likelyNeedsTools(message) {
|
|
42
|
-
const lower = message.toLowerCase();
|
|
43
|
-
return TOOL_PATTERNS.some(p => p.test(lower));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function recentlyUsedTools(history) {
|
|
47
|
-
for (let i = history.length - 1; i >= Math.max(0, history.length - 4); i--) {
|
|
48
|
-
const msg = history[i];
|
|
49
|
-
if (msg.role === 'assistant' && Array.isArray(msg.content) &&
|
|
50
|
-
msg.content.some(b => b.type === 'tool_use')) {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
27
|
async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision, onRouteUpdate, recentHistory = [], selfMemory = null }) {
|
|
58
28
|
let memoryBlock = null;
|
|
59
29
|
let model = null;
|
|
@@ -68,13 +38,13 @@ async function routeMessage(client, memory, userMessage, { vlog, onRouteDecision
|
|
|
68
38
|
2. What model complexity does it need?
|
|
69
39
|
|
|
70
40
|
Reply with ONLY a JSON object:
|
|
71
|
-
{"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "
|
|
41
|
+
{"need_memory": true/false, "search_queries": ["query1", "query2"], "model": "sonnet|opus"}
|
|
72
42
|
|
|
73
43
|
search_queries: 1-5 optimized search queries based on the full conversation context. Cover distinct topics, people, entities, time periods, or projects referenced. Single-topic messages need just one query. Use more queries when the message references multiple people, projects, or threads.
|
|
74
44
|
|
|
75
45
|
Memory: casual messages (greetings, jokes, simple questions) → false. References to past, people, projects, preferences → true.
|
|
76
46
|
|
|
77
|
-
Model: Default to "sonnet". Use "
|
|
47
|
+
Model: Default to "sonnet". Use "sonnet" for: general conversation, code generation, data analysis, content creation, explanations, creative writing, agentic tool use, questions, opinions, advice, memory-dependent questions, and most exchanges. Use "opus" for: professional software engineering tasks, advanced multi-step agent work, complex reasoning, scientific or mathematical problems, tasks requiring nuanced understanding, advanced coding challenges, in-depth research, and architecture or design decisions.
|
|
78
48
|
|
|
79
49
|
If recent context shows an ongoing task (sonnet/opus was just used, multi-step work in progress), bias toward that model even for short follow-up messages.`,
|
|
80
50
|
messages: buildRouterMessages(recentHistory, userMessage),
|
|
@@ -91,33 +61,25 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
|
|
|
91
61
|
? decision.search_queries.slice(0, 3)
|
|
92
62
|
: decision.search_query ? [decision.search_query] : [];
|
|
93
63
|
|
|
94
|
-
if (decision.model
|
|
95
|
-
vlog('[router] haiku overridden → sonnet (tool-need heuristic)');
|
|
64
|
+
if (decision.model !== 'sonnet' && decision.model !== 'opus') {
|
|
96
65
|
decision.model = 'sonnet';
|
|
97
66
|
}
|
|
98
67
|
|
|
99
|
-
|
|
100
|
-
vlog('[router] haiku overridden → sonnet (recent tool use in history)');
|
|
101
|
-
decision.model = 'sonnet';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
vlog(`[router] model=${decision.model || 'sonnet'} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
|
|
68
|
+
vlog(`[router] model=${decision.model} memory=${decision.need_memory || false}${queries.length ? ` queries=${JSON.stringify(queries)}` : ''}`);
|
|
105
69
|
|
|
106
70
|
onRouteDecision?.({
|
|
107
|
-
model: decision.model
|
|
71
|
+
model: decision.model,
|
|
108
72
|
needMemory: decision.need_memory || false,
|
|
109
73
|
memoryCount: 0,
|
|
110
74
|
});
|
|
111
75
|
|
|
112
76
|
if (decision.model === 'opus') {
|
|
113
77
|
model = 'claude-opus-4-6';
|
|
114
|
-
} else if (decision.model === 'haiku') {
|
|
115
|
-
model = 'claude-haiku-4-5';
|
|
116
78
|
}
|
|
117
79
|
|
|
118
80
|
if (decision.need_memory && memory) {
|
|
119
|
-
const budget = decision.model === 'opus' ?
|
|
120
|
-
const poolPerQuery = decision.model === 'opus' ?
|
|
81
|
+
const budget = decision.model === 'opus' ? 60 : 40;
|
|
82
|
+
const poolPerQuery = decision.model === 'opus' ? 25 : 20;
|
|
121
83
|
const searchQueries = queries.length > 0 ? queries : [userMessage];
|
|
122
84
|
|
|
123
85
|
const recentMemories = await memory.byDate('7d', { limit: Math.ceil(budget / 3) });
|
|
@@ -153,22 +115,7 @@ If recent context shows an ongoing task (sonnet/opus was just used, multi-step w
|
|
|
153
115
|
vlog(`[memory] ${topFacts.length} facts (${recentMemories.length} recent, ${semanticMemories.length} semantic, budget=${budget})`);
|
|
154
116
|
onRouteUpdate?.({ memoryCount: topFacts.length });
|
|
155
117
|
|
|
156
|
-
|
|
157
|
-
if (selfMemory) {
|
|
158
|
-
const selfResults = await Promise.all(
|
|
159
|
-
searchQueries.map(q => selfMemory.search(q, { limit: 5, threshold: 0.4 }))
|
|
160
|
-
);
|
|
161
|
-
const seen2 = new Set();
|
|
162
|
-
for (const m of selfResults.flat()) {
|
|
163
|
-
if (!seen2.has(m.id)) { seen2.add(m.id); selfFacts.push(m); }
|
|
164
|
-
}
|
|
165
|
-
if (selfFacts.length > 0) {
|
|
166
|
-
vlog(`[memory] +${selfFacts.length} self-memory facts`);
|
|
167
|
-
onRouteUpdate?.({ selfMemoryCount: selfFacts.length });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
memoryBlock = formatMemoryBlock(topFacts, selfFacts);
|
|
118
|
+
memoryBlock = formatMemoryBlock(topFacts);
|
|
172
119
|
}
|
|
173
120
|
} catch (e) {
|
|
174
121
|
console.error('[router] Memory/routing decision failed:', e.message);
|
|
@@ -33,7 +33,7 @@ Always search memory first for the user's timezone/location.`,
|
|
|
33
33
|
input_schema: {
|
|
34
34
|
type: 'object',
|
|
35
35
|
properties: {
|
|
36
|
-
status: { type: 'string', enum: ['pending', 'sent', 'cancelled', 'completed'], description: 'Filter by status (default: pending)' },
|
|
36
|
+
status: { type: 'string', enum: ['pending', 'sent', 'cancelled', 'completed', 'failed'], description: 'Filter by status (default: pending)' },
|
|
37
37
|
filters: { type: 'object', description: 'PostgREST column filters. Key = column name, value = operator.value. E.g. {"title":"like.*briefing*","cron_expr":"not.is.null","run_count":"gte.5"}', additionalProperties: { type: 'string' } },
|
|
38
38
|
order: { type: 'string', description: 'Sort order: column.direction. E.g. "run_count.desc", "created_at.asc"' },
|
|
39
39
|
},
|
package/src/db/migrate.js
CHANGED
|
@@ -287,6 +287,17 @@ async function migrate(supabaseConfig) {
|
|
|
287
287
|
WHERE id = ANY(memory_ids);
|
|
288
288
|
$$;`,
|
|
289
289
|
|
|
290
|
+
// Event retry tracking
|
|
291
|
+
`ALTER TABLE obol_events ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0;`,
|
|
292
|
+
`ALTER TABLE obol_events ADD COLUMN IF NOT EXISTS last_error TEXT;`,
|
|
293
|
+
|
|
294
|
+
`DO $$ BEGIN
|
|
295
|
+
ALTER TABLE obol_events DROP CONSTRAINT IF EXISTS obol_events_status_check;
|
|
296
|
+
ALTER TABLE obol_events ADD CONSTRAINT obol_events_status_check
|
|
297
|
+
CHECK (status IN ('pending','sent','cancelled','completed','failed'));
|
|
298
|
+
EXCEPTION WHEN undefined_object THEN NULL;
|
|
299
|
+
END $$;`,
|
|
300
|
+
|
|
290
301
|
// Soul backup table (one row per file key: 'soul', 'agents')
|
|
291
302
|
`CREATE TABLE IF NOT EXISTS obol_soul (
|
|
292
303
|
id TEXT PRIMARY KEY,
|
package/src/runtime/heartbeat.js
CHANGED
|
@@ -18,7 +18,7 @@ const NEWS_HOURS = new Set([8, 18]);
|
|
|
18
18
|
const _evolutionRunning = new Set();
|
|
19
19
|
const _newsRunning = new Set();
|
|
20
20
|
let _curiosityRunning = false;
|
|
21
|
-
|
|
21
|
+
const _inflight = new Set();
|
|
22
22
|
const _analysisRunning = new Set();
|
|
23
23
|
|
|
24
24
|
function getLocalHour(timezone) {
|
|
@@ -210,7 +210,57 @@ async function runNewsForUser(bot, config, userId) {
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
const
|
|
213
|
+
const MAX_ATTEMPTS = 3;
|
|
214
|
+
const STALE_MS = 2 * 60 * 60 * 1000;
|
|
215
|
+
const EVENT_TIMEOUT_MS = 30_000;
|
|
216
|
+
|
|
217
|
+
async function processEvent(bot, config, scheduler, event) {
|
|
218
|
+
const tz = event.timezone || 'UTC';
|
|
219
|
+
const age = Date.now() - new Date(event.due_at).getTime();
|
|
220
|
+
|
|
221
|
+
if (age > STALE_MS) {
|
|
222
|
+
console.warn(`[scheduler] Stale event ${event.id} "${event.title}" (due ${event.due_at})`);
|
|
223
|
+
if (event.cron_expr) {
|
|
224
|
+
await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
|
|
225
|
+
} else {
|
|
226
|
+
await scheduler.markFailed(event.id, 'Stale — exceeded 2h window');
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const attempts = (event.attempts || 0) + 1;
|
|
232
|
+
await scheduler.patch(event.id, { attempts });
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (event.instructions) {
|
|
236
|
+
await runAgenticEvent(bot, config, event);
|
|
237
|
+
} else {
|
|
238
|
+
await sendReminderMessage(bot, event);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (event.cron_expr) {
|
|
242
|
+
await scheduler.patch(event.id, { last_error: null });
|
|
243
|
+
await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
|
|
244
|
+
} else {
|
|
245
|
+
await scheduler.markSent(event.id);
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
const errMsg = (e.message || '').substring(0, 500);
|
|
249
|
+
console.error(`[scheduler] Event ${event.id} attempt ${attempts} failed:`, errMsg);
|
|
250
|
+
await scheduler.patch(event.id, { last_error: errMsg });
|
|
251
|
+
|
|
252
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
253
|
+
if (event.cron_expr) {
|
|
254
|
+
await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
|
|
255
|
+
} else {
|
|
256
|
+
await scheduler.markFailed(event.id, errMsg);
|
|
257
|
+
}
|
|
258
|
+
await bot.api.sendMessage(event.chat_id,
|
|
259
|
+
`⚠️ Scheduled event "${event.title}" failed: ${errMsg.substring(0, 200)}`
|
|
260
|
+
).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
214
264
|
|
|
215
265
|
function setupHeartbeat(bot, config) {
|
|
216
266
|
const supabaseConfig = config?.supabase;
|
|
@@ -228,43 +278,22 @@ function setupHeartbeat(bot, config) {
|
|
|
228
278
|
}
|
|
229
279
|
|
|
230
280
|
if (!scheduler || !bot) return;
|
|
231
|
-
if (_schedulerBusy) return;
|
|
232
|
-
_schedulerBusy = true;
|
|
233
281
|
|
|
234
282
|
try {
|
|
235
|
-
const dueEvents = await scheduler.getDue();
|
|
283
|
+
const dueEvents = await scheduler.getDue(MAX_ATTEMPTS);
|
|
236
284
|
if (dueEvents.length > 0) {
|
|
237
285
|
console.log(`[scheduler] Processing ${dueEvents.length} due event(s)`);
|
|
238
286
|
}
|
|
239
287
|
for (const event of dueEvents) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (event.instructions) {
|
|
249
|
-
await runAgenticEvent(bot, config, event);
|
|
250
|
-
} else {
|
|
251
|
-
await sendReminderMessage(bot, event);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const tz = event.timezone || 'UTC';
|
|
255
|
-
if (event.cron_expr) {
|
|
256
|
-
await scheduler.reschedule(event.id, event.cron_expr, tz, event.run_count, event.max_runs, event.ends_at);
|
|
257
|
-
} else {
|
|
258
|
-
await scheduler.markSent(event.id);
|
|
259
|
-
}
|
|
260
|
-
} catch (e) {
|
|
261
|
-
console.error(`[scheduler] Failed to process event ${event.id}:`, e.message);
|
|
262
|
-
}
|
|
288
|
+
if (_inflight.has(event.id)) continue;
|
|
289
|
+
|
|
290
|
+
_inflight.add(event.id);
|
|
291
|
+
processEvent(bot, config, scheduler, event)
|
|
292
|
+
.catch(e => console.error(`[scheduler] Unhandled error for event ${event.id}:`, e.message))
|
|
293
|
+
.finally(() => _inflight.delete(event.id));
|
|
263
294
|
}
|
|
264
295
|
} catch (e) {
|
|
265
296
|
console.error('[scheduler] Failed to check due events:', e.message);
|
|
266
|
-
} finally {
|
|
267
|
-
_schedulerBusy = false;
|
|
268
297
|
}
|
|
269
298
|
});
|
|
270
299
|
|
|
@@ -381,12 +410,20 @@ async function runAgenticEvent(bot, config, event) {
|
|
|
381
410
|
'Do NOT output JSON, code blocks, tool calls, or structured data. Write as a direct Telegram message.'
|
|
382
411
|
);
|
|
383
412
|
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
413
|
+
const controller = new AbortController();
|
|
414
|
+
const timeout = setTimeout(() => controller.abort(), EVENT_TIMEOUT_MS);
|
|
415
|
+
|
|
416
|
+
let response;
|
|
417
|
+
try {
|
|
418
|
+
response = await tenant.claude.client.messages.create({
|
|
419
|
+
model: 'claude-sonnet-4-6',
|
|
420
|
+
max_tokens: 300,
|
|
421
|
+
system: systemParts.join('\n\n'),
|
|
422
|
+
messages: [{ role: 'user', content: event.instructions }],
|
|
423
|
+
}, { signal: controller.signal });
|
|
424
|
+
} finally {
|
|
425
|
+
clearTimeout(timeout);
|
|
426
|
+
}
|
|
390
427
|
|
|
391
428
|
const text = response.content.filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
|
|
392
429
|
if (!text) throw new Error('Empty response from model');
|
package/src/runtime/scheduler.js
CHANGED
|
@@ -62,9 +62,9 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
62
62
|
return data[0];
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
async function getDue() {
|
|
65
|
+
async function getDue(maxAttempts = 3) {
|
|
66
66
|
const now = new Date().toISOString();
|
|
67
|
-
const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}`;
|
|
67
|
+
const fetchUrl = `${url}/rest/v1/obol_events?status=eq.pending&due_at=lte.${now}&attempts=lt.${maxAttempts}`;
|
|
68
68
|
const res = await fetch(fetchUrl, { headers, signal: AbortSignal.timeout(15000) });
|
|
69
69
|
const data = await res.json();
|
|
70
70
|
if (!res.ok) throw new Error(JSON.stringify(data));
|
|
@@ -88,6 +88,10 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
88
88
|
return patch(eventId, { status: 'sent' });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
async function markFailed(eventId, error) {
|
|
92
|
+
return patch(eventId, { status: 'failed', last_error: (error || '').substring(0, 500) });
|
|
93
|
+
}
|
|
94
|
+
|
|
91
95
|
async function reschedule(eventId, cronExpr, timezone, runCount, maxRuns, endsAt) {
|
|
92
96
|
const newRunCount = (runCount || 0) + 1;
|
|
93
97
|
|
|
@@ -107,6 +111,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
107
111
|
run_count: newRunCount,
|
|
108
112
|
last_run_at: new Date().toISOString(),
|
|
109
113
|
status: 'pending',
|
|
114
|
+
attempts: 0,
|
|
110
115
|
});
|
|
111
116
|
} catch (e) {
|
|
112
117
|
console.error(`[scheduler] Failed to compute next cron occurrence for event ${eventId}:`, e.message);
|
|
@@ -126,7 +131,7 @@ function createScheduler(supabaseConfig, userId = 0) {
|
|
|
126
131
|
return data[0];
|
|
127
132
|
}
|
|
128
133
|
|
|
129
|
-
return { add, list, cancel, getDue, markSent, reschedule, update };
|
|
134
|
+
return { add, list, cancel, getDue, markSent, markFailed, patch, reschedule, update };
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
module.exports = { createScheduler };
|
|
@@ -166,13 +166,16 @@ function registerMediaHandler(bot, telegramConfig, deps) {
|
|
|
166
166
|
async function handleMedia(ctx) {
|
|
167
167
|
if (!ctx.from) return;
|
|
168
168
|
const userId = ctx.from.id;
|
|
169
|
-
const { createRateLimiter } = require('../rate-limit');
|
|
170
|
-
if (!bot._rateLimiter) bot._rateLimiter = createRateLimiter();
|
|
171
|
-
const rateResult = bot._rateLimiter.check(userId);
|
|
172
|
-
if (rateResult) return;
|
|
173
169
|
const fileInfo = media.getFileInfo(ctx);
|
|
174
170
|
if (!fileInfo) return;
|
|
175
171
|
|
|
172
|
+
if (!ctx.message.media_group_id) {
|
|
173
|
+
const { createRateLimiter } = require('../rate-limit');
|
|
174
|
+
if (!bot._rateLimiter) bot._rateLimiter = createRateLimiter();
|
|
175
|
+
const rateResult = bot._rateLimiter.check(userId);
|
|
176
|
+
if (rateResult) return;
|
|
177
|
+
}
|
|
178
|
+
|
|
176
179
|
if (fileInfo.fileSize > MAX_MEDIA_SIZE) {
|
|
177
180
|
await ctx.reply(`File too large (${(fileInfo.fileSize / 1024 / 1024).toFixed(1)}MB). Max is 50MB.`).catch(() => {});
|
|
178
181
|
return;
|