morpheus-cli 0.4.4 → 0.4.7

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 CHANGED
@@ -242,6 +242,31 @@ Morpheus features a dedicated middleware system called **Sati** (Mindfulness) th
242
242
  - **Data Privacy**: Stored in a local, independent SQLite database (`santi-memory.db`), ensuring sensitive data is handled securely and reducing context window usage.
243
243
  - **Memory Management**: View and manage your long-term memories through the Web UI or via API endpoints.
244
244
 
245
+ ### 🪝 Webhooks & Notifications
246
+
247
+ Morpheus includes a **Webhook System** that lets any external service (GitHub Actions, CI/CD pipelines, monitoring tools, etc.) trigger Oracle and receive the result asynchronously.
248
+
249
+ **How it works:**
250
+ 1. Create a webhook via the Web UI or API — give it a name (slug) and a prompt.
251
+ 2. You receive a unique `api_key` for that webhook.
252
+ 3. Trigger it from anywhere by posting JSON to `POST /api/webhooks/trigger/<name>` with the `x-api-key` header.
253
+ 4. Morpheus runs Oracle with your prompt + the received payload in the background.
254
+ 5. The result is saved as a **Notification** and optionally pushed to Telegram.
255
+
256
+ **Example — trigger from GitHub Actions:**
257
+ ```yaml
258
+ - name: Notify Morpheus of deployment
259
+ run: |
260
+ curl -s -X POST https://your-morpheus-host/api/webhooks/trigger/deploy-done \
261
+ -H "x-api-key: ${{ secrets.MORPHEUS_WEBHOOK_KEY }}" \
262
+ -H "Content-Type: application/json" \
263
+ -d '{"workflow":"${{ github.workflow }}","status":"success","ref":"${{ github.ref }}"}'
264
+ ```
265
+
266
+ **Security:** Each webhook has its own `api_key` (UUID). The key is sent in the `x-api-key` header — never in the URL — to prevent leakage in server logs. Management endpoints remain protected by `THE_ARCHITECT_PASS`.
267
+
268
+ **Notification channels:** `ui` (Web UI inbox with unread badge) and/or `telegram` (proactive push message).
269
+
245
270
  ### 📊 Usage Analytics
246
271
  Track your token usage across different providers and models directly from the Web UI. View detailed breakdowns of input/output tokens and message counts to monitor costs and activity.
247
272
 
@@ -920,6 +945,106 @@ Restart the Morpheus agent.
920
945
  }
921
946
  ```
922
947
 
948
+ ### Webhook Endpoints
949
+
950
+ All management endpoints require `x-architect-pass` authentication. The trigger endpoint is **public** — authenticated only by the per-webhook `x-api-key` header.
951
+
952
+ #### POST `/api/webhooks/trigger/:webhook_name`
953
+ Trigger a webhook and queue an Oracle agent execution in the background.
954
+
955
+ * **Authentication:** `x-api-key: <webhook_api_key>` header (no `x-architect-pass` required).
956
+ * **Parameters:** `webhook_name` — the slug of the webhook to trigger.
957
+ * **Body:** Any JSON payload (forwarded to the agent as context).
958
+ * **Response (202 Accepted):**
959
+ ```json
960
+ {
961
+ "accepted": true,
962
+ "notification_id": "uuid-..."
963
+ }
964
+ ```
965
+ * **Errors:** `401` for missing/invalid api_key; `404` for webhook not found or disabled.
966
+
967
+ #### GET `/api/webhooks`
968
+ List all configured webhooks.
969
+
970
+ * **Authentication:** `x-architect-pass` header.
971
+ * **Response:**
972
+ ```json
973
+ [
974
+ {
975
+ "id": "uuid",
976
+ "name": "deploy-done",
977
+ "api_key": "uuid",
978
+ "prompt": "Analyze the deployment result...",
979
+ "enabled": true,
980
+ "notification_channels": ["ui", "telegram"],
981
+ "created_at": 1700000000000,
982
+ "last_triggered_at": 1700001000000,
983
+ "trigger_count": 42
984
+ }
985
+ ]
986
+ ```
987
+
988
+ #### POST `/api/webhooks`
989
+ Create a new webhook.
990
+
991
+ * **Authentication:** `x-architect-pass` header.
992
+ * **Body:**
993
+ ```json
994
+ {
995
+ "name": "deploy-done",
996
+ "prompt": "A deployment just finished. Analyze the payload and summarize what happened.",
997
+ "notification_channels": ["ui", "telegram"],
998
+ "enabled": true
999
+ }
1000
+ ```
1001
+ * **Response (201):** The created webhook object including the generated `api_key`.
1002
+
1003
+ #### PUT `/api/webhooks/:id`
1004
+ Update an existing webhook (prompt, channels, enabled status).
1005
+
1006
+ * **Authentication:** `x-architect-pass` header.
1007
+ * **Note:** The `name` (slug) and `api_key` fields are immutable via this endpoint.
1008
+
1009
+ #### DELETE `/api/webhooks/:id`
1010
+ Delete a webhook and all its associated notifications.
1011
+
1012
+ * **Authentication:** `x-architect-pass` header.
1013
+
1014
+ #### GET `/api/webhooks/notifications`
1015
+ List webhook execution notifications.
1016
+
1017
+ * **Authentication:** `x-architect-pass` header.
1018
+ * **Query Parameters:** `unreadOnly=true` to filter unread notifications.
1019
+ * **Response:**
1020
+ ```json
1021
+ [
1022
+ {
1023
+ "id": "uuid",
1024
+ "webhook_id": "uuid",
1025
+ "webhook_name": "deploy-done",
1026
+ "status": "completed",
1027
+ "payload": "{\"ref\":\"main\"}",
1028
+ "result": "Deployment of main to production succeeded...",
1029
+ "read": false,
1030
+ "created_at": 1700001000000,
1031
+ "completed_at": 1700001005000
1032
+ }
1033
+ ]
1034
+ ```
1035
+
1036
+ #### POST `/api/webhooks/notifications/read`
1037
+ Mark notifications as read.
1038
+
1039
+ * **Authentication:** `x-architect-pass` header.
1040
+ * **Body:** `{ "ids": ["uuid1", "uuid2"] }`
1041
+
1042
+ #### GET `/api/webhooks/notifications/unread-count`
1043
+ Get the count of unread notifications (used by the sidebar badge).
1044
+
1045
+ * **Authentication:** `x-architect-pass` header.
1046
+ * **Response:** `{ "count": 3 }`
1047
+
923
1048
  ## Testing
924
1049
 
925
1050
  We use **Vitest** for testing.
@@ -957,8 +1082,10 @@ npm run test:watch
957
1082
 
958
1083
  - [x] **Web Dashboard**: Local UI for management and logs.
959
1084
  - [x] **MCP Support**: Full integration with Model Context Protocol.
1085
+ - [x] **Webhook System**: External triggers with Oracle execution and multi-channel notifications.
960
1086
  - [ ] **Discord Adapter**: Support for Discord interactions.
961
1087
  - [ ] **Plugin System**: Extend functionality via external modules.
1088
+ - [ ] **Webhook Retry Logic**: Exponential backoff for failed Oracle executions.
962
1089
 
963
1090
  ## 🕵️ Privacy Protection
964
1091
 
@@ -356,6 +356,29 @@ export class TelegramAdapter {
356
356
  await fs.writeFile(filePath, buffer);
357
357
  return filePath;
358
358
  }
359
+ /**
360
+ * Sends a proactive message to all allowed Telegram users.
361
+ * Used by the webhook notification system to push results.
362
+ */
363
+ async sendMessage(text) {
364
+ if (!this.isConnected || !this.bot) {
365
+ this.display.log('Cannot send message: Telegram bot not connected.', { source: 'Telegram', level: 'warning' });
366
+ return;
367
+ }
368
+ const allowedUsers = this.config.get().channels.telegram.allowedUsers;
369
+ if (allowedUsers.length === 0) {
370
+ this.display.log('No allowed Telegram users configured — skipping notification.', { source: 'Telegram', level: 'warning' });
371
+ return;
372
+ }
373
+ for (const userId of allowedUsers) {
374
+ try {
375
+ await this.bot.telegram.sendMessage(userId, text, { parse_mode: 'Markdown' });
376
+ }
377
+ catch (err) {
378
+ this.display.log(`Failed to send message to Telegram user ${userId}: ${err.message}`, { source: 'Telegram', level: 'error' });
379
+ }
380
+ }
381
+ }
359
382
  async disconnect() {
360
383
  if (!this.isConnected || !this.bot) {
361
384
  return;
@@ -8,6 +8,7 @@ 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 { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
11
12
  import { PATHS } from '../../config/paths.js';
12
13
  import { Oracle } from '../../runtime/oracle.js';
13
14
  import { ProviderError } from '../../runtime/errors.js';
@@ -136,6 +137,8 @@ export const startCommand = new Command('start')
136
137
  const telegram = new TelegramAdapter(oracle);
137
138
  try {
138
139
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
140
+ // Wire Telegram adapter to webhook dispatcher for proactive notifications
141
+ WebhookDispatcher.setTelegramAdapter(telegram);
139
142
  adapters.push(telegram);
140
143
  }
141
144
  catch (e) {
@@ -26,6 +26,9 @@ export const ApocConfigSchema = LLMConfigSchema.extend({
26
26
  working_dir: z.string().optional(),
27
27
  timeout_ms: z.number().int().positive().optional(),
28
28
  });
29
+ export const WebhookConfigSchema = z.object({
30
+ telegram_notify_all: z.boolean().optional(),
31
+ }).optional();
29
32
  // Zod Schema matching MorpheusConfig interface
30
33
  export const ConfigSchema = z.object({
31
34
  agent: z.object({
@@ -35,6 +38,7 @@ export const ConfigSchema = z.object({
35
38
  llm: LLMConfigSchema.default(DEFAULT_CONFIG.llm),
36
39
  sati: SatiConfigSchema.optional(),
37
40
  apoc: ApocConfigSchema.optional(),
41
+ webhooks: WebhookConfigSchema,
38
42
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
39
43
  memory: z.object({
40
44
  limit: z.number().int().positive().optional(),
@@ -10,7 +10,7 @@ export const authMiddleware = (req, res, next) => {
10
10
  // If password is not configured (using default), log a warning
11
11
  if (!process.env.THE_ARCHITECT_PASS) {
12
12
  const display = DisplayManager.getInstance();
13
- display.log('Using default password for dashboard access. For security, set THE_ARCHITECT_PASS environment variable.', { source: 'http', level: 'warning' });
13
+ // display.log('Using default password for dashboard access. For security, set THE_ARCHITECT_PASS environment variable.', { source: 'http', level: 'warning' });
14
14
  }
15
15
  const providedPass = req.headers[AUTH_HEADER];
16
16
  if (providedPass === architectPass) {
@@ -6,7 +6,9 @@ import { fileURLToPath } from 'url';
6
6
  import { ConfigManager } from '../config/manager.js';
7
7
  import { DisplayManager } from '../runtime/display.js';
8
8
  import { createApiRouter } from './api.js';
9
+ import { createWebhooksRouter } from './webhooks-router.js';
9
10
  import { authMiddleware } from './middleware/auth.js';
11
+ import { WebhookDispatcher } from '../runtime/webhooks/dispatcher.js';
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = path.dirname(__filename);
12
14
  export class HttpServer {
@@ -16,6 +18,8 @@ export class HttpServer {
16
18
  constructor(oracle) {
17
19
  this.app = express();
18
20
  this.oracle = oracle;
21
+ // Wire Oracle into the webhook dispatcher so triggers use the full agent
22
+ WebhookDispatcher.setOracle(oracle);
19
23
  this.setupMiddleware();
20
24
  this.setupRoutes();
21
25
  }
@@ -45,6 +49,10 @@ export class HttpServer {
45
49
  uptime: process.uptime()
46
50
  });
47
51
  });
52
+ // Webhooks router — mounted BEFORE the auth-guarded /api block.
53
+ // The trigger endpoint is public (validated via x-api-key header internally).
54
+ // All other webhook management endpoints apply authMiddleware internally.
55
+ this.app.use('/api/webhooks', createWebhooksRouter());
48
56
  this.app.use('/api', authMiddleware, createApiRouter(this.oracle));
49
57
  // Serve static frontend from compiled output
50
58
  const uiPath = path.resolve(__dirname, '../ui');
@@ -0,0 +1,181 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import { WebhookRepository } from '../runtime/webhooks/repository.js';
4
+ import { WebhookDispatcher } from '../runtime/webhooks/dispatcher.js';
5
+ import { authMiddleware } from './middleware/auth.js';
6
+ import { DisplayManager } from '../runtime/display.js';
7
+ const CreateWebhookSchema = z.object({
8
+ name: z
9
+ .string()
10
+ .min(1)
11
+ .max(100)
12
+ .regex(/^[a-z0-9-_]+$/, 'Name must be a slug: lowercase letters, numbers, hyphens, underscores only'),
13
+ prompt: z.string().min(1).max(10_000),
14
+ notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).default(['ui']),
15
+ });
16
+ const UpdateWebhookSchema = z.object({
17
+ name: z
18
+ .string()
19
+ .min(1)
20
+ .max(100)
21
+ .regex(/^[a-z0-9-_]+$/, 'Name must be a slug')
22
+ .optional(),
23
+ prompt: z.string().min(1).max(10_000).optional(),
24
+ enabled: z.boolean().optional(),
25
+ notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).optional(),
26
+ });
27
+ const MarkReadSchema = z.object({
28
+ ids: z.array(z.string().uuid()).min(1),
29
+ });
30
+ export function createWebhooksRouter() {
31
+ const router = Router();
32
+ const repo = WebhookRepository.getInstance();
33
+ const display = DisplayManager.getInstance();
34
+ const dispatcher = new WebhookDispatcher();
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // PUBLIC TRIGGER ENDPOINT — no authMiddleware, api_key validated via header
37
+ // Must be declared BEFORE router.use(authMiddleware) below
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ router.post('/trigger/:webhook_name', async (req, res) => {
40
+ const { webhook_name } = req.params;
41
+ const apiKey = req.headers['x-api-key'];
42
+ if (!apiKey || typeof apiKey !== 'string') {
43
+ return res.status(401).json({ error: 'Missing x-api-key header' });
44
+ }
45
+ const webhook = repo.getAndValidateWebhook(webhook_name, apiKey);
46
+ if (!webhook) {
47
+ // Intentionally ambiguous — don't reveal whether the name exists or is disabled
48
+ return res.status(401).json({ error: 'Invalid webhook name or api key' });
49
+ }
50
+ const payload = req.body ?? {};
51
+ // Create pending notification immediately
52
+ const notification = repo.createNotification({
53
+ webhook_id: webhook.id,
54
+ webhook_name: webhook.name,
55
+ payload: JSON.stringify(payload),
56
+ });
57
+ // Increment trigger counter
58
+ repo.recordTrigger(webhook.id);
59
+ display.log(`Webhook "${webhook.name}" triggered (notification: ${notification.id})`, { source: 'Webhooks' });
60
+ // Fire-and-forget — respond immediately with 202
61
+ setImmediate(() => {
62
+ dispatcher.dispatch(webhook, payload, notification.id).catch((err) => {
63
+ display.log(`Unhandled webhook dispatch error for "${webhook.name}": ${err.message}`, { source: 'Webhooks', level: 'error' });
64
+ });
65
+ });
66
+ return res.status(202).json({
67
+ accepted: true,
68
+ notification_id: notification.id,
69
+ });
70
+ });
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // NOTIFICATION ENDPOINTS — declared before /:id to prevent route conflict
73
+ // (still public here, authMiddleware applied below protects management routes)
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ router.get('/notifications', authMiddleware, (req, res) => {
76
+ try {
77
+ const { webhookId, unreadOnly } = req.query;
78
+ const notifications = repo.listNotifications({
79
+ webhookId: typeof webhookId === 'string' ? webhookId : undefined,
80
+ unreadOnly: unreadOnly === 'true',
81
+ });
82
+ res.json(notifications);
83
+ }
84
+ catch (err) {
85
+ res.status(500).json({ error: err.message });
86
+ }
87
+ });
88
+ router.post('/notifications/read', authMiddleware, (req, res) => {
89
+ const parsed = MarkReadSchema.safeParse(req.body);
90
+ if (!parsed.success) {
91
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
92
+ }
93
+ try {
94
+ repo.markNotificationsRead(parsed.data.ids);
95
+ res.json({ success: true });
96
+ }
97
+ catch (err) {
98
+ res.status(500).json({ error: err.message });
99
+ }
100
+ });
101
+ router.get('/notifications/unread-count', authMiddleware, (req, res) => {
102
+ try {
103
+ const count = repo.countUnread();
104
+ res.json({ count });
105
+ }
106
+ catch (err) {
107
+ res.status(500).json({ error: err.message });
108
+ }
109
+ });
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // WEBHOOK MANAGEMENT ENDPOINTS — all require authMiddleware
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ router.use(authMiddleware);
114
+ // GET /api/webhooks — list all webhooks
115
+ router.get('/', (req, res) => {
116
+ try {
117
+ const webhooks = repo.listWebhooks();
118
+ res.json(webhooks);
119
+ }
120
+ catch (err) {
121
+ res.status(500).json({ error: err.message });
122
+ }
123
+ });
124
+ // POST /api/webhooks — create a webhook
125
+ router.post('/', (req, res) => {
126
+ const parsed = CreateWebhookSchema.safeParse(req.body);
127
+ if (!parsed.success) {
128
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
129
+ }
130
+ try {
131
+ const webhook = repo.createWebhook(parsed.data);
132
+ res.status(201).json(webhook);
133
+ }
134
+ catch (err) {
135
+ // SQLite UNIQUE constraint error
136
+ if (err.message?.includes('UNIQUE constraint failed: webhooks.name')) {
137
+ return res.status(409).json({ error: `A webhook with name "${parsed.data.name}" already exists` });
138
+ }
139
+ res.status(500).json({ error: err.message });
140
+ }
141
+ });
142
+ // GET /api/webhooks/:id — get one webhook
143
+ router.get('/:id', (req, res) => {
144
+ const webhook = repo.getWebhookById(req.params.id);
145
+ if (!webhook)
146
+ return res.status(404).json({ error: 'Webhook not found' });
147
+ res.json(webhook);
148
+ });
149
+ // PUT /api/webhooks/:id — update a webhook
150
+ router.put('/:id', (req, res) => {
151
+ const parsed = UpdateWebhookSchema.safeParse(req.body);
152
+ if (!parsed.success) {
153
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
154
+ }
155
+ try {
156
+ const updated = repo.updateWebhook(req.params.id, parsed.data);
157
+ if (!updated)
158
+ return res.status(404).json({ error: 'Webhook not found' });
159
+ res.json(updated);
160
+ }
161
+ catch (err) {
162
+ if (err.message?.includes('UNIQUE constraint failed: webhooks.name')) {
163
+ return res.status(409).json({ error: `A webhook with that name already exists` });
164
+ }
165
+ res.status(500).json({ error: err.message });
166
+ }
167
+ });
168
+ // DELETE /api/webhooks/:id — delete a webhook
169
+ router.delete('/:id', (req, res) => {
170
+ try {
171
+ const deleted = repo.deleteWebhook(req.params.id);
172
+ if (!deleted)
173
+ return res.status(404).json({ error: 'Webhook not found' });
174
+ res.json({ success: true });
175
+ }
176
+ catch (err) {
177
+ res.status(500).json({ error: err.message });
178
+ }
179
+ });
180
+ return router;
181
+ }
@@ -0,0 +1,95 @@
1
+ import { WebhookRepository } from './repository.js';
2
+ import { DisplayManager } from '../display.js';
3
+ export class WebhookDispatcher {
4
+ static telegramAdapter = null;
5
+ static oracle = null;
6
+ display = DisplayManager.getInstance();
7
+ /**
8
+ * Called at boot time after TelegramAdapter.connect() succeeds,
9
+ * so Telegram notifications can be dispatched from any trigger.
10
+ */
11
+ static setTelegramAdapter(adapter) {
12
+ WebhookDispatcher.telegramAdapter = adapter;
13
+ }
14
+ /**
15
+ * Called at boot time with the Oracle instance so webhooks can use
16
+ * the full Oracle (MCPs, apoc_delegate, memory, etc.).
17
+ */
18
+ static setOracle(oracle) {
19
+ WebhookDispatcher.oracle = oracle;
20
+ }
21
+ /**
22
+ * Main orchestration method — runs in background (fire-and-forget).
23
+ * 1. Builds the agent prompt from webhook.prompt + payload
24
+ * 2. Sends to Oracle (which can use MCPs or delegate to Apoc)
25
+ * 3. Persists result to DB
26
+ * 4. Dispatches to configured channels
27
+ */
28
+ async dispatch(webhook, payload, notificationId) {
29
+ const repo = WebhookRepository.getInstance();
30
+ const oracle = WebhookDispatcher.oracle;
31
+ if (!oracle) {
32
+ const errMsg = 'Oracle not available — webhook cannot be processed.';
33
+ this.display.log(errMsg, { source: 'Webhooks', level: 'error' });
34
+ repo.updateNotificationResult(notificationId, 'failed', errMsg);
35
+ return;
36
+ }
37
+ const message = this.buildPrompt(webhook.prompt, payload);
38
+ let result;
39
+ let status;
40
+ try {
41
+ result = await oracle.chat(message);
42
+ status = 'completed';
43
+ this.display.log(`Webhook "${webhook.name}" completed (notification: ${notificationId})`, { source: 'Webhooks', level: 'success' });
44
+ }
45
+ catch (err) {
46
+ result = `Execution error: ${err.message}`;
47
+ status = 'failed';
48
+ this.display.log(`Webhook "${webhook.name}" failed: ${err.message}`, { source: 'Webhooks', level: 'error' });
49
+ }
50
+ // Persist result
51
+ repo.updateNotificationResult(notificationId, status, result);
52
+ // Dispatch to configured channels
53
+ for (const channel of webhook.notification_channels) {
54
+ if (channel === 'telegram') {
55
+ await this.sendTelegram(webhook.name, result, status);
56
+ }
57
+ // 'ui' channel is handled by UI polling — nothing extra needed here
58
+ }
59
+ }
60
+ /**
61
+ * Combines the user-authored webhook prompt with the received payload.
62
+ */
63
+ buildPrompt(webhookPrompt, payload) {
64
+ const payloadStr = JSON.stringify(payload, null, 2);
65
+ return `${webhookPrompt}
66
+
67
+ ---
68
+ RECEIVED WEBHOOK PAYLOAD:
69
+ \`\`\`json
70
+ ${payloadStr}
71
+ \`\`\`
72
+
73
+ Analyze the payload above and follow the instructions provided. Be concise and actionable in your response.`;
74
+ }
75
+ /**
76
+ * Sends a formatted Telegram message to all allowed users.
77
+ * Silently skips if the adapter is not connected.
78
+ */
79
+ async sendTelegram(webhookName, result, status) {
80
+ const adapter = WebhookDispatcher.telegramAdapter;
81
+ if (!adapter) {
82
+ this.display.log('Telegram notification skipped — adapter not connected.', { source: 'Webhooks', level: 'warning' });
83
+ return;
84
+ }
85
+ try {
86
+ const icon = status === 'completed' ? '✅' : '❌';
87
+ const truncated = result.length > 3500 ? result.slice(0, 3500) + '…' : result;
88
+ const message = `${icon} *Webhook: ${webhookName}*\n\n${truncated}`;
89
+ await adapter.sendMessage(message);
90
+ }
91
+ catch (err) {
92
+ this.display.log(`Failed to send Telegram notification for webhook "${webhookName}": ${err.message}`, { source: 'Webhooks', level: 'error' });
93
+ }
94
+ }
95
+ }