mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}` };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user