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:
程序员阿江(Relakkes) 2026-04-12 15:42:44 +08:00
parent 8c54c48514
commit 8617a4b9c9
11 changed files with 400 additions and 12 deletions

View File

@ -46,7 +46,7 @@
"externalBin": [
"binaries/claude-sidecar"
],
"resources": {},
"resources": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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': '每分钟执行',

View File

@ -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 = {

View File

@ -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 })
}

View File

@ -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(() => {

View File

@ -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 = {

View File

@ -0,0 +1,278 @@
/**
* NotificationService IM
*
* Telegram Bot API / Open APIHTTP 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)
}
}
}