loopwork 0.3.0 → 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/bin/loopwork +0 -0
- package/package.json +48 -4
- package/src/backends/github.ts +6 -3
- package/src/backends/json.ts +28 -10
- package/src/commands/run.ts +2 -2
- package/src/contracts/config.ts +3 -75
- package/src/contracts/index.ts +0 -6
- package/src/core/cli.ts +25 -16
- package/src/core/state.ts +10 -4
- package/src/core/utils.ts +10 -4
- package/src/monitor/index.ts +56 -34
- package/src/plugins/index.ts +9 -131
- package/examples/README.md +0 -70
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
- package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
- package/examples/basic-json-backend/README.md +0 -32
- package/examples/basic-json-backend/TESTING.md +0 -184
- package/examples/basic-json-backend/hello.test.ts +0 -9
- package/examples/basic-json-backend/hello.ts +0 -3
- package/examples/basic-json-backend/loopwork.config.js +0 -35
- package/examples/basic-json-backend/math.test.ts +0 -29
- package/examples/basic-json-backend/math.ts +0 -3
- package/examples/basic-json-backend/package.json +0 -15
- package/examples/basic-json-backend/quick-start.sh +0 -80
- package/loopwork.config.ts +0 -164
- package/src/plugins/asana.ts +0 -192
- package/src/plugins/cost-tracking.ts +0 -402
- package/src/plugins/discord.ts +0 -269
- package/src/plugins/everhour.ts +0 -335
- package/src/plugins/telegram/bot.ts +0 -517
- package/src/plugins/telegram/index.ts +0 -6
- package/src/plugins/telegram/notifications.ts +0 -198
- package/src/plugins/todoist.ts +0 -261
- package/test/backends.test.ts +0 -929
- package/test/cli.test.ts +0 -145
- package/test/config.test.ts +0 -90
- package/test/e2e.test.ts +0 -458
- package/test/github-tasks.test.ts +0 -191
- package/test/loopwork-config-types.test.ts +0 -288
- package/test/monitor.test.ts +0 -123
- package/test/plugins.test.ts +0 -1175
- package/test/state.test.ts +0 -295
- package/test/utils.test.ts +0 -60
- package/tsconfig.json +0 -20
|
@@ -1,517 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram Bot for Loopwork Task Management
|
|
3
|
-
*
|
|
4
|
-
* Provides interactive task management through Telegram commands.
|
|
5
|
-
*
|
|
6
|
-
* Commands:
|
|
7
|
-
* /tasks - List pending tasks
|
|
8
|
-
* /task <id> - Get task details
|
|
9
|
-
* /complete <id> - Mark task as completed
|
|
10
|
-
* /fail <id> <reason> - Mark task as failed
|
|
11
|
-
* /reset <id> - Reset task to pending
|
|
12
|
-
* /status - Get loop status
|
|
13
|
-
* /help - Show available commands
|
|
14
|
-
*
|
|
15
|
-
* Setup:
|
|
16
|
-
* 1. Create a bot with @BotFather
|
|
17
|
-
* 2. Set TELEGRAM_BOT_TOKEN env var
|
|
18
|
-
* 3. Set TELEGRAM_CHAT_ID env var (your chat ID)
|
|
19
|
-
* 4. Run: bun run src/telegram-bot.ts
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { createBackend, type TaskBackend, type Task } from '../../backends'
|
|
23
|
-
import type { BackendConfig } from '../../backends/types'
|
|
24
|
-
|
|
25
|
-
interface TelegramUpdate {
|
|
26
|
-
update_id: number
|
|
27
|
-
message?: {
|
|
28
|
-
message_id: number
|
|
29
|
-
from: { id: number; username?: string }
|
|
30
|
-
chat: { id: number }
|
|
31
|
-
text?: string
|
|
32
|
-
date: number
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
interface TelegramResponse {
|
|
37
|
-
ok: boolean
|
|
38
|
-
result?: TelegramUpdate[]
|
|
39
|
-
description?: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class TelegramTaskBot {
|
|
43
|
-
private botToken: string
|
|
44
|
-
private allowedChatId: string
|
|
45
|
-
private backend: TaskBackend
|
|
46
|
-
private lastUpdateId = 0
|
|
47
|
-
private running = false
|
|
48
|
-
|
|
49
|
-
constructor(config: {
|
|
50
|
-
botToken?: string
|
|
51
|
-
chatId?: string
|
|
52
|
-
backend?: BackendConfig
|
|
53
|
-
} = {}) {
|
|
54
|
-
this.botToken = config.botToken || process.env.TELEGRAM_BOT_TOKEN || ''
|
|
55
|
-
this.allowedChatId = config.chatId || process.env.TELEGRAM_CHAT_ID || ''
|
|
56
|
-
|
|
57
|
-
if (!this.botToken) {
|
|
58
|
-
throw new Error('TELEGRAM_BOT_TOKEN is required')
|
|
59
|
-
}
|
|
60
|
-
if (!this.allowedChatId) {
|
|
61
|
-
throw new Error('TELEGRAM_CHAT_ID is required')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Auto-detect backend
|
|
65
|
-
const backendConfig: BackendConfig = config.backend || {
|
|
66
|
-
type: 'json',
|
|
67
|
-
tasksFile: '.specs/tasks/tasks.json',
|
|
68
|
-
}
|
|
69
|
-
this.backend = createBackend(backendConfig)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Send a message to the configured chat
|
|
74
|
-
*/
|
|
75
|
-
async sendMessage(text: string, parseMode: 'HTML' | 'Markdown' = 'HTML'): Promise<boolean> {
|
|
76
|
-
try {
|
|
77
|
-
const url = `https://api.telegram.org/bot${this.botToken}/sendMessage`
|
|
78
|
-
const response = await fetch(url, {
|
|
79
|
-
method: 'POST',
|
|
80
|
-
headers: { 'Content-Type': 'application/json' },
|
|
81
|
-
body: JSON.stringify({
|
|
82
|
-
chat_id: this.allowedChatId,
|
|
83
|
-
text,
|
|
84
|
-
parse_mode: parseMode,
|
|
85
|
-
}),
|
|
86
|
-
})
|
|
87
|
-
return response.ok
|
|
88
|
-
} catch {
|
|
89
|
-
return false
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Get updates from Telegram
|
|
95
|
-
*/
|
|
96
|
-
private async getUpdates(): Promise<TelegramUpdate[]> {
|
|
97
|
-
try {
|
|
98
|
-
const url = `https://api.telegram.org/bot${this.botToken}/getUpdates?offset=${this.lastUpdateId + 1}&timeout=30`
|
|
99
|
-
const response = await fetch(url)
|
|
100
|
-
const data = await response.json() as TelegramResponse
|
|
101
|
-
|
|
102
|
-
if (data.ok && data.result) {
|
|
103
|
-
return data.result
|
|
104
|
-
}
|
|
105
|
-
return []
|
|
106
|
-
} catch {
|
|
107
|
-
return []
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Handle a command
|
|
113
|
-
*/
|
|
114
|
-
private async handleCommand(chatId: number, text: string): Promise<string> {
|
|
115
|
-
// Security check - only allow configured chat
|
|
116
|
-
if (String(chatId) !== this.allowedChatId) {
|
|
117
|
-
return '⛔ Unauthorized'
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const parts = text.trim().split(/\s+/)
|
|
121
|
-
const command = parts[0].toLowerCase()
|
|
122
|
-
|
|
123
|
-
switch (command) {
|
|
124
|
-
case '/tasks':
|
|
125
|
-
case '/list':
|
|
126
|
-
return this.handleListTasks()
|
|
127
|
-
|
|
128
|
-
case '/task':
|
|
129
|
-
return this.handleGetTask(parts[1])
|
|
130
|
-
|
|
131
|
-
case '/complete':
|
|
132
|
-
case '/done':
|
|
133
|
-
return this.handleCompleteTask(parts[1])
|
|
134
|
-
|
|
135
|
-
case '/fail':
|
|
136
|
-
return this.handleFailTask(parts[1], parts.slice(2).join(' '))
|
|
137
|
-
|
|
138
|
-
case '/reset':
|
|
139
|
-
return this.handleResetTask(parts[1])
|
|
140
|
-
|
|
141
|
-
case '/status':
|
|
142
|
-
return this.handleStatus()
|
|
143
|
-
|
|
144
|
-
case '/new':
|
|
145
|
-
case '/create':
|
|
146
|
-
return this.handleCreateTask(parts.slice(1).join(' '))
|
|
147
|
-
|
|
148
|
-
case '/subtask':
|
|
149
|
-
return this.handleCreateSubTask(parts[1], parts.slice(2).join(' '))
|
|
150
|
-
|
|
151
|
-
case '/priority':
|
|
152
|
-
return this.handleSetPriority(parts[1], parts[2] as 'high' | 'medium' | 'low')
|
|
153
|
-
|
|
154
|
-
case '/help':
|
|
155
|
-
case '/start':
|
|
156
|
-
return this.handleHelp()
|
|
157
|
-
|
|
158
|
-
default:
|
|
159
|
-
return `Unknown command: ${command}\nUse /help for available commands.`
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
private async handleListTasks(): Promise<string> {
|
|
164
|
-
const tasks = await this.backend.listPendingTasks()
|
|
165
|
-
|
|
166
|
-
if (tasks.length === 0) {
|
|
167
|
-
return '✅ No pending tasks!'
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const priorityEmoji: Record<string, string> = {
|
|
171
|
-
high: '🔴',
|
|
172
|
-
medium: '🟡',
|
|
173
|
-
low: '🟢',
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const lines = tasks.slice(0, 10).map(t => {
|
|
177
|
-
const emoji = priorityEmoji[t.priority] || '⚪'
|
|
178
|
-
const title = t.title.length > 40 ? t.title.slice(0, 37) + '...' : t.title
|
|
179
|
-
return `${emoji} <code>${t.id}</code>\n ${escapeHtml(title)}`
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
let response = `📋 <b>Pending Tasks (${tasks.length})</b>\n\n${lines.join('\n\n')}`
|
|
183
|
-
|
|
184
|
-
if (tasks.length > 10) {
|
|
185
|
-
response += `\n\n<i>...and ${tasks.length - 10} more</i>`
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return response
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
private async handleGetTask(taskId?: string): Promise<string> {
|
|
192
|
-
if (!taskId) {
|
|
193
|
-
return '❌ Usage: /task <task-id>'
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const task = await this.backend.getTask(taskId)
|
|
197
|
-
|
|
198
|
-
if (!task) {
|
|
199
|
-
return `❌ Task not found: ${taskId}`
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const statusEmoji: Record<string, string> = {
|
|
203
|
-
pending: '⏳',
|
|
204
|
-
'in-progress': '🔄',
|
|
205
|
-
completed: '✅',
|
|
206
|
-
failed: '❌',
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const lines = [
|
|
210
|
-
`${statusEmoji[task.status] || '❓'} <b>${escapeHtml(task.title)}</b>`,
|
|
211
|
-
'',
|
|
212
|
-
`<b>ID:</b> <code>${task.id}</code>`,
|
|
213
|
-
`<b>Status:</b> ${task.status}`,
|
|
214
|
-
`<b>Priority:</b> ${task.priority}`,
|
|
215
|
-
]
|
|
216
|
-
|
|
217
|
-
if (task.feature) {
|
|
218
|
-
lines.push(`<b>Feature:</b> ${escapeHtml(task.feature)}`)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (task.parentId) {
|
|
222
|
-
lines.push(`<b>Parent:</b> <code>${task.parentId}</code>`)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (task.dependsOn && task.dependsOn.length > 0) {
|
|
226
|
-
lines.push(`<b>Depends on:</b> ${task.dependsOn.map(d => `<code>${d}</code>`).join(', ')}`)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Show truncated description
|
|
230
|
-
if (task.description) {
|
|
231
|
-
const desc = task.description.length > 500
|
|
232
|
-
? task.description.slice(0, 497) + '...'
|
|
233
|
-
: task.description
|
|
234
|
-
lines.push('', '<b>Description:</b>', escapeHtml(desc))
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return lines.join('\n')
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private async handleCompleteTask(taskId?: string): Promise<string> {
|
|
241
|
-
if (!taskId) {
|
|
242
|
-
return '❌ Usage: /complete <task-id>'
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const task = await this.backend.getTask(taskId)
|
|
246
|
-
if (!task) {
|
|
247
|
-
return `❌ Task not found: ${taskId}`
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const result = await this.backend.markCompleted(taskId, 'Completed via Telegram')
|
|
251
|
-
|
|
252
|
-
if (result.success) {
|
|
253
|
-
return `✅ Task ${taskId} marked as completed!`
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return `❌ Failed to complete task: ${result.error || 'Unknown error'}`
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private async handleFailTask(taskId?: string, reason?: string): Promise<string> {
|
|
260
|
-
if (!taskId) {
|
|
261
|
-
return '❌ Usage: /fail <task-id> [reason]'
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const task = await this.backend.getTask(taskId)
|
|
265
|
-
if (!task) {
|
|
266
|
-
return `❌ Task not found: ${taskId}`
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const result = await this.backend.markFailed(taskId, reason || 'Marked failed via Telegram')
|
|
270
|
-
|
|
271
|
-
if (result.success) {
|
|
272
|
-
return `❌ Task ${taskId} marked as failed`
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return `❌ Failed to update task: ${result.error || 'Unknown error'}`
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
private async handleResetTask(taskId?: string): Promise<string> {
|
|
279
|
-
if (!taskId) {
|
|
280
|
-
return '❌ Usage: /reset <task-id>'
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const task = await this.backend.getTask(taskId)
|
|
284
|
-
if (!task) {
|
|
285
|
-
return `❌ Task not found: ${taskId}`
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const result = await this.backend.resetToPending(taskId)
|
|
289
|
-
|
|
290
|
-
if (result.success) {
|
|
291
|
-
return `🔄 Task ${taskId} reset to pending`
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return `❌ Failed to reset task: ${result.error || 'Unknown error'}`
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private async handleStatus(): Promise<string> {
|
|
298
|
-
const ping = await this.backend.ping()
|
|
299
|
-
const pending = await this.backend.countPending()
|
|
300
|
-
|
|
301
|
-
const lines = [
|
|
302
|
-
'📊 <b>Loopwork Status</b>',
|
|
303
|
-
'',
|
|
304
|
-
`<b>Backend:</b> ${this.backend.name}`,
|
|
305
|
-
`<b>Health:</b> ${ping.ok ? '✅ OK' : '❌ Error'}`,
|
|
306
|
-
`<b>Latency:</b> ${ping.latencyMs}ms`,
|
|
307
|
-
`<b>Pending Tasks:</b> ${pending}`,
|
|
308
|
-
]
|
|
309
|
-
|
|
310
|
-
if (ping.error) {
|
|
311
|
-
lines.push(`<b>Error:</b> ${escapeHtml(ping.error)}`)
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return lines.join('\n')
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
private async handleCreateTask(titleAndDesc: string): Promise<string> {
|
|
318
|
-
if (!titleAndDesc.trim()) {
|
|
319
|
-
return `❌ Usage: /new <title>
|
|
320
|
-
|
|
321
|
-
Or with description:
|
|
322
|
-
/new <title>
|
|
323
|
-
<description on next lines>
|
|
324
|
-
|
|
325
|
-
Example:
|
|
326
|
-
/new Add user authentication
|
|
327
|
-
Implement login/logout with JWT tokens`
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
if (!this.backend.createTask) {
|
|
331
|
-
return '❌ This backend does not support task creation'
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Parse title and description (first line is title, rest is description)
|
|
335
|
-
const lines = titleAndDesc.split('\n')
|
|
336
|
-
const title = lines[0].trim()
|
|
337
|
-
const description = lines.slice(1).join('\n').trim()
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
const task = await this.backend.createTask({
|
|
341
|
-
title,
|
|
342
|
-
description: description || title,
|
|
343
|
-
priority: 'medium',
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
return `✅ Task created!
|
|
347
|
-
|
|
348
|
-
<b>ID:</b> <code>${task.id}</code>
|
|
349
|
-
<b>Title:</b> ${escapeHtml(task.title)}
|
|
350
|
-
<b>Priority:</b> ${task.priority}
|
|
351
|
-
|
|
352
|
-
Use /task ${task.id} to view details`
|
|
353
|
-
} catch (e: any) {
|
|
354
|
-
return `❌ Failed to create task: ${escapeHtml(e.message)}`
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private async handleCreateSubTask(parentId: string | undefined, titleAndDesc: string): Promise<string> {
|
|
359
|
-
if (!parentId || !titleAndDesc.trim()) {
|
|
360
|
-
return `❌ Usage: /subtask <parent-id> <title>
|
|
361
|
-
|
|
362
|
-
Example:
|
|
363
|
-
/subtask TASK-001 Add login form validation`
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (!this.backend.createSubTask) {
|
|
367
|
-
return '❌ This backend does not support sub-task creation'
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Check parent exists
|
|
371
|
-
const parent = await this.backend.getTask(parentId)
|
|
372
|
-
if (!parent) {
|
|
373
|
-
return `❌ Parent task not found: ${parentId}`
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const lines = titleAndDesc.split('\n')
|
|
377
|
-
const title = lines[0].trim()
|
|
378
|
-
const description = lines.slice(1).join('\n').trim()
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
const task = await this.backend.createSubTask(parentId, {
|
|
382
|
-
title,
|
|
383
|
-
description: description || title,
|
|
384
|
-
priority: parent.priority,
|
|
385
|
-
feature: parent.feature,
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
return `✅ Sub-task created!
|
|
389
|
-
|
|
390
|
-
<b>ID:</b> <code>${task.id}</code>
|
|
391
|
-
<b>Parent:</b> <code>${parentId}</code>
|
|
392
|
-
<b>Title:</b> ${escapeHtml(task.title)}
|
|
393
|
-
|
|
394
|
-
Use /task ${task.id} to view details`
|
|
395
|
-
} catch (e: any) {
|
|
396
|
-
return `❌ Failed to create sub-task: ${escapeHtml(e.message)}`
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
private async handleSetPriority(taskId: string | undefined, priority: 'high' | 'medium' | 'low' | undefined): Promise<string> {
|
|
401
|
-
if (!taskId || !priority) {
|
|
402
|
-
return `❌ Usage: /priority <task-id> <high|medium|low>
|
|
403
|
-
|
|
404
|
-
Example:
|
|
405
|
-
/priority TASK-001 high`
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (!['high', 'medium', 'low'].includes(priority)) {
|
|
409
|
-
return `❌ Invalid priority: ${priority}\nUse: high, medium, or low`
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const task = await this.backend.getTask(taskId)
|
|
413
|
-
if (!task) {
|
|
414
|
-
return `❌ Task not found: ${taskId}`
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (!this.backend.setPriority) {
|
|
418
|
-
return '❌ This backend does not support priority changes'
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const oldPriority = task.priority
|
|
422
|
-
const result = await this.backend.setPriority(taskId, priority)
|
|
423
|
-
|
|
424
|
-
if (result.success) {
|
|
425
|
-
const priorityEmoji: Record<string, string> = {
|
|
426
|
-
high: '🔴',
|
|
427
|
-
medium: '🟡',
|
|
428
|
-
low: '🟢',
|
|
429
|
-
}
|
|
430
|
-
return `✅ Priority updated!
|
|
431
|
-
|
|
432
|
-
<b>Task:</b> <code>${taskId}</code>
|
|
433
|
-
<b>Old:</b> ${priorityEmoji[oldPriority] || '⚪'} ${oldPriority}
|
|
434
|
-
<b>New:</b> ${priorityEmoji[priority] || '⚪'} ${priority}`
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
return `❌ Failed to update priority: ${result.error || 'Unknown error'}`
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
private handleHelp(): string {
|
|
441
|
-
return `🤖 <b>Loopwork Task Bot</b>
|
|
442
|
-
|
|
443
|
-
<b>View Tasks:</b>
|
|
444
|
-
/tasks - List pending tasks
|
|
445
|
-
/task <id> - Get task details
|
|
446
|
-
/status - Get backend status
|
|
447
|
-
|
|
448
|
-
<b>Create Tasks:</b>
|
|
449
|
-
/new <title> - Create new task
|
|
450
|
-
/subtask <parent> <title> - Create sub-task
|
|
451
|
-
|
|
452
|
-
<b>Update Tasks:</b>
|
|
453
|
-
/complete <id> - Mark complete
|
|
454
|
-
/fail <id> [reason] - Mark failed
|
|
455
|
-
/reset <id> - Reset to pending
|
|
456
|
-
/priority <id> <high|medium|low> - Set priority
|
|
457
|
-
|
|
458
|
-
<b>Backend:</b> ${this.backend.name}`
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Start the bot polling loop
|
|
463
|
-
*/
|
|
464
|
-
async start(): Promise<void> {
|
|
465
|
-
if (this.running) return
|
|
466
|
-
|
|
467
|
-
this.running = true
|
|
468
|
-
console.log('🤖 Telegram bot started')
|
|
469
|
-
await this.sendMessage('🤖 Loopwork bot is now online!\n\nUse /help for commands.')
|
|
470
|
-
|
|
471
|
-
while (this.running) {
|
|
472
|
-
try {
|
|
473
|
-
const updates = await this.getUpdates()
|
|
474
|
-
|
|
475
|
-
for (const update of updates) {
|
|
476
|
-
this.lastUpdateId = update.update_id
|
|
477
|
-
|
|
478
|
-
if (update.message?.text) {
|
|
479
|
-
const response = await this.handleCommand(
|
|
480
|
-
update.message.chat.id,
|
|
481
|
-
update.message.text
|
|
482
|
-
)
|
|
483
|
-
await this.sendMessage(response)
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
} catch (e: any) {
|
|
487
|
-
console.error('Bot error:', e.message)
|
|
488
|
-
await new Promise(r => setTimeout(r, 5000))
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Stop the bot
|
|
495
|
-
*/
|
|
496
|
-
stop(): void {
|
|
497
|
-
this.running = false
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function escapeHtml(text: string): string {
|
|
502
|
-
return text
|
|
503
|
-
.replace(/&/g, '&')
|
|
504
|
-
.replace(/</g, '<')
|
|
505
|
-
.replace(/>/g, '>')
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// CLI entry point
|
|
509
|
-
if (import.meta.main) {
|
|
510
|
-
const bot = new TelegramTaskBot()
|
|
511
|
-
bot.start().catch(console.error)
|
|
512
|
-
|
|
513
|
-
process.on('SIGINT', () => {
|
|
514
|
-
bot.stop()
|
|
515
|
-
process.exit(0)
|
|
516
|
-
})
|
|
517
|
-
}
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Telegram Notification Plugin for Loopwork
|
|
3
|
-
*
|
|
4
|
-
* Sends task status notifications to a Telegram chat via the Bot API.
|
|
5
|
-
*
|
|
6
|
-
* Setup:
|
|
7
|
-
* 1. Create a bot with @BotFather and get the token
|
|
8
|
-
* 2. Get your chat ID (can use @userinfobot)
|
|
9
|
-
* 3. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { LoopworkPlugin, PluginTask, LoopStats, PluginTaskResult } from '../../contracts'
|
|
13
|
-
|
|
14
|
-
export type NotificationLevel = 'info' | 'success' | 'warning' | 'error'
|
|
15
|
-
|
|
16
|
-
export interface NotificationPayload {
|
|
17
|
-
title: string
|
|
18
|
-
message: string
|
|
19
|
-
level: NotificationLevel
|
|
20
|
-
taskId?: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface NotificationPlugin {
|
|
24
|
-
metadata: {
|
|
25
|
-
name: string
|
|
26
|
-
version: string
|
|
27
|
-
type: 'notification'
|
|
28
|
-
description: string
|
|
29
|
-
}
|
|
30
|
-
isConfigured(): boolean
|
|
31
|
-
send(payload: NotificationPayload): Promise<{ success: boolean; error?: string }>
|
|
32
|
-
ping(): Promise<{ ok: boolean; error?: string }>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface TelegramConfig {
|
|
36
|
-
botToken: string
|
|
37
|
-
chatId: string
|
|
38
|
-
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2'
|
|
39
|
-
disableNotification?: boolean
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const LEVEL_EMOJI: Record<NotificationLevel, string> = {
|
|
43
|
-
info: 'ℹ️',
|
|
44
|
-
success: '✅',
|
|
45
|
-
warning: '⚠️',
|
|
46
|
-
error: '❌',
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Create a Telegram notification plugin
|
|
51
|
-
*/
|
|
52
|
-
export function createTelegramPlugin(config?: Partial<TelegramConfig>): NotificationPlugin {
|
|
53
|
-
const botToken = config?.botToken || process.env.TELEGRAM_BOT_TOKEN || ''
|
|
54
|
-
const chatId = config?.chatId || process.env.TELEGRAM_CHAT_ID || ''
|
|
55
|
-
const parseMode = config?.parseMode || 'HTML'
|
|
56
|
-
const disableNotification = config?.disableNotification ?? false
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
metadata: {
|
|
60
|
-
name: 'telegram',
|
|
61
|
-
version: '1.0.0',
|
|
62
|
-
type: 'notification',
|
|
63
|
-
description: 'Send notifications to Telegram',
|
|
64
|
-
},
|
|
65
|
-
|
|
66
|
-
isConfigured(): boolean {
|
|
67
|
-
return Boolean(botToken && chatId)
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
async send(payload: NotificationPayload): Promise<{ success: boolean; error?: string }> {
|
|
71
|
-
if (!this.isConfigured()) {
|
|
72
|
-
return { success: false, error: 'Telegram not configured (missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID)' }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const emoji = LEVEL_EMOJI[payload.level]
|
|
76
|
-
let text = `${emoji} <b>${escapeHtml(payload.title)}</b>\n\n${escapeHtml(payload.message)}`
|
|
77
|
-
|
|
78
|
-
if (payload.taskId) {
|
|
79
|
-
text += `\n\n<code>Task: ${escapeHtml(payload.taskId)}</code>`
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const url = `https://api.telegram.org/bot${botToken}/sendMessage`
|
|
84
|
-
const response = await fetch(url, {
|
|
85
|
-
method: 'POST',
|
|
86
|
-
headers: { 'Content-Type': 'application/json' },
|
|
87
|
-
body: JSON.stringify({
|
|
88
|
-
chat_id: chatId,
|
|
89
|
-
text,
|
|
90
|
-
parse_mode: parseMode,
|
|
91
|
-
disable_notification: disableNotification,
|
|
92
|
-
}),
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
if (!response.ok) {
|
|
96
|
-
const data = await response.json().catch(() => ({})) as { description?: string }
|
|
97
|
-
return { success: false, error: data.description || `HTTP ${response.status}` }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return { success: true }
|
|
101
|
-
} catch (e: any) {
|
|
102
|
-
return { success: false, error: e.message }
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
async ping(): Promise<{ ok: boolean; error?: string }> {
|
|
107
|
-
if (!this.isConfigured()) {
|
|
108
|
-
return { ok: false, error: 'Not configured' }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
const url = `https://api.telegram.org/bot${botToken}/getMe`
|
|
113
|
-
const response = await fetch(url)
|
|
114
|
-
|
|
115
|
-
if (!response.ok) {
|
|
116
|
-
return { ok: false, error: `HTTP ${response.status}` }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const data = await response.json() as { ok: boolean; result?: { username: string } }
|
|
120
|
-
if (data.ok) {
|
|
121
|
-
return { ok: true }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { ok: false, error: 'Invalid response' }
|
|
125
|
-
} catch (e: any) {
|
|
126
|
-
return { ok: false, error: e.message }
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Escape HTML special characters for Telegram
|
|
134
|
-
*/
|
|
135
|
-
function escapeHtml(text: string): string {
|
|
136
|
-
return text
|
|
137
|
-
.replace(/&/g, '&')
|
|
138
|
-
.replace(/</g, '<')
|
|
139
|
-
.replace(/>/g, '>')
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Create a hook plugin that sends Telegram notifications on task events
|
|
144
|
-
*/
|
|
145
|
-
export function createTelegramHookPlugin(config?: Partial<TelegramConfig>): LoopworkPlugin {
|
|
146
|
-
const telegram = createTelegramPlugin(config)
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
name: 'telegram-hooks',
|
|
150
|
-
|
|
151
|
-
async onTaskStart(task: PluginTask) {
|
|
152
|
-
if (!telegram.isConfigured()) return
|
|
153
|
-
|
|
154
|
-
await telegram.send({
|
|
155
|
-
title: 'Task Started',
|
|
156
|
-
message: `${task.title}`,
|
|
157
|
-
level: 'info',
|
|
158
|
-
taskId: task.id,
|
|
159
|
-
})
|
|
160
|
-
},
|
|
161
|
-
|
|
162
|
-
async onTaskComplete(task: PluginTask, result: PluginTaskResult) {
|
|
163
|
-
if (!telegram.isConfigured()) return
|
|
164
|
-
|
|
165
|
-
const durationMin = Math.round(result.duration / 60)
|
|
166
|
-
await telegram.send({
|
|
167
|
-
title: 'Task Completed',
|
|
168
|
-
message: `${task.title}\n\nDuration: ${durationMin} minutes`,
|
|
169
|
-
level: 'success',
|
|
170
|
-
taskId: task.id,
|
|
171
|
-
})
|
|
172
|
-
},
|
|
173
|
-
|
|
174
|
-
async onTaskFailed(task: PluginTask, error: string) {
|
|
175
|
-
if (!telegram.isConfigured()) return
|
|
176
|
-
|
|
177
|
-
await telegram.send({
|
|
178
|
-
title: 'Task Failed',
|
|
179
|
-
message: `${task.title}\n\nError: ${error.slice(0, 200)}`,
|
|
180
|
-
level: 'error',
|
|
181
|
-
taskId: task.id,
|
|
182
|
-
})
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
async onLoopEnd(stats: LoopStats) {
|
|
186
|
-
if (!telegram.isConfigured()) return
|
|
187
|
-
|
|
188
|
-
const durationMin = Math.round(stats.duration / 60)
|
|
189
|
-
const level = stats.failed > 0 ? 'warning' : 'success'
|
|
190
|
-
|
|
191
|
-
await telegram.send({
|
|
192
|
-
title: 'Loop Completed',
|
|
193
|
-
message: `Completed: ${stats.completed}\nFailed: ${stats.failed}\nDuration: ${durationMin} minutes`,
|
|
194
|
-
level,
|
|
195
|
-
})
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
|
-
}
|