mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
feat: scheduled task IM notification + UI fixes
- Add notification config to CronTask (enabled + channels) - New notificationService: push task results to Feishu/Telegram on completion - Feishu uses Schema 2.0 interactive card for proper Markdown rendering - Telegram uses Bot API sendMessage with Markdown parse mode - NewTaskModal: notification toggle + channel checkboxes with config validation - Fix: API layer now passes notification field through on task creation - Fix: View conversation button uses tabStore instead of legacy setActiveView - Fix: bypass permission dialog shows selected folder path instead of ~ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c54c48514
commit
8617a4b9c9
@ -46,7 +46,7 @@
|
||||
"externalBin": [
|
||||
"binaries/claude-sidecar"
|
||||
],
|
||||
"resources": {},
|
||||
"resources": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useAdapterStore } from '../../stores/adapterStore'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Input } from '../shared/Input'
|
||||
import { Button } from '../shared/Button'
|
||||
@ -59,6 +60,17 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||
const defaultWorkDir = activeSession?.workDir || ''
|
||||
const adapterConfig = useAdapterStore((s) => s.config)
|
||||
const fetchAdapterConfig = useAdapterStore((s) => s.fetchConfig)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchAdapterConfig()
|
||||
}, [open])
|
||||
|
||||
const isFeishuConfigured = !!(adapterConfig.feishu?.appId && adapterConfig.feishu?.appSecret
|
||||
&& ((adapterConfig.feishu?.pairedUsers?.length ?? 0) > 0 || (adapterConfig.feishu?.allowedUsers?.length ?? 0) > 0))
|
||||
const isTelegramConfigured = !!(adapterConfig.telegram?.botToken
|
||||
&& ((adapterConfig.telegram?.pairedUsers?.length ?? 0) > 0 || (adapterConfig.telegram?.allowedUsers?.length ?? 0) > 0))
|
||||
|
||||
const isEdit = !!editTask
|
||||
const parsed = editTask ? parseCron(editTask.cron) : null
|
||||
@ -82,6 +94,8 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>((editTask?.permissionMode as PermissionMode) || 'default')
|
||||
const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir)
|
||||
const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false)
|
||||
const [notifyEnabled, setNotifyEnabled] = useState(editTask?.notification?.enabled || false)
|
||||
const [notifyChannels, setNotifyChannels] = useState<('telegram' | 'feishu')[]>(editTask?.notification?.channels || [])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Enhanced scheduling state
|
||||
@ -118,6 +132,9 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
permissionMode: permissionMode !== 'default' ? permissionMode : undefined,
|
||||
folderPath: folderPath.trim() || undefined,
|
||||
useWorktree: useWorktree || undefined,
|
||||
notification: notifyEnabled && notifyChannels.length > 0
|
||||
? { enabled: true, channels: notifyChannels }
|
||||
: undefined,
|
||||
}
|
||||
if (isEdit) {
|
||||
await updateTask(editTask!.id, payload)
|
||||
@ -308,6 +325,68 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyEnabled}
|
||||
onChange={(e) => setNotifyEnabled(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--color-text-primary)]">{t('newTask.notifyOnComplete')}</span>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('newTask.notifyHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
{notifyEnabled && (
|
||||
<div className="flex flex-col gap-2 pl-7">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className={`flex items-center gap-2 ${isFeishuConfigured ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyChannels.includes('feishu')}
|
||||
disabled={!isFeishuConfigured}
|
||||
onChange={(e) => {
|
||||
setNotifyChannels((prev) =>
|
||||
e.target.checked ? [...prev, 'feishu'] : prev.filter((c) => c !== 'feishu'),
|
||||
)
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.feishu')}</span>
|
||||
{!isFeishuConfigured && (
|
||||
<span className="text-[10px] text-[var(--color-warning)]">{t('newTask.notConfigured')}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 ${isTelegramConfigured ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyChannels.includes('telegram')}
|
||||
disabled={!isTelegramConfigured}
|
||||
onChange={(e) => {
|
||||
setNotifyChannels((prev) =>
|
||||
e.target.checked ? [...prev, 'telegram'] : prev.filter((c) => c !== 'telegram'),
|
||||
)
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
|
||||
/>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.telegram')}</span>
|
||||
{!isTelegramConfigured && (
|
||||
<span className="text-[10px] text-[var(--color-warning)]">{t('newTask.notConfigured')}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{!isFeishuConfigured && !isTelegramConfigured && (
|
||||
<p className="text-xs text-[var(--color-warning)]">
|
||||
<span className="material-symbols-outlined text-[12px] align-middle mr-1">warning</span>
|
||||
{t('newTask.noChannelConfigured')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cron preview */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)]">
|
||||
<span className="material-symbols-outlined text-[16px]">schedule</span>
|
||||
|
||||
@ -52,7 +52,7 @@ export function PromptEditor({
|
||||
<div className="border-t border-[var(--color-border)]/40 px-3 py-2 flex flex-col gap-2 bg-[var(--color-surface-container-low)] rounded-b-[var(--radius-lg)]">
|
||||
{/* Row 1: Permission + Model selectors */}
|
||||
<div className="flex items-center justify-between">
|
||||
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} />
|
||||
<PermissionModeSelector value={permissionMode} onChange={onPermissionModeChange} workDir={folderPath || undefined} />
|
||||
<ModelSelector value={modelId} onChange={onModelChange} />
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTaskStore } from '../../stores/taskStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { parseRunOutput } from '../../lib/parseRunOutput'
|
||||
import type { TaskRun } from '../../types/task'
|
||||
@ -53,16 +52,14 @@ const STATUS_CONFIG: Record<string, { icon: string; color: string }> = {
|
||||
export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
|
||||
const t = useTranslation()
|
||||
const { fetchTaskRuns } = useTaskStore()
|
||||
const setActiveSession = useSessionStore((s) => s.setActiveSession)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const setActiveView = useUIStore((s) => s.setActiveView)
|
||||
const openTab = useTabStore((s) => s.openTab)
|
||||
const [runs, setRuns] = useState<TaskRun[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const openSession = (sessionId: string) => {
|
||||
setActiveView('code')
|
||||
setActiveSession(sessionId)
|
||||
const openSession = (sessionId: string, taskName?: string) => {
|
||||
openTab(sessionId, taskName || 'Task Run')
|
||||
connectToSession(sessionId)
|
||||
}
|
||||
|
||||
@ -163,7 +160,7 @@ export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) {
|
||||
{/* Open session — only after run completes (session is empty while running) */}
|
||||
{run.sessionId && run.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => openSession(run.sessionId!)}
|
||||
onClick={() => openSession(run.sessionId!, run.taskName)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-[var(--color-brand)] bg-[var(--color-brand)]/8 hover:bg-[var(--color-brand)]/15 rounded-[var(--radius-sm)] transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
|
||||
@ -367,6 +367,12 @@ export const en = {
|
||||
'newTask.delayNote': 'Scheduled tasks use a randomized delay of several minutes for server performance.',
|
||||
'newTask.create': 'Create task',
|
||||
'newTask.promptPlaceholder': 'Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns or missing tests, and note anything worth following up on.',
|
||||
'newTask.notification': 'Notification',
|
||||
'newTask.notifyOnComplete': 'Push notification on completion',
|
||||
'newTask.notifyChannels': 'Notification channels',
|
||||
'newTask.notifyHint': 'Send the task title and result to your IM channels when done.',
|
||||
'newTask.notConfigured': 'Not configured',
|
||||
'newTask.noChannelConfigured': 'No IM channels configured yet. Go to Settings → IM Adapters to set up.',
|
||||
|
||||
// ─── Cron Descriptions ──────────────────────────────────────
|
||||
'cron.everyMinute': 'Runs every minute',
|
||||
|
||||
@ -369,6 +369,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'newTask.delayNote': '定时任务会使用随机延迟以优化服务器性能。',
|
||||
'newTask.create': '创建任务',
|
||||
'newTask.promptPlaceholder': '查看过去 24 小时的提交。总结变更内容,标记有风险的模式或缺失的测试,并记录值得跟进的事项。',
|
||||
'newTask.notification': '通知',
|
||||
'newTask.notifyOnComplete': '完成后推送通知',
|
||||
'newTask.notifyChannels': '通知渠道',
|
||||
'newTask.notifyHint': '任务完成后将标题和结果推送到 IM 渠道。',
|
||||
'newTask.notConfigured': '未配置',
|
||||
'newTask.noChannelConfigured': '尚未配置任何 IM 渠道,请前往 设置 → IM 接入 进行配置。',
|
||||
|
||||
// ─── Cron 描述 ──────────────────────────────────────
|
||||
'cron.everyMinute': '每分钟执行',
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
// Source: src/server/services/cronService.ts
|
||||
|
||||
export type TaskNotificationConfig = {
|
||||
enabled: boolean
|
||||
channels: ('telegram' | 'feishu')[]
|
||||
}
|
||||
|
||||
export type CronTask = {
|
||||
id: string
|
||||
name: string
|
||||
@ -17,6 +22,7 @@ export type CronTask = {
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
}
|
||||
|
||||
export type CreateTaskInput = {
|
||||
@ -31,6 +37,7 @@ export type CreateTaskInput = {
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
}
|
||||
|
||||
export type TaskRun = {
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
* DELETE /api/scheduled-tasks/:id — 删除任务
|
||||
*/
|
||||
|
||||
import { CronService } from '../services/cronService.js'
|
||||
import { CronService, type CronTask } from '../services/cronService.js'
|
||||
import { cronScheduler } from '../services/cronScheduler.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
@ -61,6 +61,7 @@ export async function handleScheduledTasksApi(
|
||||
model: body.model as string | undefined,
|
||||
folderPath: body.folderPath as string | undefined,
|
||||
useWorktree: body.useWorktree as boolean | undefined,
|
||||
notification: body.notification as CronTask['notification'],
|
||||
})
|
||||
return Response.json({ task }, { status: 201 })
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
import { CronService, type CronTask } from './cronService.js'
|
||||
import { SessionService } from './sessionService.js'
|
||||
import { sendTaskNotification } from './notificationService.js'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -506,6 +507,13 @@ export class CronScheduler {
|
||||
|
||||
await updateRun(completedRun)
|
||||
|
||||
// Send IM notification if configured
|
||||
if (task.notification?.enabled && task.notification.channels.length > 0) {
|
||||
sendTaskNotification(completedRun, task.notification).catch((err) => {
|
||||
console.error(`[CronScheduler] Notification error for task ${task.id}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
// If non-recurring, disable after first run
|
||||
if (!task.recurring) {
|
||||
await this.cronService.updateTask(task.id, { enabled: false }).catch(() => {
|
||||
|
||||
@ -11,6 +11,11 @@ import * as os from 'os'
|
||||
import * as crypto from 'crypto'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
export type TaskNotificationConfig = {
|
||||
enabled: boolean
|
||||
channels: ('telegram' | 'feishu')[]
|
||||
}
|
||||
|
||||
export type CronTask = {
|
||||
id: string
|
||||
name?: string
|
||||
@ -26,6 +31,7 @@ export type CronTask = {
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
}
|
||||
|
||||
type TasksFile = {
|
||||
|
||||
278
src/server/services/notificationService.ts
Normal file
278
src/server/services/notificationService.ts
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* NotificationService — 定时任务完成后通过 IM 渠道推送通知
|
||||
*
|
||||
* 直接调用 Telegram Bot API / 飞书 Open API(HTTP),不依赖 adapter sidecar
|
||||
* 或第三方 SDK,确保即使 adapter 进程未运行也能推送。
|
||||
*/
|
||||
|
||||
import { adapterService, type AdapterFileConfig } from './adapterService.js'
|
||||
import type { TaskRun } from './cronScheduler.js'
|
||||
import type { TaskNotificationConfig } from './cronService.js'
|
||||
|
||||
// ─── Message formatting ──────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the markdown notification body.
|
||||
* Shared between Telegram and Feishu — both support markdown.
|
||||
*/
|
||||
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')
|
||||
}
|
||||
|
||||
// ─── Telegram ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TELEGRAM_API = 'https://api.telegram.org'
|
||||
const TELEGRAM_TEXT_LIMIT = 4000
|
||||
|
||||
async function sendTelegram(
|
||||
botToken: string,
|
||||
chatId: number | string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
// Telegram message limit ~4096 chars; trim with margin
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Feishu ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a P2P chat with a user (needed because Feishu send-message
|
||||
* requires a chat_id, and pairedUsers stores open_id).
|
||||
*/
|
||||
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
|
||||
}
|
||||
// Fallback: try sending with open_id directly using receive_id_type=open_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}`
|
||||
|
||||
// Meta line
|
||||
const metaLine = [
|
||||
`**Status**: ${statusText(run.status)}`,
|
||||
run.durationMs != null ? `**Duration**: ${formatDuration(run.durationMs)}` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
// Result / error content
|
||||
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)
|
||||
}
|
||||
|
||||
// Schema 2.0 interactive card — renders markdown correctly
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function sendTaskNotification(
|
||||
run: TaskRun,
|
||||
notification: TaskNotificationConfig,
|
||||
): Promise<void> {
|
||||
if (!notification.enabled || notification.channels.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 notification.channels) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user