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

@@ -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}` };
}