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.
- package/'1' +0 -0
- package/.env.example +46 -0
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +287 -0
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/Webhooks) +0 -0
- package/docs/plans/2026-03-11-github-webhooks.md +957 -0
- package/docs/screenshot-swarm-monitor.png +0 -0
- package/frontend +0 -0
- package/index.html +13 -0
- package/package.json +56 -0
- package/public/vite.svg +4 -0
- package/src/backend/__tests__/webhook-github.test.ts +934 -0
- package/src/backend/jsonl-monitor.ts +430 -0
- package/src/backend/server.ts +2972 -0
- package/src/backend/telegram-bot.ts +511 -0
- package/src/backend/webhook-github.ts +350 -0
- package/src/frontend/App.tsx +461 -0
- package/src/frontend/api.ts +281 -0
- package/src/frontend/components/ErrorBoundary.tsx +98 -0
- package/src/frontend/components/Layout.tsx +431 -0
- package/src/frontend/components/ui/Button.tsx +111 -0
- package/src/frontend/components/ui/Card.tsx +51 -0
- package/src/frontend/components/ui/StatusBadge.tsx +60 -0
- package/src/frontend/main.tsx +63 -0
- package/src/frontend/pages/AgentVizPanel.tsx +428 -0
- package/src/frontend/pages/AgentsPanel.tsx +445 -0
- package/src/frontend/pages/ConfigPanel.tsx +661 -0
- package/src/frontend/pages/Dashboard.tsx +482 -0
- package/src/frontend/pages/HiveMindPanel.tsx +355 -0
- package/src/frontend/pages/HooksPanel.tsx +240 -0
- package/src/frontend/pages/LogsPanel.tsx +261 -0
- package/src/frontend/pages/MemoryPanel.tsx +444 -0
- package/src/frontend/pages/NeuralPanel.tsx +301 -0
- package/src/frontend/pages/PerformancePanel.tsx +198 -0
- package/src/frontend/pages/SessionsPanel.tsx +428 -0
- package/src/frontend/pages/SetupWizard.tsx +181 -0
- package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
- package/src/frontend/pages/SwarmPanel.tsx +322 -0
- package/src/frontend/pages/TasksPanel.tsx +535 -0
- package/src/frontend/pages/WebhooksPanel.tsx +335 -0
- package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
- package/src/frontend/store.ts +185 -0
- package/src/frontend/styles/global.css +113 -0
- package/src/frontend/test-setup.ts +1 -0
- package/src/frontend/tour/TourContext.tsx +161 -0
- package/src/frontend/tour/tourSteps.ts +181 -0
- package/src/frontend/tour/tourStyles.css +116 -0
- package/src/frontend/types.ts +239 -0
- package/src/frontend/utils/formatTime.test.ts +83 -0
- package/src/frontend/utils/formatTime.ts +23 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +17 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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 > 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 <id> - Detail for one task',
|
|
290
|
+
'/workflows - List workflows',
|
|
291
|
+
'/swarm - Swarm topology & status',
|
|
292
|
+
'/run <description> - Create & assign a task',
|
|
293
|
+
'/cancel <id> - 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 <id>'); 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 <description>'); 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 <id>'); 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
|
+
}
|