| |
| |
| |
| |
| |
| |
|
|
| import { adapterService, type AdapterFileConfig } from './adapterService.js' |
| import type { TaskRun } from './cronScheduler.js' |
| import type { TaskNotificationConfig } from './cronService.js' |
|
|
| |
|
|
| function formatDuration(ms: number): string { |
| if (ms < 60_000) return `${Math.round(ms / 1000)}s` |
| const minutes = Math.floor(ms / 60_000) |
| const seconds = Math.round((ms % 60_000) / 1000) |
| return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m` |
| } |
|
|
| function statusEmoji(status: TaskRun['status']): string { |
| switch (status) { |
| case 'completed': return '✅' |
| case 'failed': return '❌' |
| case 'timeout': return '⏰' |
| default: return 'ℹ️' |
| } |
| } |
|
|
| function statusText(status: TaskRun['status']): string { |
| switch (status) { |
| case 'completed': return 'Completed' |
| case 'failed': return 'Failed' |
| case 'timeout': return 'Timeout' |
| default: return status |
| } |
| } |
|
|
| |
| |
| |
| |
| function buildMarkdown(run: TaskRun): string { |
| const emoji = statusEmoji(run.status) |
| const lines: string[] = [] |
|
|
| lines.push(`${emoji} **${run.taskName}**`) |
| lines.push('') |
| lines.push(`**Status**: ${statusText(run.status)}`) |
| if (run.durationMs != null) { |
| lines.push(`**Duration**: ${formatDuration(run.durationMs)}`) |
| } |
|
|
| if (run.status === 'failed' && run.error) { |
| lines.push('') |
| lines.push('**Error**:') |
| const errorText = run.error.length > 500 ? run.error.slice(0, 500) + '…' : run.error |
| lines.push(errorText) |
| } |
|
|
| if (run.output) { |
| lines.push('') |
| lines.push('**Result**:') |
| const outputText = run.output.length > 2000 ? run.output.slice(0, 2000) + '…' : run.output |
| lines.push(outputText) |
| } |
|
|
| return lines.join('\n') |
| } |
|
|
| |
|
|
| const TELEGRAM_API = 'https://api.telegram.org' |
| const TELEGRAM_TEXT_LIMIT = 4000 |
|
|
| async function sendTelegram( |
| botToken: string, |
| chatId: number | string, |
| text: string, |
| ): Promise<void> { |
| |
| const trimmed = text.length > TELEGRAM_TEXT_LIMIT |
| ? text.slice(0, TELEGRAM_TEXT_LIMIT) + '…' |
| : text |
|
|
| const resp = await fetch(`${TELEGRAM_API}/bot${botToken}/sendMessage`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| chat_id: chatId, |
| text: trimmed, |
| parse_mode: 'Markdown', |
| }), |
| }) |
|
|
| if (!resp.ok) { |
| const body = await resp.text().catch(() => '') |
| console.error(`[Notification] Telegram send failed (${resp.status}):`, body) |
| } |
| } |
|
|
| |
|
|
| const FEISHU_API = 'https://open.feishu.cn/open-apis' |
|
|
| async function getFeishuTenantToken(appId: string, appSecret: string): Promise<string | null> { |
| try { |
| const resp = await fetch(`${FEISHU_API}/auth/v3/tenant_access_token/internal`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ app_id: appId, app_secret: appSecret }), |
| }) |
| const data = await resp.json() as { tenant_access_token?: string; code?: number } |
| if (data.code === 0 && data.tenant_access_token) { |
| return data.tenant_access_token |
| } |
| console.error('[Notification] Feishu token failed:', data) |
| return null |
| } catch (err) { |
| console.error('[Notification] Feishu token error:', err) |
| return null |
| } |
| } |
|
|
| |
| |
| |
| |
| async function getFeishuChatId( |
| token: string, |
| userId: string, |
| ): Promise<string | null> { |
| try { |
| const resp = await fetch(`${FEISHU_API}/im/v1/chats?user_id_type=open_id`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify({ |
| chat_mode: 'p2p', |
| user_id_type: 'open_id', |
| user_id_list: [userId], |
| }), |
| }) |
| const data = await resp.json() as { code?: number; data?: { chat_id?: string } } |
| if (data.code === 0 && data.data?.chat_id) { |
| return data.data.chat_id |
| } |
| |
| return null |
| } catch (err) { |
| console.error('[Notification] Feishu get chat_id error:', err) |
| return null |
| } |
| } |
|
|
| async function sendFeishu( |
| token: string, |
| userId: string, |
| run: TaskRun, |
| ): Promise<void> { |
| const emoji = statusEmoji(run.status) |
| const headerTemplate = run.status === 'completed' ? 'green' : 'red' |
| const headerTitle = `${emoji} ${run.taskName}` |
|
|
| |
| const metaLine = [ |
| `**Status**: ${statusText(run.status)}`, |
| run.durationMs != null ? `**Duration**: ${formatDuration(run.durationMs)}` : '', |
| ].filter(Boolean).join(' ') |
|
|
| |
| const bodyParts: string[] = [] |
| if (run.status === 'failed' && run.error) { |
| const errorText = run.error.length > 500 ? run.error.slice(0, 500) + '…' : run.error |
| bodyParts.push(`**Error**:\n${errorText}`) |
| } |
| if (run.output) { |
| const outputText = run.output.length > 3000 ? run.output.slice(0, 3000) + '…' : run.output |
| bodyParts.push(outputText) |
| } |
|
|
| |
| const elements: Record<string, unknown>[] = [ |
| { tag: 'markdown', content: metaLine, text_align: 'left' }, |
| ] |
| if (bodyParts.length > 0) { |
| elements.push({ tag: 'hr' }) |
| elements.push({ tag: 'markdown', content: bodyParts.join('\n\n'), text_align: 'left' }) |
| } |
|
|
| const card = { |
| schema: '2.0', |
| header: { |
| template: headerTemplate, |
| title: { tag: 'plain_text', content: headerTitle }, |
| }, |
| body: { elements }, |
| } |
|
|
| const resp = await fetch(`${FEISHU_API}/im/v1/messages?receive_id_type=open_id`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify({ |
| receive_id: userId, |
| msg_type: 'interactive', |
| content: JSON.stringify(card), |
| }), |
| }) |
|
|
| if (!resp.ok) { |
| const body = await resp.text().catch(() => '') |
| console.error(`[Notification] Feishu send failed (${resp.status}):`, body) |
| } |
| } |
|
|
| |
|
|
| export async function sendTaskNotification( |
| run: TaskRun, |
| notification: TaskNotificationConfig, |
| ): Promise<void> { |
| const imChannels = notification.channels.filter((channel): channel is 'telegram' | 'feishu' => |
| channel === 'telegram' || channel === 'feishu', |
| ) |
| if (!notification.enabled || imChannels.length === 0) return |
|
|
| let config: AdapterFileConfig |
| try { |
| config = await adapterService.getRawConfig() |
| } catch (err) { |
| console.error('[Notification] Failed to read adapter config:', err) |
| return |
| } |
|
|
| const markdown = buildMarkdown(run) |
|
|
| for (const channel of imChannels) { |
| try { |
| if (channel === 'telegram') { |
| const botToken = config.telegram?.botToken |
| if (!botToken) { |
| console.warn('[Notification] Telegram botToken not configured, skipping') |
| continue |
| } |
| const users = [ |
| ...(config.telegram?.pairedUsers ?? []), |
| ...(config.telegram?.allowedUsers ?? []).map((id) => ({ userId: id })), |
| ] |
| for (const user of users) { |
| await sendTelegram(botToken, user.userId, markdown) |
| } |
| } |
|
|
| if (channel === 'feishu') { |
| const appId = config.feishu?.appId |
| const appSecret = config.feishu?.appSecret |
| if (!appId || !appSecret) { |
| console.warn('[Notification] Feishu credentials not configured, skipping') |
| continue |
| } |
| const token = await getFeishuTenantToken(appId, appSecret) |
| if (!token) continue |
|
|
| const users = [ |
| ...(config.feishu?.pairedUsers ?? []), |
| ...(config.feishu?.allowedUsers ?? []).map((id) => ({ userId: id })), |
| ] |
| for (const user of users) { |
| await sendFeishu(token, String(user.userId), run) |
| } |
| } |
| } catch (err) { |
| console.error(`[Notification] ${channel} notification error:`, err) |
| } |
| } |
| } |
|
|