morpheus-cli 0.6.2 → 0.6.4

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.
@@ -8,6 +8,8 @@ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid, killProce
8
8
  import { ConfigManager } from '../../config/manager.js';
9
9
  import { renderBanner } from '../utils/render.js';
10
10
  import { TelegramAdapter } from '../../channels/telegram.js';
11
+ import { DiscordAdapter } from '../../channels/discord.js';
12
+ import { ChannelRegistry } from '../../channels/registry.js';
11
13
  import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
12
14
  import { PATHS } from '../../config/paths.js';
13
15
  import { Oracle } from '../../runtime/oracle.js';
@@ -17,7 +19,6 @@ import { getVersion } from '../utils/version.js';
17
19
  import { startSessionEmbeddingScheduler } from '../../runtime/session-embedding-scheduler.js';
18
20
  import { TaskWorker } from '../../runtime/tasks/worker.js';
19
21
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
20
- import { TaskDispatcher } from '../../runtime/tasks/dispatcher.js';
21
22
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
22
23
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
23
24
  export const startCommand = new Command('start')
@@ -148,10 +149,7 @@ export const startCommand = new Command('start')
148
149
  const telegram = new TelegramAdapter(oracle);
149
150
  try {
150
151
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
151
- // Wire Telegram adapter to webhook dispatcher for proactive notifications
152
- WebhookDispatcher.setTelegramAdapter(telegram);
153
- TaskDispatcher.setTelegramAdapter(telegram);
154
- ChronosWorker.setNotifyFn((text) => telegram.sendMessage(text));
152
+ ChannelRegistry.register(telegram);
155
153
  adapters.push(telegram);
156
154
  }
157
155
  catch (e) {
@@ -162,6 +160,23 @@ export const startCommand = new Command('start')
162
160
  display.log(chalk.yellow('Telegram enabled but no token provided. Skipping.'), { source: 'Zaion' });
163
161
  }
164
162
  }
163
+ // Initialize Discord
164
+ if (config.channels.discord.enabled) {
165
+ if (config.channels.discord.token) {
166
+ const discord = new DiscordAdapter(oracle);
167
+ try {
168
+ await discord.connect(config.channels.discord.token, config.channels.discord.allowedUsers || []);
169
+ ChannelRegistry.register(discord);
170
+ adapters.push(discord);
171
+ }
172
+ catch (e) {
173
+ display.log(chalk.red(`Failed to initialize Discord adapter: ${e.message}`), { source: 'Zaion' });
174
+ }
175
+ }
176
+ else {
177
+ display.log(chalk.yellow('Discord enabled but no token provided. Skipping.'), { source: 'Zaion' });
178
+ }
179
+ }
165
180
  // Start Background Services
166
181
  startSessionEmbeddingScheduler();
167
182
  chronosWorker.start();
@@ -167,8 +167,9 @@ export class ConfigManager {
167
167
  allowedUsers: resolveStringArray('MORPHEUS_TELEGRAM_ALLOWED_USERS', config.channels.telegram.allowedUsers, DEFAULT_CONFIG.channels.telegram.allowedUsers)
168
168
  },
169
169
  discord: {
170
- enabled: config.channels.discord.enabled, // Discord doesn't have env var precedence for now
171
- token: config.channels.discord.token
170
+ enabled: config.channels.discord.enabled,
171
+ token: config.channels.discord.token,
172
+ allowedUsers: config.channels.discord.allowedUsers ?? []
172
173
  }
173
174
  };
174
175
  // Apply precedence to UI config
@@ -67,6 +67,7 @@ export const ConfigSchema = z.object({
67
67
  discord: z.object({
68
68
  enabled: z.boolean().default(false),
69
69
  token: z.string().optional(),
70
+ allowedUsers: z.array(z.string()).default([]),
70
71
  }).default(DEFAULT_CONFIG.channels.discord),
71
72
  }).default(DEFAULT_CONFIG.channels),
72
73
  ui: z.object({
@@ -11,12 +11,14 @@ const CreateJobSchema = z.object({
11
11
  schedule_type: ScheduleTypeSchema,
12
12
  schedule_expression: z.string().min(1),
13
13
  timezone: z.string().optional(),
14
+ notify_channels: z.array(z.string()).optional(),
14
15
  });
15
16
  const UpdateJobSchema = z.object({
16
17
  prompt: z.string().min(1).max(10000).optional(),
17
18
  schedule_expression: z.string().min(1).optional(),
18
19
  timezone: z.string().optional(),
19
20
  enabled: z.boolean().optional(),
21
+ notify_channels: z.array(z.string()).optional(),
20
22
  });
21
23
  const PreviewSchema = z.object({
22
24
  expression: z.string().min(1),
@@ -83,7 +85,7 @@ export function createChronosJobRouter(repo, _worker) {
83
85
  if (!parsed.success) {
84
86
  return res.status(400).json({ error: 'Invalid input', details: parsed.error.issues });
85
87
  }
86
- const { prompt, schedule_type, schedule_expression, timezone } = parsed.data;
88
+ const { prompt, schedule_type, schedule_expression, timezone, notify_channels } = parsed.data;
87
89
  const globalTz = configManager.getChronosConfig().timezone;
88
90
  const tz = timezone ?? globalTz;
89
91
  const opts = { timezone: tz };
@@ -97,6 +99,7 @@ export function createChronosJobRouter(repo, _worker) {
97
99
  timezone: tz,
98
100
  next_run_at: schedule.next_run_at,
99
101
  created_by: 'ui',
102
+ notify_channels: notify_channels ?? [],
100
103
  });
101
104
  const display = DisplayManager.getInstance();
102
105
  display.log(`Job ${job.id} created — ${schedule.human_readable}`, { source: 'Chronos' });
@@ -154,6 +157,7 @@ export function createChronosJobRouter(repo, _worker) {
154
157
  timezone: patch.timezone,
155
158
  next_run_at: updatedSchedule?.next_run_at,
156
159
  enabled: patch.enabled,
160
+ notify_channels: patch.notify_channels,
157
161
  });
158
162
  res.json(job);
159
163
  }
@@ -11,7 +11,7 @@ const CreateWebhookSchema = z.object({
11
11
  .max(100)
12
12
  .regex(/^[a-z0-9-_]+$/, 'Name must be a slug: lowercase letters, numbers, hyphens, underscores only'),
13
13
  prompt: z.string().min(1).max(10_000),
14
- notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).default(['ui']),
14
+ notification_channels: z.array(z.enum(['ui', 'telegram', 'discord'])).min(1).default(['ui']),
15
15
  });
16
16
  const UpdateWebhookSchema = z.object({
17
17
  name: z
@@ -22,7 +22,7 @@ const UpdateWebhookSchema = z.object({
22
22
  .optional(),
23
23
  prompt: z.string().min(1).max(10_000).optional(),
24
24
  enabled: z.boolean().optional(),
25
- notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).optional(),
25
+ notification_channels: z.array(z.enum(['ui', 'telegram', 'discord'])).min(1).optional(),
26
26
  });
27
27
  const MarkReadSchema = z.object({
28
28
  ids: z.array(z.string().uuid()).min(1),
@@ -7,7 +7,7 @@ const mockConfig = {
7
7
  llm: { provider: 'openai', model: 'gpt-3.5-turbo', temperature: 0.1, api_key: 'sk-mock-key' },
8
8
  channels: {
9
9
  telegram: { enabled: false, allowedUsers: [] },
10
- discord: { enabled: false }
10
+ discord: { enabled: false, allowedUsers: [] }
11
11
  },
12
12
  ui: { enabled: false, port: 3333 },
13
13
  logging: { enabled: false, level: 'info', retention: '1d' },
@@ -61,6 +61,7 @@ export class ChronosRepository {
61
61
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`, 'created_at');
62
62
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`, 'updated_at');
63
63
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_by TEXT NOT NULL DEFAULT 'api'`, 'created_by');
64
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN notify_channels TEXT NOT NULL DEFAULT '[]'`, 'notify_channels');
64
65
  const execInfo = this.db.pragma('table_info(chronos_executions)');
65
66
  const execCols = new Set(execInfo.map((c) => c.name));
66
67
  const addExecCol = (sql, col) => {
@@ -87,6 +88,11 @@ export class ChronosRepository {
87
88
  `);
88
89
  }
89
90
  deserializeJob(row) {
91
+ let notify_channels = [];
92
+ try {
93
+ notify_channels = JSON.parse(row.notify_channels || '[]');
94
+ }
95
+ catch { /* keep [] */ }
90
96
  return {
91
97
  id: row.id,
92
98
  prompt: row.prompt,
@@ -100,6 +106,7 @@ export class ChronosRepository {
100
106
  created_at: row.created_at,
101
107
  updated_at: row.updated_at,
102
108
  created_by: row.created_by,
109
+ notify_channels,
103
110
  };
104
111
  }
105
112
  deserializeExecution(row) {
@@ -125,9 +132,9 @@ export class ChronosRepository {
125
132
  this.db.prepare(`
126
133
  INSERT INTO chronos_jobs (
127
134
  id, prompt, schedule_type, schedule_expression, cron_normalized,
128
- timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by
129
- ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?)
130
- `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by);
135
+ timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by, notify_channels
136
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?)
137
+ `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by, JSON.stringify(input.notify_channels ?? []));
131
138
  return this.getJob(id);
132
139
  }
133
140
  getJob(id) {
@@ -181,6 +188,10 @@ export class ChronosRepository {
181
188
  sets.push('enabled = ?');
182
189
  params.push(patch.enabled ? 1 : 0);
183
190
  }
191
+ if (patch.notify_channels !== undefined) {
192
+ sets.push('notify_channels = ?');
193
+ params.push(JSON.stringify(patch.notify_channels));
194
+ }
184
195
  params.push(id);
185
196
  this.db.prepare(`UPDATE chronos_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...params);
186
197
  return this.getJob(id);
@@ -2,11 +2,11 @@ import { randomUUID } from 'crypto';
2
2
  import { ConfigManager } from '../../config/manager.js';
3
3
  import { DisplayManager } from '../display.js';
4
4
  import { parseNextRun } from './parser.js';
5
+ import { ChannelRegistry } from '../../channels/registry.js';
5
6
  export class ChronosWorker {
6
7
  repo;
7
8
  oracle;
8
9
  static instance = null;
9
- static notifyFn = null;
10
10
  /**
11
11
  * True while a Chronos job is being executed. Chronos management tools
12
12
  * (chronos_cancel, chronos_schedule) check this flag and refuse to operate
@@ -28,10 +28,6 @@ export class ChronosWorker {
28
28
  static setInstance(worker) {
29
29
  ChronosWorker.instance = worker;
30
30
  }
31
- /** Register a function that will deliver Oracle responses to users (e.g. Telegram). */
32
- static setNotifyFn(fn) {
33
- ChronosWorker.notifyFn = fn;
34
- }
35
31
  start() {
36
32
  if (this.timer)
37
33
  return;
@@ -93,11 +89,13 @@ export class ChronosWorker {
93
89
  // This avoids persisting an AIMessage with the marker in conversation history,
94
90
  // which would cause the LLM to reproduce the format in future scheduling responses.
95
91
  const promptWithContext = `[CHRONOS EXECUTION — job_id: ${job.id}]\n${job.prompt}`;
96
- // If a Telegram notify function is registered, tag delegated tasks with
97
- // origin_channel: 'telegram' so the TaskDispatcher broadcasts their result.
98
- const taskContext = ChronosWorker.notifyFn
99
- ? { origin_channel: 'telegram', session_id: activeSessionId }
100
- : undefined;
92
+ // Determine which channel to tag delegated tasks with.
93
+ // Single notify_channel → use it so TaskDispatcher routes back correctly.
94
+ // Multiple or empty → 'chronos' means broadcast to all registered adapters.
95
+ const taskOriginChannel = job.notify_channels.length === 1
96
+ ? job.notify_channels[0]
97
+ : 'chronos';
98
+ const taskContext = { origin_channel: taskOriginChannel, session_id: activeSessionId };
101
99
  // Hard-block Chronos management tools during execution.
102
100
  ChronosWorker.isExecuting = true;
103
101
  const response = await this.oracle.chat(promptWithContext, undefined, false, taskContext);
@@ -126,15 +124,26 @@ export class ChronosWorker {
126
124
  }
127
125
  }
128
126
  async notify(job, response) {
129
- if (!ChronosWorker.notifyFn)
127
+ if (ChannelRegistry.getAll().length === 0)
130
128
  return;
131
- const display = DisplayManager.getInstance();
132
129
  const header = `⏰ *Chronos* — _${job.prompt.slice(0, 80)}${job.prompt.length > 80 ? '…' : ''}_\n\n`;
133
- try {
134
- await ChronosWorker.notifyFn(header + response);
130
+ const text = header + response;
131
+ if (job.notify_channels.length === 0) {
132
+ // No specific channels → broadcast to all registered adapters
133
+ await ChannelRegistry.broadcast(text);
135
134
  }
136
- catch (err) {
137
- display.log(`Job ${job.id} notification failed — ${err.message}`, { source: 'Chronos', level: 'error' });
135
+ else {
136
+ for (const ch of job.notify_channels) {
137
+ const adapter = ChannelRegistry.get(ch);
138
+ if (adapter) {
139
+ await adapter.sendMessage(text).catch((err) => {
140
+ DisplayManager.getInstance().log(`Job ${job.id} notification failed on channel "${ch}": ${err.message}`, { source: 'Chronos', level: 'error' });
141
+ });
142
+ }
143
+ else {
144
+ DisplayManager.getInstance().log(`Job ${job.id}: no adapter registered for channel "${ch}" — notification skipped.`, { source: 'Chronos', level: 'warning' });
145
+ }
146
+ }
138
147
  }
139
148
  }
140
149
  }
@@ -29,6 +29,7 @@ function makeJob(overrides = {}) {
29
29
  created_at: Date.now() - 5000,
30
30
  updated_at: Date.now() - 5000,
31
31
  created_by: 'ui',
32
+ notify_channels: [],
32
33
  ...overrides,
33
34
  };
34
35
  }
@@ -212,6 +212,17 @@ Rules:
212
212
  10. After enqueuing all required delegated tasks for the current message, stop calling tools and return a concise acknowledgement.
213
213
  11. If a delegation is rejected as "not atomic", immediately split into smaller delegations and retry.
214
214
 
215
+ ## Chronos Channel Routing
216
+ When calling chronos_schedule, set notify_channels based on the user's message:
217
+ - User mentions a specific channel (e.g., "no Discord", "no Telegram", "on Discord", "me avise pelo Discord"): set notify_channels to that channel — e.g. ["discord"] or ["telegram"].
218
+ - User says "all channels", "todos os canais", "em todos os canais": set notify_channels to [] (empty = broadcast to all active channels).
219
+ - User does NOT mention any channel: omit notify_channels entirely (auto-detect uses the current conversation channel).
220
+
221
+ Examples:
222
+ - "me lembre daqui 5 minutos pelo Discord" → notify_channels: ["discord"]
223
+ - "lembre em todos os canais" → notify_channels: []
224
+ - "lembre em 1 hora" (sem canal) → omit notify_channels
225
+
215
226
  ## Chronos Scheduled Execution
216
227
  When the current user message starts with [CHRONOS EXECUTION], it means a Chronos scheduled job has just fired. The content after the prefix is the **job's saved prompt**, not a new live request from the user.
217
228
 
@@ -2,12 +2,9 @@ import { DisplayManager } from '../display.js';
2
2
  import { WebhookRepository } from '../webhooks/repository.js';
3
3
  import { AIMessage } from '@langchain/core/messages';
4
4
  import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
5
+ import { ChannelRegistry } from '../../channels/registry.js';
5
6
  export class TaskDispatcher {
6
- static telegramAdapter = null;
7
7
  static display = DisplayManager.getInstance();
8
- static setTelegramAdapter(adapter) {
9
- TaskDispatcher.telegramAdapter = adapter;
10
- }
11
8
  static async notifyTaskResult(task) {
12
9
  if (task.origin_channel === 'webhook') {
13
10
  if (!task.origin_message_id) {
@@ -19,25 +16,25 @@ export class TaskDispatcher {
19
16
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
20
17
  : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
21
18
  repo.updateNotificationResult(task.origin_message_id, status, result);
22
- // Send Telegram notification if the webhook has 'telegram' in notification_channels
19
+ // Notify channels configured on the webhook
23
20
  const notification = repo.getNotificationById(task.origin_message_id);
24
21
  if (notification) {
25
22
  const webhook = repo.getWebhookById(notification.webhook_id);
26
- if (webhook?.notification_channels.includes('telegram')) {
27
- const adapter = TaskDispatcher.telegramAdapter;
28
- if (adapter) {
29
- try {
30
- const icon = status === 'completed' ? '✅' : '❌';
31
- const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
32
- await adapter.sendMessage(`${icon} Webhook: ${webhook.name}\n\n${truncated}`);
23
+ if (webhook) {
24
+ const icon = status === 'completed' ? '✅' : '❌';
25
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
26
+ const message = `${icon} Webhook: ${webhook.name}\n\n${truncated}`;
27
+ for (const ch of webhook.notification_channels) {
28
+ const adapter = ChannelRegistry.get(ch);
29
+ if (adapter) {
30
+ await adapter.sendMessage(message).catch((err) => {
31
+ TaskDispatcher.display.log(`Failed to send notification via ${ch} for webhook "${webhook.name}": ${err.message}`, { source: 'TaskDispatcher', level: 'error' });
32
+ });
33
33
  }
34
- catch (err) {
35
- TaskDispatcher.display.log(`Failed to send Telegram notification for webhook "${webhook.name}": ${err.message}`, { source: 'TaskDispatcher', level: 'error' });
34
+ else {
35
+ TaskDispatcher.display.log(`Notification skipped for channel "${ch}" adapter not registered.`, { source: 'TaskDispatcher', level: 'warning' });
36
36
  }
37
37
  }
38
- else {
39
- TaskDispatcher.display.log(`Telegram notification skipped for webhook "${webhook.name}" — adapter not connected.`, { source: 'TaskDispatcher', level: 'warning' });
40
- }
41
38
  }
42
39
  }
43
40
  return;
@@ -65,13 +62,21 @@ export class TaskDispatcher {
65
62
  }
66
63
  return;
67
64
  }
68
- if (task.origin_channel !== 'telegram') {
65
+ // 'chronos' origin — broadcast to all registered channels
66
+ if (task.origin_channel === 'chronos') {
67
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
68
+ const body = task.status === 'completed'
69
+ ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
70
+ : task.status === 'cancelled'
71
+ ? 'Task was cancelled.'
72
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
73
+ const message = `${statusIcon} Task \`${task.id.toUpperCase()}\`\n` +
74
+ `Agent: \`${task.agent.toUpperCase()}\`\n` +
75
+ `Status: \`${task.status.toUpperCase()}\`\n\n${body}`;
76
+ await ChannelRegistry.broadcast(message);
69
77
  return;
70
78
  }
71
- const adapter = TaskDispatcher.telegramAdapter;
72
- if (!adapter) {
73
- throw new Error('Telegram adapter not connected');
74
- }
79
+ // Channel-specific routing (telegram, discord, etc.)
75
80
  const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
76
81
  const body = task.status === 'completed'
77
82
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
@@ -83,10 +88,15 @@ export class TaskDispatcher {
83
88
  `Status: \`${task.status.toUpperCase()}\``;
84
89
  const message = `${header}\n\n${body}`;
85
90
  if (task.origin_user_id) {
86
- await adapter.sendMessageToUser(task.origin_user_id, message);
91
+ await ChannelRegistry.sendToUser(task.origin_channel, task.origin_user_id, message);
92
+ return;
93
+ }
94
+ // No specific user — broadcast on the originating channel
95
+ const adapter = ChannelRegistry.get(task.origin_channel);
96
+ if (!adapter) {
97
+ TaskDispatcher.display.log(`Task ${task.id}: no adapter for channel "${task.origin_channel}" — notification skipped.`, { source: 'TaskDispatcher', level: 'warning' });
87
98
  return;
88
99
  }
89
- TaskDispatcher.display.log(`Task ${task.id} has telegram origin but no origin_user_id; broadcasting to allowed users.`, { source: 'TaskDispatcher', level: 'warning' });
90
100
  await adapter.sendMessage(message);
91
101
  }
92
102
  static async onTaskFinished(task) {
@@ -4,8 +4,11 @@ import { ChronosRepository } from '../chronos/repository.js';
4
4
  import { parseScheduleExpression, getNextOccurrences } from '../chronos/parser.js';
5
5
  import { ConfigManager } from '../../config/manager.js';
6
6
  import { ChronosWorker } from '../chronos/worker.js';
7
+ import { TaskRequestContext } from '../tasks/context.js';
7
8
  // ─── chronos_schedule ────────────────────────────────────────────────────────
8
- export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule_expression, timezone }) => {
9
+ // Channels that can be used as notify_channels on Chronos jobs
10
+ const NON_CHANNEL_ORIGINS = new Set(['ui', 'api', 'cli', 'chronos', 'webhook']);
11
+ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule_expression, timezone, notify_channels }) => {
9
12
  if (ChronosWorker.isExecuting) {
10
13
  return JSON.stringify({ success: false, error: 'Cannot create a new Chronos job from within an active Chronos execution.' });
11
14
  }
@@ -13,6 +16,20 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
13
16
  const cfg = ConfigManager.getInstance().getChronosConfig();
14
17
  const tz = timezone ?? cfg.timezone;
15
18
  const parsed = parseScheduleExpression(schedule_expression, schedule_type, { timezone: tz });
19
+ // Resolve notify_channels:
20
+ // - Explicitly provided → use as-is
21
+ // - Not provided → auto-detect from current conversation origin channel
22
+ // - If origin channel is not a real user-facing channel (ui/api/cli) → empty = broadcast all
23
+ let channels;
24
+ if (notify_channels !== undefined) {
25
+ channels = notify_channels;
26
+ }
27
+ else {
28
+ const originChannel = TaskRequestContext.get()?.origin_channel;
29
+ channels = originChannel && !NON_CHANNEL_ORIGINS.has(originChannel)
30
+ ? [originChannel]
31
+ : [];
32
+ }
16
33
  const repo = ChronosRepository.getInstance();
17
34
  const job = repo.createJob({
18
35
  prompt,
@@ -22,6 +39,7 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
22
39
  next_run_at: parsed.next_run_at,
23
40
  cron_normalized: parsed.cron_normalized,
24
41
  created_by: 'oracle',
42
+ notify_channels: channels,
25
43
  });
26
44
  return JSON.stringify({
27
45
  success: true,
@@ -31,6 +49,7 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
31
49
  human_readable: parsed.human_readable,
32
50
  next_run_at: job.next_run_at != null ? new Date(job.next_run_at).toISOString() : null,
33
51
  timezone: job.timezone,
52
+ notify_channels: job.notify_channels,
34
53
  });
35
54
  }
36
55
  catch (err) {
@@ -42,7 +61,9 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
42
61
  'Use schedule_type "once" for a single execution (e.g. "tomorrow at 9am", "in 2 hours", "2026-03-01T09:00:00"), ' +
43
62
  '"cron" for a recurring cron expression (e.g. "0 9 * * 1-5" = every weekday at 9am), ' +
44
63
  '"interval" for natural interval phrases (e.g. "every 30 minutes", "every 2 hours", "every day"). ' +
45
- 'Returns the created job ID and the human-readable schedule confirmation.',
64
+ 'Returns the created job ID and the human-readable schedule confirmation. ' +
65
+ 'Use notify_channels to control where the result is delivered: omit to auto-detect from current channel, ' +
66
+ 'pass ["discord"] or ["telegram"] for a specific channel, or [] for all active channels.',
46
67
  schema: z.object({
47
68
  prompt: z.string().describe('The prompt text to send to Oracle at the scheduled time.'),
48
69
  schedule_type: z
@@ -57,6 +78,13 @@ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule
57
78
  .string()
58
79
  .optional()
59
80
  .describe('IANA timezone (e.g. "America/Sao_Paulo"). Defaults to the global Chronos timezone config.'),
81
+ notify_channels: z
82
+ .array(z.string())
83
+ .optional()
84
+ .describe('Channels to notify when this job fires. ' +
85
+ 'Omit to auto-use the current conversation channel. ' +
86
+ 'Pass [] to broadcast to all active channels. ' +
87
+ 'Pass ["discord"], ["telegram"], or ["discord","telegram"] for specific channels.'),
60
88
  }),
61
89
  });
62
90
  // ─── chronos_list ────────────────────────────────────────────────────────────
@@ -1,18 +1,11 @@
1
1
  import { WebhookRepository } from './repository.js';
2
2
  import { TaskRepository } from '../tasks/repository.js';
3
3
  import { DisplayManager } from '../display.js';
4
+ import { ChannelRegistry } from '../../channels/registry.js';
4
5
  const STALE_NOTIFICATION_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
5
6
  export class WebhookDispatcher {
6
- static telegramAdapter = null;
7
7
  static oracle = null;
8
8
  display = DisplayManager.getInstance();
9
- /**
10
- * Called at boot time after TelegramAdapter.connect() succeeds,
11
- * so Telegram notifications can be dispatched from any trigger.
12
- */
13
- static setTelegramAdapter(adapter) {
14
- WebhookDispatcher.telegramAdapter = adapter;
15
- }
16
9
  /**
17
10
  * Called at boot time with the Oracle instance so webhooks can use
18
11
  * the full Oracle (MCPs, apoc_delegate, memory, etc.).
@@ -54,18 +47,14 @@ export class WebhookDispatcher {
54
47
  else {
55
48
  repo.updateNotificationResult(notificationId, 'completed', response);
56
49
  this.display.log(`Webhook "${webhook.name}" completed with direct response (notification: ${notificationId})`, { source: 'Webhooks', level: 'success' });
57
- if (webhook.notification_channels.includes('telegram')) {
58
- await this.sendTelegram(webhook.name, response, 'completed');
59
- }
50
+ await this.notifyChannels(webhook, response, 'completed');
60
51
  }
61
52
  }
62
53
  catch (err) {
63
54
  const result = `Execution error: ${err.message}`;
64
55
  this.display.log(`Webhook "${webhook.name}" failed: ${err.message}`, { source: 'Webhooks', level: 'error' });
65
56
  repo.updateNotificationResult(notificationId, 'failed', result);
66
- if (webhook.notification_channels.includes('telegram')) {
67
- await this.sendTelegram(webhook.name, result, 'failed');
68
- }
57
+ await this.notifyChannels(webhook, result, 'failed');
69
58
  }
70
59
  }
71
60
  /**
@@ -140,23 +129,23 @@ Analyze the payload above and follow the instructions provided. Be concise and a
140
129
  }
141
130
  }
142
131
  /**
143
- * Sends a formatted Telegram message to all allowed users.
144
- * Silently skips if the adapter is not connected.
132
+ * Sends a notification to all channels configured on the webhook.
133
+ * Uses ChannelRegistry no adapter references needed here.
145
134
  */
146
- async sendTelegram(webhookName, result, status) {
147
- const adapter = WebhookDispatcher.telegramAdapter;
148
- if (!adapter) {
149
- this.display.log('Telegram notification skipped — adapter not connected.', { source: 'Webhooks', level: 'warning' });
150
- return;
151
- }
152
- try {
153
- const icon = status === 'completed' ? '✅' : '❌';
154
- const truncated = result.length > 3500 ? result.slice(0, 3500) + '' : result;
155
- const message = `${icon} Webhook: ${webhookName}\n\n${truncated}`;
156
- await adapter.sendMessage(message);
157
- }
158
- catch (err) {
159
- this.display.log(`Failed to send Telegram notification for webhook "${webhookName}": ${err.message}`, { source: 'Webhooks', level: 'error' });
135
+ async notifyChannels(webhook, result, status) {
136
+ const icon = status === 'completed' ? '✅' : '❌';
137
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
138
+ const message = `${icon} Webhook: ${webhook.name}\n\n${truncated}`;
139
+ for (const ch of webhook.notification_channels) {
140
+ const adapter = ChannelRegistry.get(ch);
141
+ if (adapter) {
142
+ await adapter.sendMessage(message).catch((err) => {
143
+ this.display.log(`Failed to send notification via "${ch}" for webhook "${webhook.name}": ${err.message}`, { source: 'Webhooks', level: 'error' });
144
+ });
145
+ }
146
+ else {
147
+ this.display.log(`Notification skipped for channel "${ch}" — adapter not registered.`, { source: 'Webhooks', level: 'warning' });
148
+ }
160
149
  }
161
150
  }
162
151
  }
@@ -31,7 +31,7 @@ export const DEFAULT_CONFIG = {
31
31
  },
32
32
  channels: {
33
33
  telegram: { enabled: false, allowedUsers: [] },
34
- discord: { enabled: false },
34
+ discord: { enabled: false, allowedUsers: [] },
35
35
  },
36
36
  ui: {
37
37
  enabled: true,