feat: add experimental support for AST parsing and static graph indexing

- Introduced CLI options for using AST grep parsers and enabling static graph relationships during indexing.
- Updated configuration management to load new settings for AST parsing and static graph types.
- Enhanced AST grep processor to handle imports with aliases and improve relationship tracking.
- Modified TreeSitter parsers to support synthetic module scopes for better static graph persistence.
- Implemented global relationship updates in the incremental indexer for static graph expansion.
- Added new ArtifactTag and FloatingFileBrowser components to the frontend for improved terminal dashboard functionality.
- Created utility functions for detecting CCW artifacts in terminal output with associated tests.
This commit is contained in:
catlog22
2026-02-15 23:12:06 +08:00
parent 48a6a1f2aa
commit 8938c47f88
39 changed files with 2956 additions and 297 deletions

View File

@@ -71,6 +71,7 @@ export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
export const SimpleOptionSchema = z.object({
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
});
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
@@ -114,6 +115,7 @@ export const AskQuestionResultSchema = z.object({
answers: z.array(QuestionAnswerSchema),
timestamp: z.string(),
error: z.string().optional(),
autoSelected: z.boolean().optional(),
});
export type AskQuestionResult = z.infer<typeof AskQuestionResultSchema>;

View File

@@ -8,6 +8,7 @@ import { homedir } from 'os';
import { spawn } from 'child_process';
import type { RouteContext } from './types.js';
import { a2uiWebSocketHandler } from '../a2ui/A2UIWebSocketHandler.js';
interface HooksRouteContext extends RouteContext {
extractSessionIdFromPath: (filePath: string) => string | null;
@@ -313,6 +314,20 @@ export async function handleHooksRoutes(ctx: HooksRouteContext): Promise<boolean
}
};
// When an A2UI surface is forwarded from the MCP process, initialize
// selection tracking on the Dashboard so that submit actions resolve
// to the correct value type (single-select string vs multi-select array).
if (type === 'a2ui-surface' && extraData?.initialState) {
const initState = extraData.initialState as Record<string, unknown>;
const questionId = initState.questionId as string | undefined;
const questionType = initState.questionType as string | undefined;
if (questionId && questionType === 'select') {
a2uiWebSocketHandler.initSingleSelect(questionId);
} else if (questionId && questionType === 'multi-select') {
a2uiWebSocketHandler.initMultiSelect(questionId);
}
}
broadcastToClients(notification);
return { success: true, notification };

View File

@@ -12,7 +12,7 @@ import {
} from '../../config/remote-notification-config.js';
import {
remoteNotificationService,
} from '../../services/remote-notification-service.js';
} from '../services/remote-notification-service.js';
import {
maskSensitiveConfig,
type RemoteNotificationConfig,
@@ -21,6 +21,10 @@ import {
type DiscordConfig,
type TelegramConfig,
type WebhookConfig,
type FeishuConfig,
type DingTalkConfig,
type WeComConfig,
type EmailConfig,
} from '../../types/remote-notification.js';
import { deepMerge } from '../../types/util.js';
@@ -110,13 +114,72 @@ function isValidHeaders(headers: unknown): { valid: boolean; error?: string } {
return { valid: true };
}
/**
* Validate Feishu webhook URL format
*/
function isValidFeishuWebhookUrl(url: string): boolean {
if (!isValidUrl(url)) return false;
try {
const parsed = new URL(url);
// Feishu webhooks are typically: open.feishu.cn/open-apis/bot/v2/hook/{token}
// or: open.larksuite.com/open-apis/bot/v2/hook/{token}
const validHosts = ['open.feishu.cn', 'open.larksuite.com'];
return validHosts.includes(parsed.hostname) && parsed.pathname.includes('/bot/');
} catch {
return false;
}
}
/**
* Validate DingTalk webhook URL format
*/
function isValidDingTalkWebhookUrl(url: string): boolean {
if (!isValidUrl(url)) return false;
try {
const parsed = new URL(url);
// DingTalk webhooks are typically: oapi.dingtalk.com/robot/send?access_token=xxx
return parsed.hostname.includes('dingtalk.com') && parsed.pathname.includes('robot');
} catch {
return false;
}
}
/**
* Validate WeCom webhook URL format
*/
function isValidWeComWebhookUrl(url: string): boolean {
if (!isValidUrl(url)) return false;
try {
const parsed = new URL(url);
// WeCom webhooks are typically: qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
return parsed.hostname.includes('qyapi.weixin.qq.com') && parsed.pathname.includes('webhook');
} catch {
return false;
}
}
/**
* Validate email address format
*/
function isValidEmail(email: string): boolean {
// Basic email validation regex
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* Validate SMTP port number
*/
function isValidSmtpPort(port: number): boolean {
return Number.isInteger(port) && port > 0 && port <= 65535;
}
/**
* Validate configuration updates
*/
function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { valid: boolean; error?: string } {
// Validate platforms if present
if (updates.platforms) {
const { discord, telegram, webhook } = updates.platforms;
const { discord, telegram, webhook, feishu, dingtalk, wecom, email } = updates.platforms;
// Validate Discord config
if (discord) {
@@ -165,6 +228,99 @@ function validateConfigUpdates(updates: Partial<RemoteNotificationConfig>): { va
return { valid: false, error: 'Webhook timeout must be between 1000ms and 60000ms' };
}
}
// Validate Feishu config
if (feishu) {
if (feishu.webhookUrl !== undefined && feishu.webhookUrl !== '') {
if (!isValidUrl(feishu.webhookUrl)) {
return { valid: false, error: 'Invalid Feishu webhook URL format' };
}
if (!isValidFeishuWebhookUrl(feishu.webhookUrl)) {
console.warn('[RemoteNotification] Webhook URL does not match Feishu format');
}
}
if (feishu.title !== undefined && feishu.title.length > 100) {
return { valid: false, error: 'Feishu title too long (max 100 chars)' };
}
}
// Validate DingTalk config
if (dingtalk) {
if (dingtalk.webhookUrl !== undefined && dingtalk.webhookUrl !== '') {
if (!isValidUrl(dingtalk.webhookUrl)) {
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
}
if (!isValidDingTalkWebhookUrl(dingtalk.webhookUrl)) {
console.warn('[RemoteNotification] Webhook URL does not match DingTalk format');
}
}
if (dingtalk.keywords !== undefined) {
if (!Array.isArray(dingtalk.keywords)) {
return { valid: false, error: 'DingTalk keywords must be an array' };
}
if (dingtalk.keywords.length > 10) {
return { valid: false, error: 'Too many DingTalk keywords (max 10)' };
}
}
}
// Validate WeCom config
if (wecom) {
if (wecom.webhookUrl !== undefined && wecom.webhookUrl !== '') {
if (!isValidUrl(wecom.webhookUrl)) {
return { valid: false, error: 'Invalid WeCom webhook URL format' };
}
if (!isValidWeComWebhookUrl(wecom.webhookUrl)) {
console.warn('[RemoteNotification] Webhook URL does not match WeCom format');
}
}
if (wecom.mentionedList !== undefined) {
if (!Array.isArray(wecom.mentionedList)) {
return { valid: false, error: 'WeCom mentionedList must be an array' };
}
if (wecom.mentionedList.length > 100) {
return { valid: false, error: 'Too many mentioned users (max 100)' };
}
}
}
// Validate Email config
if (email) {
if (email.host !== undefined && email.host !== '') {
if (email.host.length > 255) {
return { valid: false, error: 'Email host too long (max 255 chars)' };
}
}
if (email.port !== undefined) {
if (!isValidSmtpPort(email.port)) {
return { valid: false, error: 'Invalid SMTP port (must be 1-65535)' };
}
}
if (email.username !== undefined && email.username.length > 255) {
return { valid: false, error: 'Email username too long (max 255 chars)' };
}
if (email.from !== undefined && email.from !== '') {
if (!isValidEmail(email.from)) {
return { valid: false, error: 'Invalid sender email address' };
}
}
if (email.to !== undefined) {
if (!Array.isArray(email.to)) {
return { valid: false, error: 'Email recipients must be an array' };
}
if (email.to.length === 0) {
return { valid: false, error: 'At least one email recipient is required' };
}
if (email.to.length > 50) {
return { valid: false, error: 'Too many email recipients (max 50)' };
}
for (const addr of email.to) {
if (!isValidEmail(addr)) {
return { valid: false, error: `Invalid email address: ${addr}` };
}
}
}
}
}
// Validate timeout
@@ -183,7 +339,7 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
return { valid: false, error: 'Missing platform' };
}
const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
const validPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook', 'feishu', 'dingtalk', 'wecom', 'email'];
if (!validPlatforms.includes(request.platform as NotificationPlatform)) {
return { valid: false, error: `Invalid platform: ${request.platform}` };
}
@@ -236,6 +392,66 @@ function validateTestRequest(request: TestNotificationRequest): { valid: boolean
}
break;
}
case 'feishu': {
const config = request.config as Partial<FeishuConfig>;
if (!config.webhookUrl) {
return { valid: false, error: 'Feishu webhook URL is required' };
}
if (!isValidUrl(config.webhookUrl)) {
return { valid: false, error: 'Invalid Feishu webhook URL format' };
}
break;
}
case 'dingtalk': {
const config = request.config as Partial<DingTalkConfig>;
if (!config.webhookUrl) {
return { valid: false, error: 'DingTalk webhook URL is required' };
}
if (!isValidUrl(config.webhookUrl)) {
return { valid: false, error: 'Invalid DingTalk webhook URL format' };
}
break;
}
case 'wecom': {
const config = request.config as Partial<WeComConfig>;
if (!config.webhookUrl) {
return { valid: false, error: 'WeCom webhook URL is required' };
}
if (!isValidUrl(config.webhookUrl)) {
return { valid: false, error: 'Invalid WeCom webhook URL format' };
}
break;
}
case 'email': {
const config = request.config as Partial<EmailConfig>;
if (!config.host) {
return { valid: false, error: 'SMTP host is required' };
}
if (!config.username) {
return { valid: false, error: 'SMTP username is required' };
}
if (!config.password) {
return { valid: false, error: 'SMTP password is required' };
}
if (!config.from) {
return { valid: false, error: 'Sender email address is required' };
}
if (!isValidEmail(config.from)) {
return { valid: false, error: 'Invalid sender email address' };
}
if (!config.to || config.to.length === 0) {
return { valid: false, error: 'At least one recipient email is required' };
}
for (const addr of config.to) {
if (!isValidEmail(addr)) {
return { valid: false, error: `Invalid recipient email: ${addr}` };
}
}
if (config.port !== undefined && !isValidSmtpPort(config.port)) {
return { valid: false, error: 'Invalid SMTP port' };
}
break;
}
}
return { valid: true };

View File

@@ -16,6 +16,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
FeishuConfig,
DingTalkConfig,
WeComConfig,
EmailConfig,
} from '../../types/remote-notification.js';
import {
loadConfig,
@@ -170,6 +174,14 @@ class RemoteNotificationService {
return await this.sendTelegram(context, config.platforms.telegram!, config.timeout);
case 'webhook':
return await this.sendWebhook(context, config.platforms.webhook!, config.timeout);
case 'feishu':
return await this.sendFeishu(context, config.platforms.feishu!, config.timeout);
case 'dingtalk':
return await this.sendDingTalk(context, config.platforms.dingtalk!, config.timeout);
case 'wecom':
return await this.sendWeCom(context, config.platforms.wecom!, config.timeout);
case 'email':
return await this.sendEmail(context, config.platforms.email!, config.timeout);
default:
return {
platform,
@@ -408,6 +420,538 @@ class RemoteNotificationService {
}
}
/**
* Send Feishu notification via webhook
* Supports both rich card format and simple text format
*/
private async sendFeishu(
context: NotificationContext,
config: FeishuConfig,
timeout: number
): Promise<PlatformNotificationResult> {
const startTime = Date.now();
if (!config.webhookUrl) {
return { platform: 'feishu', success: false, error: 'Webhook URL not configured' };
}
const useCard = config.useCard !== false; // Default to true
try {
let body: unknown;
if (useCard) {
// Rich card format
const card = this.buildFeishuCard(context, config);
body = {
msg_type: 'interactive',
card,
};
} else {
// Simple text format
const text = this.buildFeishuText(context);
body = {
msg_type: 'post',
content: {
post: {
zh_cn: {
title: config.title || 'CCW Notification',
content: [[{ tag: 'text', text }]],
},
},
},
};
}
await this.httpRequest(config.webhookUrl, body, timeout);
return {
platform: 'feishu',
success: true,
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
platform: 'feishu',
success: false,
error: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
};
}
}
/**
* Build Feishu interactive card from context
*/
private buildFeishuCard(context: NotificationContext, config: FeishuConfig): Record<string, unknown> {
const elements: Array<Record<string, unknown>> = [];
// Add event type as header
elements.push({
tag: 'markdown',
content: `**${this.formatEventName(context.eventType)}**`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
// Add session info
if (context.sessionId) {
elements.push({
tag: 'markdown',
content: `**Session:** ${context.sessionId.slice(0, 16)}...`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
}
// Add question text
if (context.questionText) {
const truncated = context.questionText.length > 300
? context.questionText.slice(0, 300) + '...'
: context.questionText;
elements.push({
tag: 'markdown',
content: `**Question:** ${this.escapeFeishuMarkdown(truncated)}`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
}
// Add task description
if (context.taskDescription) {
const truncated = context.taskDescription.length > 300
? context.taskDescription.slice(0, 300) + '...'
: context.taskDescription;
elements.push({
tag: 'markdown',
content: `**Task:** ${this.escapeFeishuMarkdown(truncated)}`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
}
// Add error message
if (context.errorMessage) {
const truncated = context.errorMessage.length > 300
? context.errorMessage.slice(0, 300) + '...'
: context.errorMessage;
elements.push({
tag: 'markdown',
content: `**Error:** ${this.escapeFeishuMarkdown(truncated)}`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
}
// Add timestamp
elements.push({
tag: 'markdown',
content: `**Time:** ${new Date(context.timestamp).toLocaleString()}`,
text_align: 'left' as const,
text_size: 'normal_v2' as const,
});
return {
schema: '2.0',
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: 'normal',
pc: 'normal',
mobile: 'heading',
},
},
},
},
header: {
title: {
tag: 'plain_text',
content: config.title || 'CCW Notification',
},
template: 'wathet',
padding: '12px 12px 12px 12px',
},
body: {
direction: 'vertical',
horizontal_spacing: '8px',
vertical_spacing: '8px',
horizontal_align: 'left',
vertical_align: 'top',
padding: '12px 12px 12px 12px',
elements,
},
};
}
/**
* Build Feishu simple text message
*/
private buildFeishuText(context: NotificationContext): string {
const lines: string[] = [];
lines.push(`Event: ${this.formatEventName(context.eventType)}`);
if (context.sessionId) {
lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
}
if (context.questionText) {
const truncated = context.questionText.length > 200
? context.questionText.slice(0, 200) + '...'
: context.questionText;
lines.push(`Question: ${truncated}`);
}
if (context.taskDescription) {
const truncated = context.taskDescription.length > 200
? context.taskDescription.slice(0, 200) + '...'
: context.taskDescription;
lines.push(`Task: ${truncated}`);
}
if (context.errorMessage) {
const truncated = context.errorMessage.length > 200
? context.errorMessage.slice(0, 200) + '...'
: context.errorMessage;
lines.push(`Error: ${truncated}`);
}
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
return lines.join('\n');
}
/**
* Escape special characters for Feishu markdown
*/
private escapeFeishuMarkdown(text: string): string {
return text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Send DingTalk notification via webhook
*/
private async sendDingTalk(
context: NotificationContext,
config: DingTalkConfig,
timeout: number
): Promise<PlatformNotificationResult> {
const startTime = Date.now();
if (!config.webhookUrl) {
return { platform: 'dingtalk', success: false, error: 'Webhook URL not configured' };
}
const text = this.buildDingTalkText(context, config.keywords);
const body = {
msgtype: 'text',
text: {
content: text,
},
};
try {
await this.httpRequest(config.webhookUrl, body, timeout);
return {
platform: 'dingtalk',
success: true,
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
platform: 'dingtalk',
success: false,
error: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
};
}
}
/**
* Build DingTalk text message
*/
private buildDingTalkText(context: NotificationContext, keywords?: string[]): string {
const lines: string[] = [];
// Add keywords at the beginning if configured (for security check)
if (keywords && keywords.length > 0) {
lines.push(`[${keywords[0]}]`);
}
lines.push(`Event: ${this.formatEventName(context.eventType)}`);
if (context.sessionId) {
lines.push(`Session: ${context.sessionId.slice(0, 16)}...`);
}
if (context.questionText) {
const truncated = context.questionText.length > 200
? context.questionText.slice(0, 200) + '...'
: context.questionText;
lines.push(`Question: ${truncated}`);
}
if (context.taskDescription) {
const truncated = context.taskDescription.length > 200
? context.taskDescription.slice(0, 200) + '...'
: context.taskDescription;
lines.push(`Task: ${truncated}`);
}
if (context.errorMessage) {
const truncated = context.errorMessage.length > 200
? context.errorMessage.slice(0, 200) + '...'
: context.errorMessage;
lines.push(`Error: ${truncated}`);
}
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
return lines.join('\n');
}
/**
* Send WeCom (WeChat Work) notification via webhook
*/
private async sendWeCom(
context: NotificationContext,
config: WeComConfig,
timeout: number
): Promise<PlatformNotificationResult> {
const startTime = Date.now();
if (!config.webhookUrl) {
return { platform: 'wecom', success: false, error: 'Webhook URL not configured' };
}
const markdown = this.buildWeComMarkdown(context);
const body: Record<string, unknown> = {
msgtype: 'markdown',
markdown: {
content: markdown,
},
};
// Add mentioned list if configured
if (config.mentionedList && config.mentionedList.length > 0) {
body.text = {
content: markdown,
mentioned_list: config.mentionedList,
};
}
try {
await this.httpRequest(config.webhookUrl, body, timeout);
return {
platform: 'wecom',
success: true,
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
platform: 'wecom',
success: false,
error: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
};
}
}
/**
* Build WeCom markdown message
*/
private buildWeComMarkdown(context: NotificationContext): string {
const lines: string[] = [];
lines.push(`### ${this.formatEventName(context.eventType)}`);
lines.push('');
if (context.sessionId) {
lines.push(`> Session: \`${context.sessionId.slice(0, 16)}...\``);
}
if (context.questionText) {
const truncated = context.questionText.length > 200
? context.questionText.slice(0, 200) + '...'
: context.questionText;
lines.push(`**Question:** ${truncated}`);
}
if (context.taskDescription) {
const truncated = context.taskDescription.length > 200
? context.taskDescription.slice(0, 200) + '...'
: context.taskDescription;
lines.push(`**Task:** ${truncated}`);
}
if (context.errorMessage) {
const truncated = context.errorMessage.length > 200
? context.errorMessage.slice(0, 200) + '...'
: context.errorMessage;
lines.push(`**Error:** <font color="warning">${truncated}</font>`);
}
lines.push('');
lines.push(`Time: ${new Date(context.timestamp).toLocaleString()}`);
return lines.join('\n');
}
/**
* Send Email notification via SMTP
*/
private async sendEmail(
context: NotificationContext,
config: EmailConfig,
timeout: number
): Promise<PlatformNotificationResult> {
const startTime = Date.now();
if (!config.host || !config.username || !config.password || !config.from || !config.to || config.to.length === 0) {
return { platform: 'email', success: false, error: 'Email configuration incomplete (host, username, password, from, to required)' };
}
try {
// Dynamic import for nodemailer (optional dependency)
const nodemailer = await this.loadNodemailer();
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port || 465,
secure: config.secure !== false, // Default to true for port 465
auth: {
user: config.username,
pass: config.password,
},
});
const { subject, html } = this.buildEmailContent(context);
// Set timeout for email sending
await Promise.race([
transporter.sendMail({
from: config.from,
to: config.to.join(', '),
subject,
html,
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Email send timeout')), timeout)
),
]);
return {
platform: 'email',
success: true,
responseTime: Date.now() - startTime,
};
} catch (error) {
return {
platform: 'email',
success: false,
error: error instanceof Error ? error.message : String(error),
responseTime: Date.now() - startTime,
};
}
}
/**
* Load nodemailer module (optional dependency)
*/
private async loadNodemailer(): Promise<{
createTransport: (options: Record<string, unknown>) => {
sendMail: (mailOptions: Record<string, unknown>) => Promise<unknown>;
};
}> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('nodemailer');
} catch {
throw new Error('nodemailer not installed. Run: npm install nodemailer');
}
}
/**
* Build email subject and HTML content
*/
private buildEmailContent(context: NotificationContext): { subject: string; html: string } {
const subject = `[CCW] ${this.formatEventName(context.eventType)}`;
const htmlParts: string[] = [];
htmlParts.push('<!DOCTYPE html>');
htmlParts.push('<html>');
htmlParts.push('<head>');
htmlParts.push('<meta charset="utf-8">');
htmlParts.push('<style>');
htmlParts.push('body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; }');
htmlParts.push('.container { max-width: 600px; margin: 0 auto; padding: 20px; }');
htmlParts.push('.header { background: #4a90d9; color: white; padding: 20px; border-radius: 8px 8px 0 0; }');
htmlParts.push('.content { background: #f9f9f9; padding: 20px; border: 1px solid #ddd; }');
htmlParts.push('.field { margin-bottom: 15px; }');
htmlParts.push('.label { font-weight: bold; color: #555; }');
htmlParts.push('.value { margin-top: 5px; }');
htmlParts.push('.error { background: #fff3f3; border-left: 4px solid #e74c3c; padding: 10px; }');
htmlParts.push('.footer { text-align: center; color: #888; font-size: 12px; margin-top: 20px; }');
htmlParts.push('</style>');
htmlParts.push('</head>');
htmlParts.push('<body>');
htmlParts.push('<div class="container">');
// Header
htmlParts.push('<div class="header">');
htmlParts.push(`<h2 style="margin: 0;">${this.formatEventName(context.eventType)}</h2>`);
htmlParts.push('</div>');
// Content
htmlParts.push('<div class="content">');
if (context.sessionId) {
htmlParts.push('<div class="field">');
htmlParts.push('<div class="label">Session</div>');
htmlParts.push(`<div class="value"><code>${context.sessionId}</code></div>`);
htmlParts.push('</div>');
}
if (context.questionText) {
const truncated = context.questionText.length > 500
? context.questionText.slice(0, 500) + '...'
: context.questionText;
htmlParts.push('<div class="field">');
htmlParts.push('<div class="label">Question</div>');
htmlParts.push(`<div class="value">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
htmlParts.push('</div>');
}
if (context.taskDescription) {
const truncated = context.taskDescription.length > 500
? context.taskDescription.slice(0, 500) + '...'
: context.taskDescription;
htmlParts.push('<div class="field">');
htmlParts.push('<div class="label">Task</div>');
htmlParts.push(`<div class="value">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
htmlParts.push('</div>');
}
if (context.errorMessage) {
const truncated = context.errorMessage.length > 500
? context.errorMessage.slice(0, 500) + '...'
: context.errorMessage;
htmlParts.push('<div class="field">');
htmlParts.push('<div class="label">Error</div>');
htmlParts.push(`<div class="value error">${this.escapeHtml(truncated).replace(/\n/g, '<br>')}</div>`);
htmlParts.push('</div>');
}
htmlParts.push('<div class="field">');
htmlParts.push('<div class="label">Timestamp</div>');
htmlParts.push(`<div class="value">${new Date(context.timestamp).toLocaleString()}</div>`);
htmlParts.push('</div>');
htmlParts.push('</div>'); // content
// Footer
htmlParts.push('<div class="footer">');
htmlParts.push('Sent by CCW Remote Notification System');
htmlParts.push('</div>');
htmlParts.push('</div>'); // container
htmlParts.push('</body>');
htmlParts.push('</html>');
return { subject, html: htmlParts.join('\n') };
}
/**
* Check if a URL is safe from SSRF attacks
* Blocks private IP ranges, loopback, and link-local addresses
@@ -556,7 +1100,7 @@ class RemoteNotificationService {
*/
async testPlatform(
platform: NotificationPlatform,
config: DiscordConfig | TelegramConfig | WebhookConfig
config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
): Promise<{ success: boolean; error?: string; responseTime?: number }> {
const testContext: NotificationContext = {
eventType: 'task-completed',
@@ -575,6 +1119,14 @@ class RemoteNotificationService {
return await this.sendTelegram(testContext, config as TelegramConfig, 10000);
case 'webhook':
return await this.sendWebhook(testContext, config as WebhookConfig, 10000);
case 'feishu':
return await this.sendFeishu(testContext, config as FeishuConfig, 10000);
case 'dingtalk':
return await this.sendDingTalk(testContext, config as DingTalkConfig, 10000);
case 'wecom':
return await this.sendWeCom(testContext, config as WeComConfig, 10000);
case 'email':
return await this.sendEmail(testContext, config as EmailConfig, 30000); // Longer timeout for email
default:
return { success: false, error: `Unknown platform: ${platform}` };
}

View File

@@ -163,17 +163,22 @@ function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
type = 'input';
}
const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
value: opt.label,
label: opt.label,
description: opt.description,
}));
let defaultValue: string | undefined;
const options: QuestionOption[] | undefined = simple.options?.map((opt) => {
const isDefault = opt.isDefault === true
|| /\(Recommended\)/i.test(opt.label);
if (isDefault && !defaultValue) {
defaultValue = opt.label;
}
return { value: opt.label, label: opt.label, description: opt.description };
});
return {
id: simple.header,
type,
title: simple.question,
options,
...(defaultValue !== undefined && { defaultValue }),
} as Question;
}
@@ -192,7 +197,7 @@ function isSimpleFormat(params: Record<string, unknown>): params is { questions:
* @param surfaceId - Surface ID for the question
* @returns A2UI surface update object
*/
function generateQuestionSurface(question: Question, surfaceId: string): {
function generateQuestionSurface(question: Question, surfaceId: string, timeoutMs: number): {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
@@ -274,6 +279,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
})) || [];
// Add "Other" option for custom input
@@ -281,6 +287,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
isDefault: false,
});
// Use RadioGroup for direct selection display (not dropdown)
@@ -411,6 +418,8 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
questionType: question.type,
options: question.options,
required: question.required,
timeoutAt: new Date(Date.now() + timeoutMs).toISOString(),
...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }),
},
/** Display mode: 'popup' for centered dialog (interactive questions) */
displayMode: 'popup' as const,
@@ -451,20 +460,31 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
setTimeout(() => {
if (pendingQuestions.has(question.id)) {
pendingQuestions.delete(question.id);
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
if (question.defaultValue !== undefined) {
resolve({
success: true,
surfaceId,
cancelled: false,
answers: [{ questionId: question.id, value: question.defaultValue as string | string[] | boolean, cancelled: false }],
timestamp: new Date().toISOString(),
autoSelected: true,
});
} else {
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
}
}
}, params.timeout || DEFAULT_TIMEOUT_MS);
});
// Send A2UI surface via WebSocket to frontend
const a2uiSurface = generateQuestionSurface(question, surfaceId);
const a2uiSurface = generateQuestionSurface(question, surfaceId, params.timeout || DEFAULT_TIMEOUT_MS);
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
// Trigger remote notification for ask-user-question event (if enabled)
@@ -594,9 +614,17 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
if (isComposite && Array.isArray(parsed.answers)) {
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
if (!ok && pendingQuestions.has(questionId)) {
// Answer consumed but delivery failed; keep polling for a new answer
setTimeout(poll, POLL_INTERVAL_MS);
}
} else if (!isComposite && parsed.answer) {
const ok = handleAnswer(parsed.answer as QuestionAnswer);
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
if (!ok && pendingQuestions.has(questionId)) {
// Answer consumed but validation/delivery failed; keep polling for a new answer
setTimeout(poll, POLL_INTERVAL_MS);
}
} else {
console.error(`[A2UI-Poll] Unexpected response shape, keep polling`);
setTimeout(poll, POLL_INTERVAL_MS);
@@ -873,6 +901,7 @@ function generateMultiQuestionSurface(
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue),
})) || [];
// Add "Other" option for custom input
@@ -880,6 +909,7 @@ function generateMultiQuestionSurface(
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
isDefault: false,
});
components.push({
@@ -997,7 +1027,8 @@ async function executeSimpleFormat(
return result;
}
if (result.result.cancelled) {
// Propagate inner failures (e.g. timeout) — don't mask them as success
if (result.result.cancelled || !result.result.success) {
return result;
}
@@ -1058,14 +1089,33 @@ async function executeSimpleFormat(
setTimeout(() => {
if (pendingQuestions.has(compositeId)) {
pendingQuestions.delete(compositeId);
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
// Collect default values from each sub-question
const defaultAnswers: QuestionAnswer[] = [];
for (const simpleQ of questions) {
const q = normalizeSimpleQuestion(simpleQ);
if (q.defaultValue !== undefined) {
defaultAnswers.push({ questionId: q.id, value: q.defaultValue as string | string[] | boolean, cancelled: false });
}
}
if (defaultAnswers.length > 0) {
resolve({
success: true,
surfaceId,
cancelled: false,
answers: defaultAnswers,
timestamp: new Date().toISOString(),
autoSelected: true,
});
} else {
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
}
}
}, timeout ?? DEFAULT_TIMEOUT_MS);
});

View File

@@ -2,12 +2,12 @@
// Remote Notification Types
// ========================================
// Type definitions for remote notification system
// Supports Discord, Telegram, and Generic Webhook platforms
// Supports Discord, Telegram, Feishu, DingTalk, WeCom, Email, and Generic Webhook platforms
/**
* Supported notification platforms
*/
export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
/**
* Event types that can trigger notifications
@@ -47,6 +47,66 @@ export interface TelegramConfig {
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
/**
* Feishu (Lark) platform configuration
*/
export interface FeishuConfig {
/** Whether Feishu notifications are enabled */
enabled: boolean;
/** Feishu webhook URL */
webhookUrl: string;
/** Use rich card format (default: true) */
useCard?: boolean;
/** Custom title for notifications */
title?: string;
}
/**
* DingTalk platform configuration
*/
export interface DingTalkConfig {
/** Whether DingTalk notifications are enabled */
enabled: boolean;
/** DingTalk webhook URL */
webhookUrl: string;
/** Optional keywords for security check */
keywords?: string[];
}
/**
* WeCom (WeChat Work) platform configuration
*/
export interface WeComConfig {
/** Whether WeCom notifications are enabled */
enabled: boolean;
/** WeCom webhook URL */
webhookUrl: string;
/** Mentioned user IDs (@all for all members) */
mentionedList?: string[];
}
/**
* Email SMTP platform configuration
*/
export interface EmailConfig {
/** Whether Email notifications are enabled */
enabled: boolean;
/** SMTP server host */
host: string;
/** SMTP server port */
port: number;
/** Use secure connection (TLS) */
secure?: boolean;
/** SMTP username */
username: string;
/** SMTP password */
password: string;
/** Sender email address */
from: string;
/** Recipient email addresses */
to: string[];
}
/**
* Generic Webhook platform configuration
*/
@@ -85,6 +145,10 @@ export interface RemoteNotificationConfig {
platforms: {
discord?: DiscordConfig;
telegram?: TelegramConfig;
feishu?: FeishuConfig;
dingtalk?: DingTalkConfig;
wecom?: WeComConfig;
email?: EmailConfig;
webhook?: WebhookConfig;
};
/** Event-to-platform mappings */
@@ -192,6 +256,22 @@ export function maskSensitiveConfig(config: RemoteNotificationConfig): RemoteNot
...config.platforms.telegram,
botToken: maskToken(config.platforms.telegram.botToken),
} : undefined,
feishu: config.platforms.feishu ? {
...config.platforms.feishu,
webhookUrl: maskWebhookUrl(config.platforms.feishu.webhookUrl),
} : undefined,
dingtalk: config.platforms.dingtalk ? {
...config.platforms.dingtalk,
webhookUrl: maskWebhookUrl(config.platforms.dingtalk.webhookUrl),
} : undefined,
wecom: config.platforms.wecom ? {
...config.platforms.wecom,
webhookUrl: maskWebhookUrl(config.platforms.wecom.webhookUrl),
} : undefined,
email: config.platforms.email ? {
...config.platforms.email,
password: maskToken(config.platforms.email.password),
} : undefined,
webhook: config.platforms.webhook ? {
...config.platforms.webhook,
// Don't mask webhook URL as it's needed for display