neoagent 2.3.0 → 2.3.1-beta.2

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.
@@ -0,0 +1,559 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const db = require('../../../db/database');
5
+ const { fetchJson } = require('../oauth_provider');
6
+
7
+ const WEATHER_APP = {
8
+ id: 'forecast',
9
+ label: 'Forecast',
10
+ description: 'Public weather data from Open-Meteo (no API key required).',
11
+ };
12
+
13
+ const WEATHER_TOOL_DEFINITIONS = [
14
+ {
15
+ appId: WEATHER_APP.id,
16
+ name: 'weather_search_locations',
17
+ access: 'read',
18
+ description: 'Search Open-Meteo locations by city or place name.',
19
+ parameters: {
20
+ type: 'object',
21
+ properties: {
22
+ query: { type: 'string', description: 'Location text like "Berlin" or "San Francisco".' },
23
+ limit: { type: 'number', description: 'Maximum locations to return, default 5.' },
24
+ },
25
+ required: ['query'],
26
+ },
27
+ },
28
+ {
29
+ appId: WEATHER_APP.id,
30
+ name: 'weather_get_current',
31
+ access: 'read',
32
+ description: 'Get current weather conditions for a location or coordinates.',
33
+ parameters: {
34
+ type: 'object',
35
+ properties: {
36
+ location: { type: 'string', description: 'Optional location name like "Tokyo".' },
37
+ latitude: { type: 'number', description: 'Latitude, required with longitude if location is omitted.' },
38
+ longitude: { type: 'number', description: 'Longitude, required with latitude if location is omitted.' },
39
+ },
40
+ },
41
+ },
42
+ {
43
+ appId: WEATHER_APP.id,
44
+ name: 'weather_get_forecast',
45
+ access: 'read',
46
+ description: 'Get hourly weather forecast for a location or coordinates.',
47
+ parameters: {
48
+ type: 'object',
49
+ properties: {
50
+ location: { type: 'string', description: 'Optional location name like "London".' },
51
+ latitude: { type: 'number', description: 'Latitude, required with longitude if location is omitted.' },
52
+ longitude: { type: 'number', description: 'Longitude, required with latitude if location is omitted.' },
53
+ forecast_hours: { type: 'number', description: 'Forecast horizon in hours (1-72), default 24.' },
54
+ },
55
+ },
56
+ },
57
+ ];
58
+
59
+ const WEATHER_CODE_LABELS = {
60
+ 0: 'Clear sky',
61
+ 1: 'Mainly clear',
62
+ 2: 'Partly cloudy',
63
+ 3: 'Overcast',
64
+ 45: 'Fog',
65
+ 48: 'Depositing rime fog',
66
+ 51: 'Light drizzle',
67
+ 53: 'Moderate drizzle',
68
+ 55: 'Dense drizzle',
69
+ 56: 'Light freezing drizzle',
70
+ 57: 'Dense freezing drizzle',
71
+ 61: 'Slight rain',
72
+ 63: 'Moderate rain',
73
+ 65: 'Heavy rain',
74
+ 66: 'Light freezing rain',
75
+ 67: 'Heavy freezing rain',
76
+ 71: 'Slight snowfall',
77
+ 73: 'Moderate snowfall',
78
+ 75: 'Heavy snowfall',
79
+ 77: 'Snow grains',
80
+ 80: 'Slight rain showers',
81
+ 81: 'Moderate rain showers',
82
+ 82: 'Violent rain showers',
83
+ 85: 'Slight snow showers',
84
+ 86: 'Heavy snow showers',
85
+ 95: 'Thunderstorm',
86
+ 96: 'Thunderstorm with slight hail',
87
+ 99: 'Thunderstorm with heavy hail',
88
+ };
89
+
90
+ function normalizeConnectionAccount(row, envStatus) {
91
+ if (!row) {
92
+ return {
93
+ id: null,
94
+ status: 'not_connected',
95
+ connected: false,
96
+ accountEmail: null,
97
+ lastConnectedAt: null,
98
+ accessMode: 'read_write',
99
+ };
100
+ }
101
+
102
+ return {
103
+ id: row.id || null,
104
+ status: row.status || 'not_connected',
105
+ connected: row.status === 'connected',
106
+ accountEmail: row.account_email || null,
107
+ lastConnectedAt: row.last_connected_at || null,
108
+ accessMode: 'read_write',
109
+ };
110
+ }
111
+
112
+ function sanitizeToolDefinition(definition) {
113
+ return {
114
+ ...definition,
115
+ description:
116
+ `${definition.description} When multiple Weather accounts are connected, set connection_id or account_email to choose which one to use.`,
117
+ parameters: {
118
+ ...(definition.parameters || { type: 'object', properties: {} }),
119
+ type: 'object',
120
+ properties: {
121
+ ...((definition.parameters && definition.parameters.properties) || {}),
122
+ connection_id: {
123
+ type: 'number',
124
+ description: 'Optional connected Weather account ID.',
125
+ },
126
+ account_email: {
127
+ type: 'string',
128
+ description: 'Optional connected Weather account identifier.',
129
+ },
130
+ },
131
+ required: Array.isArray(definition.parameters?.required)
132
+ ? definition.parameters.required.slice()
133
+ : [],
134
+ },
135
+ };
136
+ }
137
+
138
+ function toNumber(value, fallback = null) {
139
+ const num = Number(value);
140
+ return Number.isFinite(num) ? num : fallback;
141
+ }
142
+
143
+ function cleanLocationResult(item = {}) {
144
+ return {
145
+ name: item.name || null,
146
+ latitude: toNumber(item.latitude),
147
+ longitude: toNumber(item.longitude),
148
+ country: item.country || null,
149
+ admin1: item.admin1 || null,
150
+ timezone: item.timezone || null,
151
+ population: toNumber(item.population),
152
+ };
153
+ }
154
+
155
+ async function geocodeLocation(locationQuery, limit = 1) {
156
+ const query = String(locationQuery || '').trim();
157
+ if (!query) {
158
+ throw new Error('location is required when latitude/longitude are not provided.');
159
+ }
160
+ const result = await fetchJson(
161
+ `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=${Math.max(1, Math.min(Number(limit) || 1, 10))}&language=en&format=json`,
162
+ { method: 'GET' },
163
+ { serviceName: 'Open-Meteo geocoding' },
164
+ );
165
+ const matches = Array.isArray(result?.results) ? result.results : [];
166
+ if (matches.length === 0) {
167
+ throw new Error(`No location match found for "${query}".`);
168
+ }
169
+ return matches.map(cleanLocationResult);
170
+ }
171
+
172
+ async function resolveLocation(args = {}, connection = null) {
173
+ const latitude = toNumber(args.latitude, null);
174
+ const longitude = toNumber(args.longitude, null);
175
+ if (latitude !== null && longitude !== null) {
176
+ return {
177
+ latitude,
178
+ longitude,
179
+ label: `${latitude.toFixed(4)}, ${longitude.toFixed(4)}`,
180
+ timezone: null,
181
+ };
182
+ }
183
+
184
+ const location = String(args.location || '').trim();
185
+ if (location) {
186
+ const [best] = await geocodeLocation(location, 1);
187
+ return {
188
+ latitude: best.latitude,
189
+ longitude: best.longitude,
190
+ label: [best.name, best.admin1, best.country].filter(Boolean).join(', ') || location,
191
+ timezone: best.timezone || null,
192
+ };
193
+ }
194
+
195
+ if (connection) {
196
+ try {
197
+ const metadata = JSON.parse(String(connection.metadata_json || '{}')) || {};
198
+ const metaLatitude = toNumber(metadata.latitude, null);
199
+ const metaLongitude = toNumber(metadata.longitude, null);
200
+ if (metaLatitude !== null && metaLongitude !== null) {
201
+ return {
202
+ latitude: metaLatitude,
203
+ longitude: metaLongitude,
204
+ label: String(metadata.locationLabel || `${metaLatitude.toFixed(4)}, ${metaLongitude.toFixed(4)}`),
205
+ timezone: String(metadata.timezone || '') || null,
206
+ };
207
+ }
208
+ } catch {
209
+ // Ignore malformed metadata and fail with a proper validation message below.
210
+ }
211
+ }
212
+
213
+ throw new Error('Provide either location or latitude/longitude for weather tools.');
214
+ }
215
+
216
+ function enrichCurrent(current = {}) {
217
+ const weatherCode = Number(current.weather_code);
218
+ return {
219
+ ...current,
220
+ weather_label: Number.isFinite(weatherCode) ? WEATHER_CODE_LABELS[weatherCode] || 'Unknown' : 'Unknown',
221
+ };
222
+ }
223
+
224
+ async function fetchForecastForLocation(location, forecastHours = 24) {
225
+ const horizon = Math.max(1, Math.min(Number(forecastHours) || 24, 72));
226
+ const url = new URL('https://api.open-meteo.com/v1/forecast');
227
+ url.searchParams.set('latitude', String(location.latitude));
228
+ url.searchParams.set('longitude', String(location.longitude));
229
+ url.searchParams.set(
230
+ 'current',
231
+ [
232
+ 'temperature_2m',
233
+ 'apparent_temperature',
234
+ 'precipitation',
235
+ 'rain',
236
+ 'showers',
237
+ 'snowfall',
238
+ 'weather_code',
239
+ 'wind_speed_10m',
240
+ 'wind_gusts_10m',
241
+ 'wind_direction_10m',
242
+ 'is_day',
243
+ ].join(','),
244
+ );
245
+ url.searchParams.set(
246
+ 'hourly',
247
+ [
248
+ 'temperature_2m',
249
+ 'precipitation',
250
+ 'rain',
251
+ 'showers',
252
+ 'snowfall',
253
+ 'weather_code',
254
+ 'wind_speed_10m',
255
+ 'wind_gusts_10m',
256
+ 'is_day',
257
+ ].join(','),
258
+ );
259
+ url.searchParams.set('forecast_hours', String(horizon));
260
+ url.searchParams.set('timezone', location.timezone || 'auto');
261
+
262
+ const result = await fetchJson(url.toString(), { method: 'GET' }, { serviceName: 'Open-Meteo forecast' });
263
+ const hourly = result?.hourly || {};
264
+ const times = Array.isArray(hourly.time) ? hourly.time : [];
265
+ const rows = times.map((time, index) => {
266
+ const weatherCode = Number(hourly.weather_code?.[index]);
267
+ return {
268
+ time,
269
+ temperature: toNumber(hourly.temperature_2m?.[index]),
270
+ precipitation: toNumber(hourly.precipitation?.[index], 0),
271
+ rain: toNumber(hourly.rain?.[index], 0),
272
+ showers: toNumber(hourly.showers?.[index], 0),
273
+ snowfall: toNumber(hourly.snowfall?.[index], 0),
274
+ windSpeed: toNumber(hourly.wind_speed_10m?.[index], 0),
275
+ windGust: toNumber(hourly.wind_gusts_10m?.[index], 0),
276
+ weatherCode: Number.isFinite(weatherCode) ? weatherCode : null,
277
+ weatherLabel: Number.isFinite(weatherCode) ? WEATHER_CODE_LABELS[weatherCode] || 'Unknown' : 'Unknown',
278
+ isDay: Number(hourly.is_day?.[index]) === 1,
279
+ };
280
+ });
281
+
282
+ return {
283
+ location: {
284
+ label: location.label,
285
+ latitude: toNumber(result?.latitude, location.latitude),
286
+ longitude: toNumber(result?.longitude, location.longitude),
287
+ timezone: result?.timezone || location.timezone || null,
288
+ elevation: toNumber(result?.elevation),
289
+ },
290
+ current: enrichCurrent(result?.current || {}),
291
+ hourly: rows,
292
+ };
293
+ }
294
+
295
+ class WeatherProvider {
296
+ constructor() {
297
+ this.key = 'weather';
298
+ this.label = 'Weather';
299
+ this.description = 'Official weather integration powered by Open-Meteo public APIs (no API key required).';
300
+ this.icon = 'weather';
301
+ this.apps = [{ ...WEATHER_APP }];
302
+ this.connectPrompt =
303
+ 'Connect Weather to use keyless forecast and current-condition tools, then trigger tasks on weather events.';
304
+ this.sessions = new Map();
305
+ this.sessionTTL = 30 * 60 * 1000;
306
+ this.#pruneTimer = setInterval(() => this.pruneExpiredSessions(), this.sessionTTL);
307
+ }
308
+
309
+ #pruneTimer = null;
310
+
311
+ pruneExpiredSessions() {
312
+ const now = Date.now();
313
+ for (const [id, session] of this.sessions) {
314
+ if (now - session.createdAt > this.sessionTTL) {
315
+ this.sessions.delete(id);
316
+ }
317
+ }
318
+ }
319
+
320
+ getApp(appId) {
321
+ return String(appId || '').trim() === WEATHER_APP.id ? { ...WEATHER_APP } : null;
322
+ }
323
+
324
+ getToolAppId(toolName) {
325
+ const normalized = String(toolName || '').trim();
326
+ return WEATHER_TOOL_DEFINITIONS.some((tool) => tool.name === normalized)
327
+ ? WEATHER_APP.id
328
+ : null;
329
+ }
330
+
331
+ getEnvStatus() {
332
+ return {
333
+ configured: true,
334
+ missing: [],
335
+ summary: 'Weather is ready for account connections.',
336
+ };
337
+ }
338
+
339
+ buildSnapshot(connectionRows) {
340
+ const env = this.getEnvStatus();
341
+ const accounts = (Array.isArray(connectionRows) ? connectionRows : [])
342
+ .slice()
343
+ .sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')))
344
+ .map((row) => normalizeConnectionAccount(row, env));
345
+ const connectedAccounts = accounts.filter((account) => account.connected);
346
+ const latestConnectedAt = connectedAccounts
347
+ .map((account) => account.lastConnectedAt)
348
+ .filter(Boolean)
349
+ .sort()
350
+ .reverse()[0] || null;
351
+
352
+ const appSnapshot = {
353
+ id: WEATHER_APP.id,
354
+ label: WEATHER_APP.label,
355
+ description: WEATHER_APP.description,
356
+ accounts,
357
+ connection: {
358
+ status: connectedAccounts.length > 0 ? 'connected' : 'not_connected',
359
+ connected: connectedAccounts.length > 0,
360
+ accountCount: connectedAccounts.length,
361
+ accountEmail: connectedAccounts.length === 1 ? connectedAccounts[0].accountEmail : null,
362
+ lastConnectedAt: latestConnectedAt,
363
+ },
364
+ availableToolCount: connectedAccounts.length > 0 ? WEATHER_TOOL_DEFINITIONS.length : 0,
365
+ };
366
+
367
+ return {
368
+ id: this.key,
369
+ label: this.label,
370
+ description: this.description,
371
+ icon: this.icon,
372
+ apps: [appSnapshot],
373
+ env,
374
+ connection: {
375
+ status: appSnapshot.connection.status,
376
+ connected: appSnapshot.connection.connected,
377
+ accountCount: appSnapshot.connection.accountCount,
378
+ appCount: appSnapshot.connection.connected ? 1 : 0,
379
+ accountEmail: appSnapshot.connection.accountEmail,
380
+ lastConnectedAt: appSnapshot.connection.lastConnectedAt,
381
+ },
382
+ availableToolCount: appSnapshot.availableToolCount,
383
+ connectPrompt: this.connectPrompt,
384
+ };
385
+ }
386
+
387
+ summarizeForModel(snapshot) {
388
+ if (!snapshot?.connection?.connected) {
389
+ return 'Weather: available but not connected yet. Ask the user to connect Weather in Official Integrations.';
390
+ }
391
+ return 'Weather: connected with keyless Open-Meteo tools for geocoding, current conditions, and hourly forecast.';
392
+ }
393
+
394
+ getToolDefinitions({ connectedAppIds } = {}) {
395
+ const appIds = new Set(Array.isArray(connectedAppIds) ? connectedAppIds : []);
396
+ if (!appIds.has(WEATHER_APP.id)) {
397
+ return [];
398
+ }
399
+ return WEATHER_TOOL_DEFINITIONS.map(sanitizeToolDefinition);
400
+ }
401
+
402
+ supportsTool(toolName) {
403
+ return WEATHER_TOOL_DEFINITIONS.some((tool) => tool.name === String(toolName || '').trim());
404
+ }
405
+
406
+ async beginConnection({ userId, agentId, appKey }) {
407
+ if (!this.getApp(appKey)) {
408
+ throw new Error(`Unknown ${this.label} app: ${appKey || 'missing app key'}`);
409
+ }
410
+
411
+ const accountEmail = 'public@open-meteo';
412
+ db.prepare(
413
+ `INSERT INTO integration_connections (
414
+ user_id,
415
+ agent_id,
416
+ provider_key,
417
+ app_key,
418
+ status,
419
+ account_email,
420
+ scopes_json,
421
+ credentials_json,
422
+ metadata_json,
423
+ last_connected_at,
424
+ updated_at
425
+ ) VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?, datetime('now'), datetime('now'))
426
+ ON CONFLICT(user_id, agent_id, provider_key, app_key, account_email) DO UPDATE SET
427
+ status = 'connected',
428
+ scopes_json = excluded.scopes_json,
429
+ credentials_json = excluded.credentials_json,
430
+ metadata_json = excluded.metadata_json,
431
+ last_connected_at = excluded.last_connected_at,
432
+ updated_at = excluded.updated_at`,
433
+ ).run(
434
+ userId,
435
+ agentId,
436
+ this.key,
437
+ WEATHER_APP.id,
438
+ accountEmail,
439
+ JSON.stringify(['open-meteo:public']),
440
+ JSON.stringify({}),
441
+ JSON.stringify({ source: 'open-meteo', mode: 'public' }),
442
+ );
443
+
444
+ const connection = db.prepare(
445
+ `SELECT id FROM integration_connections
446
+ WHERE user_id = ? AND agent_id = ? AND provider_key = ? AND app_key = ? AND account_email = ?`,
447
+ ).get(userId, agentId, this.key, WEATHER_APP.id, accountEmail);
448
+
449
+ const sessionId = crypto.randomBytes(18).toString('hex');
450
+ this.sessions.set(sessionId, {
451
+ id: sessionId,
452
+ userId,
453
+ agentId,
454
+ appKey: WEATHER_APP.id,
455
+ status: 'connected',
456
+ connectionId: connection?.id || null,
457
+ accountEmail,
458
+ createdAt: Date.now(),
459
+ });
460
+
461
+ return {
462
+ provider: this.key,
463
+ appId: WEATHER_APP.id,
464
+ status: 'interactive_connect',
465
+ sessionId,
466
+ url: `/api/integrations/${this.key}/connect/${sessionId}`,
467
+ };
468
+ }
469
+
470
+ getConnectionSession(userId, providerKey, sessionId, agentId = null) {
471
+ if (providerKey !== this.key) {
472
+ return null;
473
+ }
474
+ const session = this.sessions.get(String(sessionId || '').trim());
475
+ if (!session) {
476
+ return null;
477
+ }
478
+ if (session.userId !== userId || String(session.agentId || '') !== String(agentId || '')) {
479
+ return null;
480
+ }
481
+ return {
482
+ id: session.id,
483
+ provider: this.key,
484
+ appId: session.appKey,
485
+ status: session.status,
486
+ connectionId: session.connectionId,
487
+ accountEmail: session.accountEmail,
488
+ error: null,
489
+ qr: null,
490
+ };
491
+ }
492
+
493
+ async disconnect(sessionId = null) {
494
+ if (sessionId) {
495
+ this.sessions.delete(String(sessionId));
496
+ }
497
+ return null;
498
+ }
499
+
500
+ shutdown() {
501
+ if (this.#pruneTimer) {
502
+ clearInterval(this.#pruneTimer);
503
+ this.#pruneTimer = null;
504
+ }
505
+ }
506
+
507
+ async executeTool(toolName, args, connection) {
508
+ switch (toolName) {
509
+ case 'weather_search_locations': {
510
+ const query = String(args.query || '').trim();
511
+ if (!query) {
512
+ throw new Error('query is required.');
513
+ }
514
+ const limit = Math.max(1, Math.min(Number(args.limit) || 5, 10));
515
+ const results = await geocodeLocation(query, limit);
516
+ return {
517
+ result: {
518
+ query,
519
+ count: results.length,
520
+ results,
521
+ },
522
+ };
523
+ }
524
+ case 'weather_get_current': {
525
+ const location = await resolveLocation(args, connection);
526
+ const forecast = await fetchForecastForLocation(location, 1);
527
+ return {
528
+ result: {
529
+ location: forecast.location,
530
+ current: forecast.current,
531
+ },
532
+ };
533
+ }
534
+ case 'weather_get_forecast': {
535
+ const location = await resolveLocation(args, connection);
536
+ const forecastHours = Math.max(1, Math.min(Number(args.forecast_hours) || 24, 72));
537
+ const forecast = await fetchForecastForLocation(location, forecastHours);
538
+ return {
539
+ result: {
540
+ location: forecast.location,
541
+ current: forecast.current,
542
+ hourly: forecast.hourly,
543
+ horizonHours: forecastHours,
544
+ },
545
+ };
546
+ }
547
+ default:
548
+ return null;
549
+ }
550
+ }
551
+ }
552
+
553
+ function createWeatherProvider() {
554
+ return new WeatherProvider();
555
+ }
556
+
557
+ module.exports = {
558
+ createWeatherProvider,
559
+ };
@@ -33,6 +33,7 @@ const {
33
33
  summarizeAccessPolicy,
34
34
  classifyRecentTarget,
35
35
  } = require('./access_policy');
36
+ const { decryptValue, encryptValue } = require('../integrations/secrets');
36
37
 
37
38
  const LEGACY_WHATSAPP_AUTH_DIR = path.join(DATA_DIR, 'whatsapp-auth');
38
39
 
@@ -268,6 +269,31 @@ class MessagingManager extends EventEmitter {
268
269
  return undefined;
269
270
  }
270
271
 
272
+ _encodeStoredConfig(config) {
273
+ const serialized = JSON.stringify(this._persistableConfig(config) || {});
274
+ if (!serialized) return '{}';
275
+ try {
276
+ return encryptValue(serialized);
277
+ } catch {
278
+ return serialized;
279
+ }
280
+ }
281
+
282
+ _decodeStoredConfig(value) {
283
+ const raw = String(value || '').trim();
284
+ if (!raw) return {};
285
+ try {
286
+ const decoded = decryptValue(raw);
287
+ return decoded ? JSON.parse(decoded) : {};
288
+ } catch {
289
+ try {
290
+ return JSON.parse(raw);
291
+ } catch {
292
+ return {};
293
+ }
294
+ }
295
+ }
296
+
271
297
  async connectPlatform(userId, platformName, config = {}, options = {}) {
272
298
  const agentId = this._agentId(userId, options);
273
299
  config = { ...(config || {}) };
@@ -322,7 +348,7 @@ class MessagingManager extends EventEmitter {
322
348
  config.voiceRuntimeManager = this.voiceRuntimeManager || null;
323
349
  }
324
350
 
325
- const storedConfig = JSON.stringify(this._persistableConfig(config) || {});
351
+ const storedConfig = this._encodeStoredConfig(config);
326
352
 
327
353
  const key = this._key(userId, agentId, platformName);
328
354
  let platform = this.platforms.get(key);
@@ -569,11 +595,7 @@ class MessagingManager extends EventEmitter {
569
595
  if (platformName === 'whatsapp') {
570
596
  reconnectConfig = platform?.config || {};
571
597
  if ((!reconnectConfig || Object.keys(reconnectConfig).length === 0) && row?.config) {
572
- try {
573
- reconnectConfig = JSON.parse(row.config);
574
- } catch {
575
- reconnectConfig = {};
576
- }
598
+ reconnectConfig = this._decodeStoredConfig(row.config);
577
599
  }
578
600
  }
579
601
  if (platform && platform.logout) {
@@ -594,7 +616,7 @@ class MessagingManager extends EventEmitter {
594
616
  ).all();
595
617
  for (const row of rows) {
596
618
  try {
597
- const config = row.config ? JSON.parse(row.config) : {};
619
+ const config = this._decodeStoredConfig(row.config);
598
620
  console.log(`[Messaging] Restoring ${row.platform} for user ${row.user_id} agent ${row.agent_id || 'main'}`);
599
621
  await this.connectPlatform(row.user_id, row.platform, config, { agentId: row.agent_id });
600
622
  } catch (err) {
@@ -6,5 +6,6 @@ module.exports = [
6
6
  require('./outlook_email_received'),
7
7
  require('./slack_message_received'),
8
8
  require('./teams_message_received'),
9
+ require('./weather_event'),
9
10
  require('./whatsapp_personal_message_received'),
10
11
  ];
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const cron = require('node-cron');
4
- const { findNextRun } = require('../schedule_utils');
4
+ const { findNextRun, parseCronExpression } = require('../schedule_utils');
5
5
 
6
6
  function normalizeRunAt(value) {
7
7
  if (!value) return null;
@@ -12,6 +12,37 @@ function normalizeRunAt(value) {
12
12
  return date.toISOString();
13
13
  }
14
14
 
15
+ function normalizeCronExpression(value) {
16
+ const raw = String(value || '').trim();
17
+ if (!raw) {
18
+ throw new Error('A valid cron expression is required.');
19
+ }
20
+
21
+ const fields = raw.split(/\s+/);
22
+ if (fields.length === 6) {
23
+ const seconds = String(fields[0] || '').trim();
24
+ if (seconds !== '0' && seconds !== '*') {
25
+ throw new Error('Cron expressions with seconds are not supported. Use a 5-field expression.');
26
+ }
27
+ fields.shift();
28
+ }
29
+
30
+ if (fields.length !== 5) {
31
+ throw new Error('A valid cron expression is required.');
32
+ }
33
+
34
+ // Quartz-style "?" means "no specific value"; convert to standard wildcard.
35
+ if (fields[2] === '?') fields[2] = '*';
36
+ if (fields[4] === '?') fields[4] = '*';
37
+
38
+ const normalized = fields.join(' ');
39
+ parseCronExpression(normalized);
40
+ if (!cron.validate(normalized)) {
41
+ throw new Error('A valid cron expression is required.');
42
+ }
43
+ return normalized;
44
+ }
45
+
15
46
  module.exports = {
16
47
  type: 'schedule',
17
48
  label: 'Schedule',
@@ -31,10 +62,7 @@ module.exports = {
31
62
  };
32
63
  }
33
64
 
34
- const cronExpression = String(config.cronExpression || config.cron_expression || '').trim();
35
- if (!cronExpression || !cron.validate(cronExpression)) {
36
- throw new Error('A valid cron expression is required.');
37
- }
65
+ const cronExpression = normalizeCronExpression(config.cronExpression || config.cron_expression);
38
66
  return {
39
67
  mode,
40
68
  cronExpression,