rufloui 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/'1' +0 -0
  2. package/.env.example +46 -0
  3. package/CHANGELOG.md +87 -0
  4. package/CLAUDE.md +287 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/Webhooks) +0 -0
  8. package/docs/plans/2026-03-11-github-webhooks.md +957 -0
  9. package/docs/screenshot-swarm-monitor.png +0 -0
  10. package/frontend +0 -0
  11. package/index.html +13 -0
  12. package/package.json +56 -0
  13. package/public/vite.svg +4 -0
  14. package/src/backend/__tests__/webhook-github.test.ts +934 -0
  15. package/src/backend/jsonl-monitor.ts +430 -0
  16. package/src/backend/server.ts +2972 -0
  17. package/src/backend/telegram-bot.ts +511 -0
  18. package/src/backend/webhook-github.ts +350 -0
  19. package/src/frontend/App.tsx +461 -0
  20. package/src/frontend/api.ts +281 -0
  21. package/src/frontend/components/ErrorBoundary.tsx +98 -0
  22. package/src/frontend/components/Layout.tsx +431 -0
  23. package/src/frontend/components/ui/Button.tsx +111 -0
  24. package/src/frontend/components/ui/Card.tsx +51 -0
  25. package/src/frontend/components/ui/StatusBadge.tsx +60 -0
  26. package/src/frontend/main.tsx +63 -0
  27. package/src/frontend/pages/AgentVizPanel.tsx +428 -0
  28. package/src/frontend/pages/AgentsPanel.tsx +445 -0
  29. package/src/frontend/pages/ConfigPanel.tsx +661 -0
  30. package/src/frontend/pages/Dashboard.tsx +482 -0
  31. package/src/frontend/pages/HiveMindPanel.tsx +355 -0
  32. package/src/frontend/pages/HooksPanel.tsx +240 -0
  33. package/src/frontend/pages/LogsPanel.tsx +261 -0
  34. package/src/frontend/pages/MemoryPanel.tsx +444 -0
  35. package/src/frontend/pages/NeuralPanel.tsx +301 -0
  36. package/src/frontend/pages/PerformancePanel.tsx +198 -0
  37. package/src/frontend/pages/SessionsPanel.tsx +428 -0
  38. package/src/frontend/pages/SetupWizard.tsx +181 -0
  39. package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
  40. package/src/frontend/pages/SwarmPanel.tsx +322 -0
  41. package/src/frontend/pages/TasksPanel.tsx +535 -0
  42. package/src/frontend/pages/WebhooksPanel.tsx +335 -0
  43. package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
  44. package/src/frontend/store.ts +185 -0
  45. package/src/frontend/styles/global.css +113 -0
  46. package/src/frontend/test-setup.ts +1 -0
  47. package/src/frontend/tour/TourContext.tsx +161 -0
  48. package/src/frontend/tour/tourSteps.ts +181 -0
  49. package/src/frontend/tour/tourStyles.css +116 -0
  50. package/src/frontend/types.ts +239 -0
  51. package/src/frontend/utils/formatTime.test.ts +83 -0
  52. package/src/frontend/utils/formatTime.ts +23 -0
  53. package/tsconfig.json +23 -0
  54. package/vite.config.ts +26 -0
  55. package/vitest.config.ts +17 -0
  56. package/{,+ +0 -0
@@ -0,0 +1,511 @@
1
+ import TelegramBot from 'node-telegram-bot-api'
2
+
3
+ // Local interface copies — avoids coupling to server.ts exports
4
+ interface TaskRecord {
5
+ id: string; title: string; description: string; status: string
6
+ priority: string; assignedTo?: string; createdAt: string; startedAt?: string
7
+ completedAt?: string; result?: string
8
+ }
9
+ interface WorkflowRecord {
10
+ id: string; name: string; template: string; status: string
11
+ taskId?: string; createdAt: string; completedAt?: string; result?: string
12
+ steps: { id: string; name: string; status: string; agent?: string; detail?: string }[]
13
+ }
14
+ interface AgentActivity {
15
+ status: 'idle' | 'working' | 'error'
16
+ currentTask?: string; currentAction?: string; lastUpdate: string
17
+ tasksCompleted: number; errors: number
18
+ }
19
+
20
+ export interface TelegramStores {
21
+ taskStore: Map<string, TaskRecord>
22
+ workflowStore: Map<string, WorkflowRecord>
23
+ agentRegistry: Map<string, { id: string; name: string; type: string }>
24
+ terminatedAgents: Set<string>
25
+ agentActivity: Map<string, AgentActivity>
26
+ getSwarmStatus: () => { id: string; topology: string; status: string; activeAgents: number }
27
+ getSystemHealth: () => Promise<{ status: string; passed: number; warnings: number }>
28
+ createAndAssignTask: (title: string, description: string) => Promise<{ taskId: string; assigned: boolean }>
29
+ cancelTask: (taskId: string) => Promise<{ ok: boolean; error?: string }>
30
+ addLog: (direction: 'in' | 'out', message: string) => void
31
+ }
32
+
33
+ export interface TelegramNotifications {
34
+ taskCompleted: boolean
35
+ taskFailed: boolean
36
+ swarmInit: boolean
37
+ swarmShutdown: boolean
38
+ agentError: boolean
39
+ taskProgress: boolean
40
+ }
41
+
42
+ export interface TelegramConfig {
43
+ enabled: boolean
44
+ token: string
45
+ chatId: string
46
+ notifications: TelegramNotifications
47
+ }
48
+
49
+ export interface TelegramHandle {
50
+ onBroadcast: (type: string, payload: unknown) => void
51
+ stop: () => Promise<void>
52
+ getStatus: () => { enabled: boolean; connected: boolean; botUsername: string | null }
53
+ sendTest: () => Promise<{ ok: boolean; error?: string }>
54
+ }
55
+
56
+ const PREFIX = '[telegram]'
57
+
58
+ /** Escape HTML special chars for Telegram HTML parse mode */
59
+ function h(text: string): string {
60
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
61
+ }
62
+
63
+ function truncate(text: string, max: number): string {
64
+ if (text.length <= max) return text
65
+ return text.slice(0, max - 3) + '...'
66
+ }
67
+
68
+ const HTML = { parse_mode: 'HTML' as const }
69
+
70
+ export function initTelegramBot(config: TelegramConfig, stores: TelegramStores): TelegramHandle | null {
71
+ if (!config.enabled) {
72
+ console.log(`${PREFIX} Bot disabled`)
73
+ return null
74
+ }
75
+
76
+ if (!config.token || !config.chatId) {
77
+ console.warn(`${PREFIX} Enabled but token or chatId missing`)
78
+ return null
79
+ }
80
+
81
+ let bot: TelegramBot
82
+ try {
83
+ bot = new TelegramBot(config.token, { polling: true })
84
+ } catch (err) {
85
+ console.error(`${PREFIX} Failed to create bot:`, err instanceof Error ? err.message : String(err))
86
+ return null
87
+ }
88
+
89
+ let botUsername: string | null = null
90
+ let connected = false
91
+
92
+ bot.getMe().then(me => {
93
+ botUsername = me.username || null
94
+ connected = true
95
+ console.log(`${PREFIX} Bot connected as @${me.username}`)
96
+ }).catch(err => {
97
+ connected = false
98
+ console.error(`${PREFIX} Bot connection failed:`, err instanceof Error ? err.message : String(err))
99
+ })
100
+
101
+ // ── AUTO-RECONNECT ──────────────────────────────────────────────────
102
+
103
+ let reconnectAttempts = 0
104
+ let reconnecting = false
105
+ const MAX_RECONNECT = 5
106
+
107
+ bot.on('polling_error', (err) => {
108
+ console.error(`${PREFIX} Polling error:`, err.message)
109
+ connected = false
110
+ if (reconnecting) return // prevent stacking timeouts
111
+ reconnectAttempts++
112
+ if (reconnectAttempts <= MAX_RECONNECT) {
113
+ reconnecting = true
114
+ const delay = Math.min(reconnectAttempts * 5000, 30000)
115
+ console.log(`${PREFIX} Reconnecting in ${delay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT})`)
116
+ setTimeout(() => {
117
+ reconnecting = false
118
+ bot.stopPolling().then(() => {
119
+ bot.startPolling()
120
+ console.log(`${PREFIX} Polling restarted`)
121
+ }).catch(() => { /* ignore */ })
122
+ }, delay)
123
+ } else {
124
+ console.error(`${PREFIX} Max reconnect attempts reached, stopping polling`)
125
+ bot.stopPolling().catch(() => { /* ignore */ })
126
+ }
127
+ })
128
+
129
+ // Reset reconnect counter on successful message receipt
130
+ bot.on('message', () => { reconnectAttempts = 0; connected = true })
131
+
132
+ const chatId = config.chatId
133
+
134
+ // Helper: send with HTML and log errors
135
+ async function reply(chatIdTo: number | string, text: string, keyboard?: TelegramBot.InlineKeyboardButton[][]) {
136
+ const opts: TelegramBot.SendMessageOptions = { parse_mode: 'HTML' }
137
+ if (keyboard) opts.reply_markup = { inline_keyboard: keyboard }
138
+ try {
139
+ await bot.sendMessage(chatIdTo, text, opts)
140
+ stores.addLog('out', text.replace(/<[^>]+>/g, '').slice(0, 100))
141
+ } catch (err) {
142
+ console.error(`${PREFIX} Reply failed:`, err instanceof Error ? err.message : String(err))
143
+ // Fallback: try plain text
144
+ try { await bot.sendMessage(chatIdTo, text.replace(/<[^>]+>/g, '')) } catch { /* give up */ }
145
+ }
146
+ }
147
+
148
+ // ── AUTHORIZATION ───────────────────────────────────────────────────
149
+
150
+ function isAuthorized(msg: TelegramBot.Message): boolean {
151
+ if (String(msg.chat.id) === chatId) return true
152
+ console.warn(`${PREFIX} Unauthorized message from chat ${msg.chat.id}`)
153
+ return false
154
+ }
155
+
156
+ function isAuthorizedChat(chatIdToCheck: number | string): boolean {
157
+ return String(chatIdToCheck) === chatId
158
+ }
159
+
160
+ // /start — open to ALL users so they can discover their chat ID
161
+ bot.onText(/\/start(@\w+)?$/, (msg) => {
162
+ stores.addLog('in', msg.text || '/start')
163
+ reply(
164
+ msg.chat.id,
165
+ `Your chat ID is: <code>${msg.chat.id}</code>\nTo configure this bot, enter this ID in the RuFloUI dashboard under Config &gt; Telegram Bot.`
166
+ )
167
+ })
168
+
169
+ // ── EXTRACTED COMMAND HANDLERS ──────────────────────────────────────
170
+
171
+ async function handleStatus(chatIdTo: number | string) {
172
+ try {
173
+ const swarm = stores.getSwarmStatus()
174
+ const health = await stores.getSystemHealth()
175
+ const agents = [...stores.agentRegistry.entries()]
176
+ .filter(([key]) => !stores.terminatedAgents.has(key))
177
+ const tasks = [...stores.taskStore.values()]
178
+ const pending = tasks.filter(t => t.status === 'pending').length
179
+ const inProgress = tasks.filter(t => t.status === 'in_progress').length
180
+ const completed = tasks.filter(t => t.status === 'completed').length
181
+
182
+ const lines = [
183
+ '<b>System Status</b>',
184
+ `Health: ${h(health.status)} (${health.passed} passed, ${health.warnings} warnings)`,
185
+ `Swarm: ${h(swarm.status)} | ${h(swarm.topology)} | ${swarm.activeAgents} agents`,
186
+ `Agents: ${agents.length} active`,
187
+ `Tasks: ${pending} pending, ${inProgress} running, ${completed} done`,
188
+ ]
189
+ const keyboard: TelegramBot.InlineKeyboardButton[][] = [[
190
+ { text: 'Agents', callback_data: 'cmd:agents' },
191
+ { text: 'Tasks', callback_data: 'cmd:tasks' },
192
+ { text: 'Swarm', callback_data: 'cmd:swarm' },
193
+ ]]
194
+ reply(chatIdTo, lines.join('\n'), keyboard)
195
+ } catch (err) {
196
+ reply(chatIdTo, `Error: ${h(err instanceof Error ? err.message : String(err))}`)
197
+ }
198
+ }
199
+
200
+ function handleAgents(chatIdTo: number | string) {
201
+ const agents = [...stores.agentRegistry.entries()]
202
+ .filter(([key]) => !stores.terminatedAgents.has(key))
203
+ if (agents.length === 0) {
204
+ reply(chatIdTo, 'No active agents.')
205
+ return
206
+ }
207
+ const lines = agents.map(([, a]) => {
208
+ const activity = stores.agentActivity.get(a.id)
209
+ const status = activity?.status ?? 'unknown'
210
+ return `- <b>${h(a.name || a.id)}</b> (${h(a.type)}) - ${h(status)}`
211
+ })
212
+ const keyboard: TelegramBot.InlineKeyboardButton[][] = [[
213
+ { text: 'Refresh', callback_data: 'cmd:agents' },
214
+ ]]
215
+ reply(chatIdTo, `<b>Active Agents (${agents.length})</b>\n${lines.join('\n')}`, keyboard)
216
+ }
217
+
218
+ function handleTasks(chatIdTo: number | string) {
219
+ const tasks = [...stores.taskStore.values()]
220
+ if (tasks.length === 0) {
221
+ reply(chatIdTo, 'No tasks.')
222
+ return
223
+ }
224
+ const groups: Record<string, TaskRecord[]> = {}
225
+ for (const t of tasks) {
226
+ const s = t.status || 'unknown'
227
+ if (!groups[s]) groups[s] = []
228
+ groups[s].push(t)
229
+ }
230
+ const lines: string[] = ['<b>Tasks</b>']
231
+ for (const [status, items] of Object.entries(groups)) {
232
+ lines.push(`\n<i>${h(status)}</i> (${items.length}):`)
233
+ for (const t of items.slice(0, 10)) {
234
+ lines.push(` - <code>${h(t.id)}</code> ${h(truncate(t.title, 50))}`)
235
+ }
236
+ if (items.length > 10) lines.push(` ... and ${items.length - 10} more`)
237
+ }
238
+ const keyboard: TelegramBot.InlineKeyboardButton[][] = [[
239
+ { text: 'Refresh', callback_data: 'cmd:tasks' },
240
+ ]]
241
+ reply(chatIdTo, lines.join('\n'), keyboard)
242
+ }
243
+
244
+ function handleTask(chatIdTo: number | string, id: string) {
245
+ const task = stores.taskStore.get(id)
246
+ if (!task) { reply(chatIdTo, `Task not found: <code>${h(id)}</code>`); return }
247
+ const lines = [
248
+ `<b>Task: ${h(truncate(task.title, 60))}</b>`,
249
+ `ID: <code>${h(task.id)}</code>`,
250
+ `Status: ${h(task.status)}`,
251
+ `Priority: ${h(task.priority)}`,
252
+ `Created: ${h(task.createdAt)}`,
253
+ ]
254
+ if (task.assignedTo) lines.push(`Assigned: ${h(task.assignedTo)}`)
255
+ if (task.startedAt) lines.push(`Started: ${h(task.startedAt)}`)
256
+ if (task.completedAt) lines.push(`Completed: ${h(task.completedAt)}`)
257
+ if (task.result) lines.push(`\nResult: ${h(truncate(task.result, 200))}`)
258
+ if (task.description) lines.push(`\nDescription: ${h(truncate(task.description, 200))}`)
259
+ const keyboard: TelegramBot.InlineKeyboardButton[][] = []
260
+ if (task.status === 'pending' || task.status === 'in_progress') {
261
+ keyboard.push([{ text: 'Cancel', callback_data: `cancel:${task.id}` }])
262
+ }
263
+ reply(chatIdTo, lines.join('\n'), keyboard.length > 0 ? keyboard : undefined)
264
+ }
265
+
266
+ function handleSwarm(chatIdTo: number | string) {
267
+ const swarm = stores.getSwarmStatus()
268
+ const lines = [
269
+ '<b>Swarm Status</b>',
270
+ `Status: ${h(swarm.status)}`,
271
+ `Topology: ${h(swarm.topology)}`,
272
+ `Active Agents: ${swarm.activeAgents}`,
273
+ ]
274
+ if (swarm.id) lines.push(`ID: <code>${h(swarm.id)}</code>`)
275
+ reply(chatIdTo, lines.join('\n'))
276
+ }
277
+
278
+ // ── COMMAND LISTENERS ──────────────────────────────────────────────
279
+
280
+ bot.onText(/\/help(@\w+)?$/, (msg) => {
281
+ if (!isAuthorized(msg)) return
282
+ stores.addLog('in', msg.text || '/help')
283
+ const text = [
284
+ '<b>RuFloUI Bot Commands</b>',
285
+ '',
286
+ '/status - System health + swarm + counts',
287
+ '/agents - List active agents',
288
+ '/tasks - Tasks grouped by status',
289
+ '/task &lt;id&gt; - Detail for one task',
290
+ '/workflows - List workflows',
291
+ '/swarm - Swarm topology &amp; status',
292
+ '/run &lt;description&gt; - Create &amp; assign a task',
293
+ '/cancel &lt;id&gt; - Cancel a running task',
294
+ '/help - This message',
295
+ ].join('\n')
296
+ reply(msg.chat.id, text)
297
+ })
298
+
299
+ bot.onText(/\/status(@\w+)?$/, async (msg) => {
300
+ if (!isAuthorized(msg)) return
301
+ stores.addLog('in', msg.text || '/status')
302
+ handleStatus(msg.chat.id)
303
+ })
304
+
305
+ bot.onText(/\/agents(@\w+)?$/, (msg) => {
306
+ if (!isAuthorized(msg)) return
307
+ stores.addLog('in', msg.text || '/agents')
308
+ handleAgents(msg.chat.id)
309
+ })
310
+
311
+ bot.onText(/\/tasks(@\w+)?$/, (msg) => {
312
+ if (!isAuthorized(msg)) return
313
+ stores.addLog('in', msg.text || '/tasks')
314
+ handleTasks(msg.chat.id)
315
+ })
316
+
317
+ bot.onText(/\/task (.+)/, (msg, match) => {
318
+ if (!isAuthorized(msg)) return
319
+ stores.addLog('in', msg.text || '/task')
320
+ const id = match?.[1]?.trim()
321
+ if (!id) { reply(msg.chat.id, 'Usage: /task &lt;id&gt;'); return }
322
+ handleTask(msg.chat.id, id)
323
+ })
324
+
325
+ bot.onText(/\/workflows(@\w+)?$/, (msg) => {
326
+ if (!isAuthorized(msg)) return
327
+ stores.addLog('in', msg.text || '/workflows')
328
+ const workflows = [...stores.workflowStore.values()]
329
+ if (workflows.length === 0) {
330
+ reply(msg.chat.id, 'No workflows.')
331
+ return
332
+ }
333
+ const lines = workflows.map(w =>
334
+ `- <b>${h(w.name)}</b> (${h(w.status)}) - ${w.steps.length} steps`
335
+ )
336
+ reply(msg.chat.id, `<b>Workflows (${workflows.length})</b>\n${lines.join('\n')}`)
337
+ })
338
+
339
+ bot.onText(/\/swarm(@\w+)?$/, (msg) => {
340
+ if (!isAuthorized(msg)) return
341
+ stores.addLog('in', msg.text || '/swarm')
342
+ handleSwarm(msg.chat.id)
343
+ })
344
+
345
+ bot.onText(/\/run (.+)/, async (msg, match) => {
346
+ if (!isAuthorized(msg)) return
347
+ stores.addLog('in', msg.text || '/run')
348
+ const description = match?.[1]?.trim()
349
+ if (!description) { reply(msg.chat.id, 'Usage: /run &lt;description&gt;'); return }
350
+ try {
351
+ await reply(msg.chat.id, `Creating task: ${h(truncate(description, 100))}`)
352
+ const result = await stores.createAndAssignTask(description, description)
353
+ const assignedText = result.assigned ? 'assigned to swarm' : 'created (no active swarm)'
354
+ reply(msg.chat.id, `Task <code>${h(result.taskId)}</code> ${assignedText}`)
355
+ } catch (err) {
356
+ reply(msg.chat.id, `Failed to create task: ${h(err instanceof Error ? err.message : String(err))}`)
357
+ }
358
+ })
359
+
360
+ bot.onText(/\/cancel (.+)/, async (msg, match) => {
361
+ if (!isAuthorized(msg)) return
362
+ stores.addLog('in', msg.text || '/cancel')
363
+ const taskId = match?.[1]?.trim()
364
+ if (!taskId) { reply(msg.chat.id, 'Usage: /cancel &lt;id&gt;'); return }
365
+ try {
366
+ const result = await stores.cancelTask(taskId)
367
+ if (result.ok) {
368
+ reply(msg.chat.id, `Task <code>${h(taskId)}</code> cancelled.`)
369
+ } else {
370
+ reply(msg.chat.id, `Could not cancel: ${h(result.error || 'unknown error')}`)
371
+ }
372
+ } catch (err) {
373
+ reply(msg.chat.id, `Cancel failed: ${h(err instanceof Error ? err.message : String(err))}`)
374
+ }
375
+ })
376
+
377
+ // ── CALLBACK QUERY HANDLER ──────────────────────────────────────────
378
+
379
+ bot.on('callback_query', async (query) => {
380
+ const data = query.data || ''
381
+ const chatIdQ = query.message?.chat.id
382
+ if (!chatIdQ) return
383
+
384
+ // Authorization check for callback queries
385
+ if (!isAuthorizedChat(chatIdQ)) {
386
+ await bot.answerCallbackQuery(query.id, { text: 'Unauthorized' })
387
+ return
388
+ }
389
+
390
+ // Acknowledge the callback to remove loading spinner
391
+ await bot.answerCallbackQuery(query.id)
392
+
393
+ if (data.startsWith('cmd:')) {
394
+ const cmd = data.slice(4)
395
+ switch (cmd) {
396
+ case 'status': handleStatus(chatIdQ); break
397
+ case 'agents': handleAgents(chatIdQ); break
398
+ case 'tasks': handleTasks(chatIdQ); break
399
+ case 'swarm': handleSwarm(chatIdQ); break
400
+ default: reply(chatIdQ, `Unknown command: ${h(cmd)}`)
401
+ }
402
+ }
403
+
404
+ if (data.startsWith('cancel:')) {
405
+ const taskId = data.slice(7)
406
+ const result = await stores.cancelTask(taskId)
407
+ if (result.ok) {
408
+ reply(chatIdQ, `Task <code>${h(taskId)}</code> cancelled.`)
409
+ } else {
410
+ reply(chatIdQ, `Could not cancel task <code>${h(taskId)}</code>: ${h(result.error || 'unknown error')}`)
411
+ }
412
+ }
413
+ })
414
+
415
+ // ── NOTIFICATIONS (broadcast hook) ───────────────────────────────────
416
+
417
+ const notif = config.notifications
418
+ const progressThrottle = new Map<string, number>() // taskId -> last notify timestamp
419
+ const PROGRESS_INTERVAL = 30_000 // 30s between progress updates per task
420
+
421
+ function onBroadcast(type: string, payload: unknown) {
422
+ try {
423
+ const p = payload as Record<string, unknown>
424
+
425
+ if (type === 'task:updated') {
426
+ const status = String(p?.status ?? '')
427
+ const title = String(p?.title ?? p?.id ?? 'Unknown')
428
+ const taskId = String(p?.id ?? '')
429
+ // Clean up progress throttle for terminal states
430
+ if (status === 'completed' || status === 'failed' || status === 'cancelled') {
431
+ progressThrottle.delete(taskId)
432
+ }
433
+ if (status === 'completed' && notif.taskCompleted) {
434
+ const result = truncate(String(p?.result ?? 'No result'), 200)
435
+ send(`Task completed: <b>${h(title)}</b>\n${h(result)}`)
436
+ } else if (status === 'failed' && notif.taskFailed) {
437
+ const result = truncate(String(p?.result ?? 'No details'), 200)
438
+ send(`Task failed: <b>${h(title)}</b>\n${h(result)}`)
439
+ }
440
+ }
441
+
442
+ if (type === 'task:output' && notif.taskProgress) {
443
+ const pType = String(p?.type ?? '')
444
+ if (pType === 'progress') {
445
+ const taskId = String(p?.id ?? '')
446
+ const now = Date.now()
447
+ const last = progressThrottle.get(taskId) || 0
448
+ if (now - last >= PROGRESS_INTERVAL) {
449
+ progressThrottle.set(taskId, now)
450
+ const content = truncate(String(p?.content ?? ''), 150)
451
+ send(`Task <code>${h(taskId)}</code>: ${h(content)}`)
452
+ }
453
+ }
454
+ }
455
+
456
+ if (type === 'swarm:status') {
457
+ const status = String(p?.status ?? '')
458
+ if (status === 'active' && notif.swarmInit) {
459
+ const topology = String(p?.topology ?? 'unknown')
460
+ const agents = Number(p?.activeAgents ?? p?.agentCount ?? 0)
461
+ send(`Swarm initialized: ${h(topology)} topology, ${agents} agents`)
462
+ } else if (status === 'shutdown' && notif.swarmShutdown) {
463
+ send('Swarm shut down')
464
+ }
465
+ }
466
+
467
+ if (type === 'agent:activity' && notif.agentError) {
468
+ const status = String(p?.status ?? '')
469
+ if (status === 'error') {
470
+ const agentId = String(p?.agentId ?? p?.id ?? 'unknown')
471
+ send(`Agent error: ${h(agentId)}`)
472
+ }
473
+ }
474
+ } catch {
475
+ // Fire-and-forget — never crash the broadcast path
476
+ }
477
+ }
478
+
479
+ function send(text: string) {
480
+ stores.addLog('out', text.replace(/<[^>]+>/g, '').slice(0, 100))
481
+ bot.sendMessage(chatId, text, HTML).catch(err => {
482
+ console.error(`${PREFIX} Send failed:`, err instanceof Error ? err.message : String(err))
483
+ })
484
+ }
485
+
486
+ async function stop() {
487
+ try {
488
+ await bot.stopPolling()
489
+ console.log(`${PREFIX} Bot stopped`)
490
+ } catch (err) {
491
+ console.error(`${PREFIX} Stop failed:`, err instanceof Error ? err.message : String(err))
492
+ }
493
+ }
494
+
495
+ function getStatus() {
496
+ return { enabled: true, connected, botUsername }
497
+ }
498
+
499
+ async function sendTest(): Promise<{ ok: boolean; error?: string }> {
500
+ try {
501
+ const ts = new Date().toLocaleString()
502
+ await bot.sendMessage(chatId, `RuFloUI test message - ${h(ts)}`, HTML)
503
+ return { ok: true }
504
+ } catch (err) {
505
+ const msg = err instanceof Error ? err.message : String(err)
506
+ return { ok: false, error: msg }
507
+ }
508
+ }
509
+
510
+ return { onBroadcast, stop, getStatus, sendTest }
511
+ }