@cuebot/skill 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * 通知推送模块
3
+ * 复用 OpenClaw 的消息发送能力 + 可靠队列
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { createLogger } from '../core/logger.js';
8
+ import { enqueueNotification, startNotificationProcessor } from '../utils/notificationQueue.js';
9
+
10
+ const logger = createLogger('Notifier');
11
+
12
+ // 启动后台通知处理器
13
+ let processorStarted = false;
14
+
15
+ /**
16
+ * 启动可靠通知系统
17
+ * @param {string} chatId
18
+ */
19
+ export function startReliableNotifier(chatId) {
20
+ if (processorStarted) return;
21
+
22
+ startNotificationProcessor(chatId, async (notif) => {
23
+ try {
24
+ const success = await sendMessageDirect(notif.chat_id, formatNotification(notif));
25
+ return success;
26
+ } catch (error) {
27
+ await logger.error('Failed to send notification', error);
28
+ return false;
29
+ }
30
+ });
31
+
32
+ processorStarted = true;
33
+ logger.info('Reliable notification processor started');
34
+ }
35
+
36
+ /**
37
+ * 格式化通知内容
38
+ * @param {Object} notif
39
+ * @returns {string}
40
+ */
41
+ function formatNotification(notif) {
42
+ switch (notif.type) {
43
+ case 'research_complete':
44
+ return formatResearchComplete(notif.data);
45
+ case 'progress':
46
+ return formatProgress(notif.data);
47
+ case 'monitor_trigger':
48
+ return formatMonitorTrigger(notif.data);
49
+ default:
50
+ return JSON.stringify(notif.data);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 直接发送消息(不经过队列)
56
+ * @param {string} chatId
57
+ * @param {string} text
58
+ * @param {string} channel
59
+ * @returns {Promise<boolean>}
60
+ */
61
+ async function sendMessageDirect(chatId, text, channel = 'feishu') {
62
+ try {
63
+ // 方法1: 使用 OpenClaw CLI
64
+ const result = execSync(
65
+ `openclaw message send --channel ${channel} --target "${chatId}" --text "${text}"`,
66
+ { encoding: 'utf-8', timeout: 10000 }
67
+ );
68
+ await logger.info(`Message sent to ${chatId}`);
69
+ return true;
70
+ } catch (error) {
71
+ await logger.error(`Failed to send message via OpenClaw`, error);
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 发送消息(经过可靠队列)
78
+ * @param {string} chatId - 目标聊天 ID
79
+ * @param {string} text - 消息内容
80
+ * @param {string} channel - 渠道(默认 feishu)
81
+ * @returns {Promise<boolean>}
82
+ */
83
+ export async function sendMessage(chatId, text, channel = 'feishu') {
84
+ // 先尝试直接发送
85
+ const directSuccess = await sendMessageDirect(chatId, text, channel);
86
+
87
+ if (directSuccess) {
88
+ return true;
89
+ }
90
+
91
+ // 如果失败,加入队列重试
92
+ await logger.info(`Direct send failed, enqueueing for retry: ${chatId}`);
93
+
94
+ await enqueueNotification({
95
+ chatId,
96
+ type: 'generic',
97
+ data: { text, channel }
98
+ });
99
+
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * 格式化研究完成通知
105
+ * @param {Object} data
106
+ * @returns {string}
107
+ */
108
+ function formatResearchComplete(data) {
109
+ const { topic, taskId, reportUrl, duration, monitorSuggestions = [] } = data;
110
+
111
+ const monitorText = monitorSuggestions.length > 0
112
+ ? `\n🔔 建议监控:${monitorSuggestions.join('、')} 等 ${monitorSuggestions.length} 项\n💡 回复 Y 创建,N 跳过`
113
+ : '';
114
+
115
+ return `✅ 研究完成:${topic}
116
+
117
+ ⏱️ 耗时:${duration} 分钟
118
+ 📝 任务ID:${taskId}
119
+
120
+ 🔗 ${reportUrl}
121
+ ${monitorText}`;
122
+ }
123
+
124
+ /**
125
+ * 发送研究完成通知
126
+ * @param {Object} options
127
+ * @param {string} options.chatId - 聊天 ID
128
+ * @param {string} options.taskId - 任务 ID
129
+ * @param {string} options.topic - 研究主题
130
+ * @param {string} options.reportUrl - 报告链接
131
+ * @param {number} options.duration - 耗时(分钟)
132
+ * @param {Array} options.monitorSuggestions - 监控建议
133
+ */
134
+ export async function sendResearchCompleteNotification({
135
+ chatId,
136
+ taskId,
137
+ topic,
138
+ reportUrl,
139
+ duration,
140
+ monitorSuggestions = []
141
+ }) {
142
+ // 启动可靠通知系统
143
+ startReliableNotifier(chatId);
144
+
145
+ // 直接发送
146
+ const message = formatResearchComplete({ topic, taskId, reportUrl, duration, monitorSuggestions });
147
+ const success = await sendMessage(chatId, message);
148
+
149
+ if (!success) {
150
+ // 如果直接发送失败,加入队列
151
+ await enqueueNotification({
152
+ chatId,
153
+ type: 'research_complete',
154
+ data: { topic, taskId, reportUrl, duration, monitorSuggestions }
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 格式化进度通知
161
+ * @param {Object} data
162
+ * @returns {string}
163
+ */
164
+ function formatProgress(data) {
165
+ const { topic, elapsedMinutes } = data;
166
+
167
+ const stageDescriptions = {
168
+ 0: '初始化研究任务',
169
+ 10: '全网信息搜集与初步筛选',
170
+ 30: '多源交叉验证与事实核查',
171
+ 50: '深度分析与逻辑推理',
172
+ 60: '报告生成与质量检查'
173
+ };
174
+
175
+ const stage = Object.keys(stageDescriptions)
176
+ .map(Number)
177
+ .filter(t => elapsedMinutes >= t)
178
+ .pop() || 0;
179
+
180
+ return `🔄 研究进度更新
181
+
182
+ 📋 主题:${topic}
183
+ ⏱️ 已用时:${elapsedMinutes} 分钟
184
+ 📊 当前阶段:${stageDescriptions[stage]}
185
+
186
+ 预计剩余时间:${60 - elapsedMinutes} 分钟`;
187
+ }
188
+
189
+ /**
190
+ * 发送进度更新通知
191
+ * @param {Object} options
192
+ * @param {string} options.chatId - 聊天 ID
193
+ * @param {string} options.taskId - 任务 ID
194
+ * @param {string} options.topic - 研究主题
195
+ * @param {string} options.progress - 进度描述
196
+ * @param {number} options.elapsedMinutes - 已耗时(分钟)
197
+ */
198
+ export async function sendProgressNotification({
199
+ chatId,
200
+ taskId,
201
+ topic,
202
+ progress,
203
+ elapsedMinutes
204
+ }) {
205
+ // 进度通知重要性较低,直接发送即可
206
+ const message = formatProgress({ topic, elapsedMinutes });
207
+ await sendMessage(chatId, message);
208
+ }
209
+
210
+ /**
211
+ * 格式化监控触发通知
212
+ * @param {Object} data
213
+ * @returns {string}
214
+ */
215
+ function formatMonitorTrigger(data) {
216
+ const { monitorTitle, message, category = 'Data' } = data;
217
+ const timestamp = new Date().toLocaleString('zh-CN');
218
+
219
+ return `🔔 监控触发提醒
220
+
221
+ 📊 监控:${monitorTitle}
222
+ 📂 分类:${category}
223
+ ⏰ 时间:${timestamp}
224
+
225
+ ${message}
226
+
227
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
228
+ 💡 使用 /cn 查看最近通知`;
229
+ }
230
+
231
+ /**
232
+ * 发送监控触发通知
233
+ * @param {Object} options
234
+ * @param {string} options.chatId - 聊天 ID
235
+ * @param {string} options.monitorId - 监控 ID
236
+ * @param {string} options.monitorTitle - 监控标题
237
+ * @param {string} options.message - 触发消息
238
+ * @param {string} options.category - 分类
239
+ */
240
+ export async function sendMonitorTriggerNotification({
241
+ chatId,
242
+ monitorId,
243
+ monitorTitle,
244
+ message,
245
+ category = 'Data'
246
+ }) {
247
+ // 启动可靠通知系统
248
+ startReliableNotifier(chatId);
249
+
250
+ // 直接发送
251
+ const notification = formatMonitorTrigger({ monitorTitle, message, category });
252
+ const success = await sendMessage(chatId, notification);
253
+
254
+ if (!success) {
255
+ // 如果失败,加入队列(监控触发很重要)
256
+ await enqueueNotification({
257
+ chatId,
258
+ type: 'monitor_trigger',
259
+ data: { monitorId, monitorTitle, message, category }
260
+ });
261
+ }
262
+ }
263
+
264
+ /**
265
+ * 格式化监控建议通知
266
+ * @param {Object} data
267
+ * @returns {string}
268
+ */
269
+ function formatMonitorSuggestion(data) {
270
+ const { topic, suggestions } = data;
271
+
272
+ const suggestionsText = suggestions
273
+ .map((s, i) => `${i + 1}. ${s.title} - ${s.description}`)
274
+ .join('\n');
275
+
276
+ return `💡 监控建议
277
+
278
+ 研究主题:${topic}
279
+
280
+ 基于研究报告,建议关注以下监控项:
281
+ ${suggestionsText}
282
+
283
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
284
+ 回复 Y 确认创建这些监控项
285
+ 回复 N 跳过`;
286
+ }
287
+
288
+ /**
289
+ * 发送监控建议通知
290
+ * @param {Object} options
291
+ * @param {string} options.chatId - 聊天 ID
292
+ * @param {string} options.taskId - 任务 ID
293
+ * @param {string} options.topic - 研究主题
294
+ * @param {Array} options.suggestions - 监控建议列表
295
+ */
296
+ export async function sendMonitorSuggestionNotification({
297
+ chatId,
298
+ taskId,
299
+ topic,
300
+ suggestions
301
+ }) {
302
+ if (!suggestions || suggestions.length === 0) return;
303
+
304
+ // 启动可靠通知系统
305
+ startReliableNotifier(chatId);
306
+
307
+ // 直接发送
308
+ const message = formatMonitorSuggestion({ topic, suggestions });
309
+ const success = await sendMessage(chatId, message);
310
+
311
+ if (!success) {
312
+ // 如果失败,加入队列
313
+ await enqueueNotification({
314
+ chatId,
315
+ type: 'monitor_suggestion',
316
+ data: { taskId, topic, suggestions }
317
+ });
318
+ }
319
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 数据源管理器
3
+ * 支持多种搜索/数据获取方式
4
+ */
5
+
6
+ import { createLogger } from '../core/logger.js';
7
+
8
+ const logger = createLogger('DataSource');
9
+
10
+ /**
11
+ * 数据源配置
12
+ */
13
+ export const DATA_SOURCES = {
14
+ // 付费搜索 API
15
+ tavily: {
16
+ name: 'Tavily',
17
+ envKey: 'TAVILY_API_KEY',
18
+ priority: 1,
19
+ type: 'search'
20
+ },
21
+ qveris: {
22
+ name: 'QVeris',
23
+ envKey: 'QVERIS_API_KEY',
24
+ priority: 2,
25
+ type: 'search'
26
+ },
27
+
28
+ // 免费股票数据 API
29
+ yahoo: {
30
+ name: 'Yahoo Finance',
31
+ envKey: null, // 免费
32
+ priority: 10,
33
+ type: 'stock',
34
+ endpoints: {
35
+ quote: 'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}',
36
+ search: 'https://finance.yahoo.com/quote/{symbol}'
37
+ }
38
+ },
39
+ google: {
40
+ name: 'Google Finance',
41
+ envKey: null,
42
+ priority: 11,
43
+ type: 'stock',
44
+ endpoints: {
45
+ quote: 'https://www.google.com/finance/quote/{symbol}:EX'
46
+ }
47
+ },
48
+ tencent: {
49
+ name: '腾讯财经',
50
+ envKey: null,
51
+ priority: 12,
52
+ type: 'stock',
53
+ endpoints: {
54
+ quote: 'https://qt.gtimg.cn/q={symbol}'
55
+ }
56
+ }
57
+ };
58
+
59
+ /**
60
+ * 获取可用的数据源
61
+ * @param {string} type - 数据类型 (search/stock)
62
+ * @returns {Array} 可用数据源列表
63
+ */
64
+ export function getAvailableSources(type) {
65
+ const sources = [];
66
+
67
+ for (const [key, config] of Object.entries(DATA_SOURCES)) {
68
+ if (config.type !== type) continue;
69
+
70
+ // 检查是否有 API Key(如果是需要 key 的源)
71
+ if (config.envKey && !process.env[config.envKey]) {
72
+ continue;
73
+ }
74
+
75
+ sources.push({ key, ...config });
76
+ }
77
+
78
+ // 按优先级排序
79
+ sources.sort((a, b) => a.priority - b.priority);
80
+
81
+ return sources;
82
+ }
83
+
84
+ /**
85
+ * 获取股票数据
86
+ * @param {string} symbol - 股票代码
87
+ * @returns {Promise<Object>}
88
+ */
89
+ export async function fetchStockData(symbol) {
90
+ const sources = getAvailableSources('stock');
91
+
92
+ for (const source of sources) {
93
+ try {
94
+ logger.info(`Trying ${source.name} for ${symbol}`);
95
+ const data = await fetchFromSource(source, symbol);
96
+ if (data) return data;
97
+ } catch (e) {
98
+ logger.warn(`${source.name} failed: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ async function fetchFromSource(source, symbol) {
106
+ const endpoint = source.endpoints?.quote;
107
+ if (!endpoint) return null;
108
+
109
+ const url = endpoint.replace('{symbol}', symbol);
110
+
111
+ const response = await fetch(url, {
112
+ headers: {
113
+ 'User-Agent': 'Mozilla/5.0'
114
+ }
115
+ });
116
+
117
+ if (!response.ok) return null;
118
+
119
+ return await response.json();
120
+ }
121
+
122
+ /**
123
+ * 搜索新闻/信息
124
+ * @param {string} query - 查询
125
+ * @returns {Promise<Object>}
126
+ */
127
+ export async function searchNews(query) {
128
+ const sources = getAvailableSources('search');
129
+
130
+ for (const source of sources) {
131
+ try {
132
+ logger.info(`Searching with ${source.name}`);
133
+ // 实现各源搜索
134
+ } catch (e) {
135
+ logger.warn(`${source.name} failed`);
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * 环境变量工具 - 安全版本 v1.0.4
3
+ * 仅使用 ~/.cuecue 目录,不写入共享配置文件
4
+ */
5
+
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { homedir } from 'os';
9
+ import { createLogger } from '../core/logger.js';
10
+
11
+ const logger = createLogger('EnvUtils');
12
+
13
+ // ✅ 安全修复:仅使用技能自己的目录
14
+ const CUECUE_DIR = path.join(homedir(), '.cuecue');
15
+ const SECURE_ENV_FILE = path.join(CUECUE_DIR, '.env.secure');
16
+
17
+ /**
18
+ * 确保目录存在并设置权限
19
+ * @param {string} dir - 目录路径
20
+ * @param {number} mode - 权限模式 (默认 0o700)
21
+ */
22
+ async function ensureSecureDir(dir, mode = 0o700) {
23
+ await fs.ensureDir(dir);
24
+
25
+ // 设置权限:仅所有者可读写执行
26
+ try {
27
+ await fs.chmod(dir, mode);
28
+ } catch (error) {
29
+ await logger.warn(`Failed to set directory permissions for ${dir}`, error);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 加载环境变量文件
35
+ * @returns {Promise<Map<string, string>>}
36
+ */
37
+ export async function loadEnvFile() {
38
+ const env = new Map();
39
+
40
+ try {
41
+ const content = await fs.readFile(SECURE_ENV_FILE, 'utf-8');
42
+ const lines = content.split('\n');
43
+
44
+ for (const line of lines) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith('#')) continue;
47
+
48
+ const match = trimmed.match(/^export\s+(\w+)="([^"]*)"$/);
49
+ if (match) {
50
+ env.set(match[1], match[2]);
51
+ }
52
+ }
53
+ } catch (error) {
54
+ // 文件不存在,返回空 Map
55
+ }
56
+
57
+ return env;
58
+ }
59
+
60
+ /**
61
+ * 保存环境变量到文件
62
+ * @param {Map<string, string>} env - 环境变量 Map
63
+ */
64
+ export async function saveEnvFile(env) {
65
+ // ✅ 安全修复:确保目录存在并设置权限
66
+ await ensureSecureDir(CUECUE_DIR, 0o700);
67
+
68
+ const lines = ['# Cue v1.0.4 - Secure Environment Variables', '# DO NOT SHARE THIS FILE'];
69
+ for (const [key, value] of env) {
70
+ lines.push(`export ${key}="${value}"`);
71
+ }
72
+
73
+ await fs.writeFile(SECURE_ENV_FILE, lines.join('\n') + '\n');
74
+
75
+ // ✅ 安全修复:设置文件权限 600 (仅所有者可读写)
76
+ try {
77
+ await fs.chmod(SECURE_ENV_FILE, 0o600);
78
+ await logger.info('Secure env file created with permissions 600');
79
+ } catch (error) {
80
+ await logger.warn('Failed to set secure file permissions', error);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 获取 API Key
86
+ * 优先从环境变量读取,其次从安全文件读取
87
+ * @param {string} keyName - Key 名称
88
+ * @returns {Promise<string|null>}
89
+ */
90
+ export async function getApiKey(keyName) {
91
+ // 1. 首先检查 process.env (由 OpenClaw 注入)
92
+ if (process.env[keyName]) {
93
+ return process.env[keyName];
94
+ }
95
+
96
+ // 2. 然后检查技能自己的安全文件
97
+ const env = await loadEnvFile();
98
+ return env.get(keyName) || null;
99
+ }
100
+
101
+ /**
102
+ * 设置 API Key
103
+ * 仅保存到技能自己的目录,不写入共享配置文件
104
+ * @param {string} keyName - Key 名称
105
+ * @param {string} value - Key 值
106
+ */
107
+ export async function setApiKey(keyName, value) {
108
+ // 更新当前进程环境变量
109
+ process.env[keyName] = value;
110
+
111
+ // ✅ 安全修复:仅保存到 ~/.cuecue/.env.secure
112
+ const env = await loadEnvFile();
113
+ env.set(keyName, value);
114
+ await saveEnvFile(env);
115
+
116
+ await logger.info("API Key saved to secure storage");
117
+ }
118
+
119
+ /**
120
+ * 检测 API Key 对应的服务
121
+ * @param {string} apiKey - API Key
122
+ * @returns {{service: string, name: string, url: string}|null}
123
+ */
124
+ export function detectServiceFromKey(apiKey) {
125
+ if (!apiKey || apiKey.length < 10) {
126
+ return null;
127
+ }
128
+
129
+ // Tavily: tvly-xxxxx
130
+ if (apiKey.startsWith('tvly-')) {
131
+ return {
132
+ service: 'tavily',
133
+ name: 'Tavily',
134
+ url: 'https://tavily.com'
135
+ };
136
+ }
137
+
138
+ // CueCue: skb... 开头
139
+ if (apiKey.startsWith('skb')) {
140
+ return {
141
+ service: 'cuecue',
142
+ name: 'CueCue',
143
+ url: 'https://cuecue.cn'
144
+ };
145
+ }
146
+
147
+ // 根据长度区分 CueCue 和 QVeris
148
+ if (apiKey.startsWith('sk-')) {
149
+ if (apiKey.length > 40) {
150
+ return {
151
+ service: 'qveris',
152
+ name: 'QVeris',
153
+ url: 'https://qveris.ai'
154
+ };
155
+ } else {
156
+ return {
157
+ service: 'cuecue',
158
+ name: 'CueCue',
159
+ url: 'https://cuecue.cn'
160
+ };
161
+ }
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * 获取所有 API Key 状态
169
+ * @returns {Promise<Array<{name: string, key: string, configured: boolean, masked: string}>>}
170
+ */
171
+ export async function getApiKeyStatus() {
172
+ const keys = [
173
+ { name: 'CueCue', key: 'CUECUE_API_KEY' },
174
+ { name: 'Tavily', key: 'TAVILY_API_KEY' },
175
+ { name: 'QVeris', key: 'QVERIS_API_KEY' }
176
+ ];
177
+
178
+ const results = [];
179
+ for (const { name, key } of keys) {
180
+ const value = await getApiKey(key);
181
+ const masked = value
182
+ ? (value.length > 16
183
+ ? `${value.slice(0, 4)}****${value.slice(-4)}`
184
+ : `${value.slice(0, 2)}****`)
185
+ : null;
186
+
187
+ results.push({
188
+ name,
189
+ key,
190
+ configured: !!value,
191
+ masked
192
+ });
193
+ }
194
+
195
+ return results;
196
+ }
197
+
198
+ /**
199
+ * 获取当前渠道配置
200
+ * @returns {{channel: string, chatId: string}}
201
+ */
202
+ export function getChannelConfig() {
203
+ return {
204
+ channel: process.env.OPENCLAW_CHANNEL || 'feishu',
205
+ chatId: process.env.CHAT_ID || process.env.FEISHU_CHAT_ID || 'default'
206
+ };
207
+ }
208
+
209
+ /**
210
+ * 验证安全设置
211
+ * 检查目录和文件权限
212
+ * @returns {Promise<{secure: boolean, issues: string[]}>}
213
+ */
214
+ export async function validateSecurity() {
215
+ const issues = [];
216
+
217
+ // 检查目录权限
218
+ try {
219
+ const stats = await fs.stat(CUECUE_DIR);
220
+ const mode = stats.mode & 0o777;
221
+ if (mode !== 0o700) {
222
+ issues.push(`Directory permissions should be 700, got ${mode.toString(8)}`);
223
+ }
224
+ } catch (error) {
225
+ issues.push('CueCue directory does not exist');
226
+ }
227
+
228
+ // 检查文件权限
229
+ try {
230
+ const stats = await fs.stat(SECURE_ENV_FILE);
231
+ const mode = stats.mode & 0o777;
232
+ if (mode !== 0o600) {
233
+ issues.push(`Env file permissions should be 600, got ${mode.toString(8)}`);
234
+ }
235
+ } catch (error) {
236
+ // 文件不存在不算错误
237
+ }
238
+
239
+ return {
240
+ secure: issues.length === 0,
241
+ issues
242
+ };
243
+ }