@cuebot/skill 1.0.5 → 1.0.6

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.
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 可靠通知队列
3
- * 本地持久化 + 失败重试机制
3
+ * 优先使用 OpenClaw Queue,本地队列作为 fallback
4
+ * 支持自动重试和统计
4
5
  */
5
6
 
6
7
  import fs from 'fs-extra';
@@ -10,302 +11,189 @@ import { createLogger } from '../core/logger.js';
10
11
 
11
12
  const logger = createLogger('NotificationQueue');
12
13
 
14
+ const MAX_RETRY = 3;
15
+ const RETRY_DELAYS = [5000, 15000, 60000]; // 重试延迟: 5s, 15s, 60s
16
+
17
+ /**
18
+ * 检测是否在 OpenClaw 环境中
19
+ */
20
+ function isOpenClawEnv() {
21
+ return process.env.OPENCLAW_MODE === 'gateway' ||
22
+ process.env.OPENCLAW_GATEWAY_PORT ||
23
+ process.env.OPENCLAW_HOME;
24
+ }
25
+
13
26
  /**
14
- * 获取通知队列目录
15
- * @param {string} chatId
16
- * @returns {string}
27
+ * 获取通知队列目录 (本地 fallback)
17
28
  */
18
- function getQueueDir(chatId) {
29
+ function getLocalQueueDir(chatId) {
19
30
  return path.join(getUserDir(chatId), 'notification-queue');
20
31
  }
21
32
 
22
33
  /**
23
- * 初始化队列目录
24
- * @param {string} chatId
34
+ * 初始化队列目录 (本地 fallback)
25
35
  */
26
36
  async function initQueue(chatId) {
27
- const queueDir = getQueueDir(chatId);
28
- await fs.ensureDir(queueDir);
37
+ if (isOpenClawEnv()) return;
29
38
 
30
- // 创建子目录
39
+ const queueDir = getLocalQueueDir(chatId);
40
+ await fs.ensureDir(queueDir);
31
41
  await fs.ensureDir(path.join(queueDir, 'pending'));
32
42
  await fs.ensureDir(path.join(queueDir, 'failed'));
33
43
  await fs.ensureDir(path.join(queueDir, 'sent'));
44
+ await fs.ensureDir(path.join(queueDir, 'processing'));
34
45
  }
35
46
 
36
- /**
37
- * 生成通知 ID
38
- * @returns {string}
39
- */
40
47
  function generateNotificationId() {
41
48
  return `notif_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
42
49
  }
43
50
 
44
51
  /**
45
52
  * 添加通知到队列
46
- * @param {Object} notification
47
- * @param {string} notification.chatId
48
- * @param {string} notification.type - 类型: 'research_complete', 'monitor_trigger', 'progress'
49
- * @param {Object} notification.data - 通知数据
50
- * @returns {Promise<string>} 通知 ID
51
53
  */
52
- export async function enqueueNotification({ chatId, type, data }) {
54
+ export async function enqueueNotification(notification) {
55
+ const { chatId, type, data } = notification;
56
+
57
+ if (isOpenClawEnv()) {
58
+ try {
59
+ const { execSync } = await import('child_process');
60
+ const queueName = `cue-notifications-${chatId}`;
61
+ execSync(`openclaw queue add ${queueName} "${JSON.stringify(notification).replace(/"/g, '\\"')}"`, {
62
+ stdio: 'ignore'
63
+ });
64
+ logger.info(`Notification enqueued to OpenClaw Queue: ${queueName}`);
65
+ return generateNotificationId();
66
+ } catch (err) {
67
+ logger.warn('OpenClaw Queue failed, using local fallback');
68
+ }
69
+ }
70
+
71
+ const queueDir = getLocalQueueDir(chatId);
53
72
  await initQueue(chatId);
54
73
 
55
- const notificationId = generateNotificationId();
56
- const notification = {
57
- id: notificationId,
58
- chat_id: chatId,
74
+ const notifId = generateNotificationId();
75
+ const filePath = path.join(queueDir, 'pending', `${notifId}.json`);
76
+
77
+ await fs.writeJson(filePath, {
78
+ id: notifId,
79
+ chatId,
59
80
  type,
60
81
  data,
61
- status: 'pending',
62
82
  created_at: new Date().toISOString(),
63
83
  retry_count: 0,
64
- max_retries: 3
65
- };
66
-
67
- const queueDir = getQueueDir(chatId);
68
- const filePath = path.join(queueDir, 'pending', `${notificationId}.json`);
69
-
70
- await fs.writeJson(filePath, notification, { spaces: 2 });
71
- await logger.info(`Notification enqueued: ${notificationId} (${type})`);
84
+ max_retry: MAX_RETRY
85
+ }, { spaces: 2 });
72
86
 
73
- return notificationId;
87
+ logger.info(`Notification enqueued locally: ${notifId}`);
88
+ return notifId;
74
89
  }
75
90
 
76
91
  /**
77
- * 获取待发送通知
78
- * @param {string} chatId
79
- * @returns {Promise<Array>}
92
+ * 获取队列统计
80
93
  */
81
- export async function getPendingNotifications(chatId) {
82
- try {
83
- const queueDir = getQueueDir(chatId);
84
- const pendingDir = path.join(queueDir, 'pending');
85
-
86
- const files = await fs.readdir(pendingDir);
87
- const notifications = [];
88
-
89
- for (const file of files.filter(f => f.endsWith('.json'))) {
90
- try {
91
- const notif = await fs.readJson(path.join(pendingDir, file));
92
- if (notif.status === 'pending') {
93
- notifications.push(notif);
94
- }
95
- } catch (e) {
96
- // 忽略读取错误
97
- }
98
- }
99
-
100
- // 按创建时间排序
101
- return notifications.sort((a, b) =>
102
- new Date(a.created_at) - new Date(b.created_at)
103
- );
104
- } catch (error) {
105
- return [];
94
+ export async function getQueueStats(chatId) {
95
+ if (isOpenClawEnv()) {
96
+ try {
97
+ const { execSync } = await import('child_process');
98
+ const result = execSync(`openclaw queue status cue-notifications-${chatId}`, {
99
+ encoding: 'utf-8'
100
+ });
101
+ return { source: 'openclaw', stats: result };
102
+ } catch {}
106
103
  }
107
- }
108
-
109
- /**
110
- * 标记通知为已发送
111
- * @param {string} chatId
112
- * @param {string} notificationId
113
- */
114
- export async function markNotificationSent(chatId, notificationId) {
115
- try {
116
- const queueDir = getQueueDir(chatId);
117
- const pendingPath = path.join(queueDir, 'pending', `${notificationId}.json`);
118
- const sentPath = path.join(queueDir, 'sent', `${notificationId}.json`);
119
-
120
- // 读取并更新状态
121
- const notification = await fs.readJson(pendingPath);
122
- notification.status = 'sent';
123
- notification.sent_at = new Date().toISOString();
124
-
125
- // 移动到 sent 目录
126
- await fs.writeJson(sentPath, notification, { spaces: 2 });
127
- await fs.remove(pendingPath);
128
-
129
- await logger.info(`Notification marked as sent: ${notificationId}`);
130
- } catch (error) {
131
- await logger.error(`Failed to mark notification as sent: ${notificationId}`, error);
132
- }
133
- }
134
-
135
- /**
136
- * 标记通知为失败
137
- * @param {string} chatId
138
- * @param {string} notificationId
139
- * @param {string} error
140
- */
141
- export async function markNotificationFailed(chatId, notificationId, error) {
142
- try {
143
- const queueDir = getQueueDir(chatId);
144
- const pendingPath = path.join(queueDir, 'pending', `${notificationId}.json`);
145
- const failedPath = path.join(queueDir, 'failed', `${notificationId}.json`);
146
-
147
- // 读取通知
148
- const notification = await fs.readJson(pendingPath);
149
- notification.retry_count++;
150
- notification.last_error = error;
151
- notification.last_attempt = new Date().toISOString();
152
-
153
- if (notification.retry_count >= notification.max_retries) {
154
- // 超过最大重试次数,移动到失败目录
155
- notification.status = 'failed';
156
- await fs.writeJson(failedPath, notification, { spaces: 2 });
157
- await fs.remove(pendingPath);
158
- await logger.warn(`Notification failed permanently: ${notificationId} (${notification.retry_count} retries)`);
159
- } else {
160
- // 更新重试计数,保留在 pending 目录
161
- await fs.writeJson(pendingPath, notification, { spaces: 2 });
162
- await logger.info(`Notification retry scheduled: ${notificationId} (attempt ${notification.retry_count})`);
163
- }
164
- } catch (err) {
165
- await logger.error(`Failed to mark notification failed: ${notificationId}`, err);
166
- }
167
- }
168
-
169
- /**
170
- * 获取需要重试的通知
171
- * @param {string} chatId
172
- * @returns {Promise<Array>}
173
- */
174
- export async function getNotificationsForRetry(chatId) {
175
- const pending = await getPendingNotifications(chatId);
176
- const now = Date.now();
177
104
 
178
- return pending.filter(notif => {
179
- if (notif.retry_count === 0) return true;
180
-
181
- // 指数退避:1分钟, 5分钟, 25分钟
182
- const backoffMinutes = Math.pow(5, notif.retry_count - 1);
183
- const lastAttempt = new Date(notif.last_attempt || notif.created_at).getTime();
184
- const nextRetry = lastAttempt + (backoffMinutes * 60 * 1000);
185
-
186
- return now >= nextRetry;
187
- });
105
+ const queueDir = getLocalQueueDir(chatId);
106
+ const pending = await fs.readdir(path.join(queueDir, 'pending')).catch(() => []);
107
+ const failed = await fs.readdir(path.join(queueDir, 'failed')).catch(() => []);
108
+ const sent = await fs.readdir(path.join(queueDir, 'sent')).catch(() => []);
109
+ const processing = await fs.readdir(path.join(queueDir, 'processing')).catch(() => []);
110
+
111
+ // 计算总处理数
112
+ const totalProcessed = sent.length + failed.length;
113
+ const successRate = totalProcessed > 0 ? ((sent.length / totalProcessed) * 100).toFixed(1) : 0;
114
+
115
+ return {
116
+ source: 'local',
117
+ pending: pending.length,
118
+ processing: processing.length,
119
+ failed: failed.length,
120
+ sent: sent.length,
121
+ totalProcessed,
122
+ successRate: `${successRate}%`,
123
+ lastUpdated: new Date().toISOString()
124
+ };
188
125
  }
189
126
 
190
127
  /**
191
- * 获取失败通知列表
192
- * @param {string} chatId
193
- * @param {number} limit
194
- * @returns {Promise<Array>}
128
+ * 处理失败重试
195
129
  */
196
- export async function getFailedNotifications(chatId, limit = 10) {
197
- try {
198
- const queueDir = getQueueDir(chatId);
199
- const failedDir = path.join(queueDir, 'failed');
200
-
201
- const files = await fs.readdir(failedDir);
202
- const notifications = [];
203
-
204
- for (const file of files.filter(f => f.endsWith('.json')).slice(0, limit)) {
205
- try {
206
- const notif = await fs.readJson(path.join(failedDir, file));
207
- notifications.push(notif);
208
- } catch (e) {
209
- // 忽略
210
- }
211
- }
212
-
213
- return notifications.sort((a, b) =>
214
- new Date(b.created_at) - new Date(a.created_at)
215
- );
216
- } catch (error) {
217
- return [];
130
+ async function handleFailed(notif, queueDir) {
131
+ const filePath = path.join(queueDir, 'failed', `${notif.id}.json`);
132
+ const failedPath = path.join(queueDir, 'failed', `${notif.id}.json`);
133
+
134
+ // 检查重试次数
135
+ if (notif.retry_count >= MAX_RETRY) {
136
+ logger.warn(`Notification ${notif.id} exceeded max retries, moving to dead letter`);
137
+ await fs.move(failedPath, path.join(queueDir, 'dead', `${notif.id}.json`));
138
+ return;
218
139
  }
140
+
141
+ // 延迟重试
142
+ const delay = RETRY_DELAYS[notif.retry_count] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
143
+ notif.retry_count++;
144
+ notif.next_retry_at = new Date(Date.now() + delay).toISOString();
145
+
146
+ await fs.writeJson(failedPath, notif, { spaces: 2 });
147
+ logger.info(`Notification ${notif.id} scheduled for retry ${notif.retry_count}/${MAX_RETRY}`);
219
148
  }
220
149
 
221
150
  /**
222
- * 清理已发送通知(保留最近7天)
223
- * @param {string} chatId
151
+ * 启动通知处理器 - 支持自动重试
224
152
  */
225
- export async function cleanupSentNotifications(chatId) {
226
- try {
227
- const queueDir = getQueueDir(chatId);
228
- const sentDir = path.join(queueDir, 'sent');
229
-
230
- const files = await fs.readdir(sentDir);
231
- const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7天前
232
-
233
- let cleaned = 0;
234
- for (const file of files.filter(f => f.endsWith('.json'))) {
235
- try {
236
- const notif = await fs.readJson(path.join(sentDir, file));
237
- const sentAt = new Date(notif.sent_at || notif.created_at).getTime();
238
-
239
- if (sentAt < cutoff) {
240
- await fs.remove(path.join(sentDir, file));
241
- cleaned++;
242
- }
243
- } catch (e) {
244
- // 忽略
245
- }
246
- }
247
-
248
- if (cleaned > 0) {
249
- await logger.info(`Cleaned up ${cleaned} old sent notifications`);
250
- }
251
- } catch (error) {
252
- await logger.error('Failed to cleanup sent notifications', error);
153
+ export async function startNotificationProcessor(chatId, handler) {
154
+ if (isOpenClawEnv()) {
155
+ logger.info('Using OpenClaw Queue processor');
156
+ return;
253
157
  }
254
- }
255
-
256
- /**
257
- * 启动后台通知处理器
258
- * @param {string} chatId
259
- * @param {Function} sendFn - 实际发送函数
260
- */
261
- export function startNotificationProcessor(chatId, sendFn) {
262
- // 立即处理一次
263
- processNotificationQueue(chatId, sendFn);
264
-
265
- // 每30秒检查一次队列
266
- const intervalId = setInterval(() => {
267
- processNotificationQueue(chatId, sendFn);
268
- }, 30000);
269
158
 
270
- // 每小时清理一次已发送通知
271
- const cleanupInterval = setInterval(() => {
272
- cleanupSentNotifications(chatId);
273
- }, 60 * 60 * 1000);
159
+ const queueDir = getLocalQueueDir(chatId);
274
160
 
275
- return {
276
- stop: () => {
277
- clearInterval(intervalId);
278
- clearInterval(cleanupInterval);
279
- }
280
- };
281
- }
282
-
283
- /**
284
- * 处理通知队列
285
- * @param {string} chatId
286
- * @param {Function} sendFn
287
- */
288
- async function processNotificationQueue(chatId, sendFn) {
289
- try {
290
- const notifications = await getNotificationsForRetry(chatId);
161
+ const processQueue = async () => {
162
+ const pending = await fs.readdir(path.join(queueDir, 'pending')).catch(() => []);
291
163
 
292
- for (const notif of notifications) {
164
+ for (const file of pending) {
293
165
  try {
294
- await logger.info(`Processing notification: ${notif.id}`);
166
+ const filePath = path.join(queueDir, 'pending', file);
167
+ const notif = await fs.readJson(filePath);
168
+
169
+ // 标记为处理中
170
+ await fs.move(filePath, path.join(queueDir, 'processing', file));
295
171
 
296
- // 调用实际发送函数
297
- const success = await sendFn(notif);
172
+ await handler(notif);
298
173
 
299
- if (success) {
300
- await markNotificationSent(chatId, notif.id);
301
- } else {
302
- await markNotificationFailed(chatId, notif.id, 'Send returned false');
174
+ // 成功移动到已发送
175
+ await fs.move(path.join(queueDir, 'processing', file),
176
+ path.join(queueDir, 'sent', file));
177
+ } catch (err) {
178
+ logger.error(`Failed to process ${file}:`, err.message);
179
+
180
+ // 处理失败,移至失败队列
181
+ const procPath = path.join(queueDir, 'processing', file);
182
+ if (await fs.pathExists(procPath)) {
183
+ const notif = await fs.readJson(procPath);
184
+ await handleFailed(notif, queueDir);
303
185
  }
304
- } catch (error) {
305
- await markNotificationFailed(chatId, notif.id, error.message);
306
186
  }
307
187
  }
308
- } catch (error) {
309
- await logger.error('Failed to process notification queue', error);
310
- }
188
+ };
189
+
190
+ setInterval(processQueue, 60000);
191
+ processQueue();
311
192
  }
193
+
194
+ export default {
195
+ enqueueNotification,
196
+ getQueueStats,
197
+ startNotificationProcessor,
198
+ initQueue
199
+ };
@@ -26,6 +26,27 @@ export function isOpenClawGateway() {
26
26
  * @param {Object} options - 选项
27
27
  * @returns {boolean} 是否成功
28
28
  */
29
+ /**
30
+ * 检查 cron 任务是否已存在
31
+ * @param {string} name - 任务名称
32
+ * @returns {Promise<boolean>}
33
+ */
34
+ export async function hasCronTask(name) {
35
+ if (!isOpenClawGateway()) return false;
36
+
37
+ try {
38
+ const tasks = listCronTasks();
39
+ return tasks.some(t => t.name === name);
40
+ } catch (e) {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 注册 cron 任务(幂等)
47
+ * @param {Object} options - 选项
48
+ * @returns {Promise<boolean>} 是否成功(新增或已存在)
49
+ */
29
50
  export async function registerCronTask(options) {
30
51
  const { name, message, cron, chatId } = options;
31
52
 
@@ -34,13 +55,19 @@ export async function registerCronTask(options) {
34
55
  return false;
35
56
  }
36
57
 
58
+ // ✅ 幂等检查:先检查是否已存在
59
+ if (await hasCronTask(name)) {
60
+ logger.info(`Cron task already exists: ${name}`);
61
+ return true;
62
+ }
63
+
37
64
  try {
38
65
  const cmd = [
39
66
  'openclaw cron add',
40
67
  `--name "${name}"`,
41
68
  `--message "${message}"`,
42
69
  `--cron "${cron}"`,
43
- '--channel feishu',
70
+ '--channel ${process.env.OPENCLAW_CHANNEL || \'feishu\'}',
44
71
  `--to "${chatId}"`,
45
72
  '--announce'
46
73
  ].join(' ');
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Subagent 调度模块
3
+ * 支持并行研究和监控后台检查
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { createLogger } from '../core/logger.js';
8
+ import { isOpenClaw } from './envAdapter.js';
9
+
10
+ const logger = createLogger('SubagentScheduler');
11
+
12
+ /**
13
+ * 启动并行研究任务
14
+ * @param {Array} topics - 研究主题列表
15
+ * @returns {Promise<Array>} 任务 ID 列表
16
+ */
17
+ export async function startParallelResearch(topics) {
18
+ if (!isOpenClaw()) {
19
+ logger.warn('Not in OpenClaw environment, cannot start subagent research');
20
+ return [];
21
+ }
22
+
23
+ const taskIds = [];
24
+
25
+ for (const topic of topics) {
26
+ try {
27
+ const taskId = await startResearchTask(topic);
28
+ taskIds.push(taskId);
29
+ logger.info(`Started research task: ${topic} -> ${taskId}`);
30
+ } catch (error) {
31
+ logger.error(`Failed to start research for ${topic}:`, error.message);
32
+ }
33
+ }
34
+
35
+ return taskIds;
36
+ }
37
+
38
+ /**
39
+ * 启动单个研究任务
40
+ * @param {string} topic - 研究主题
41
+ * @returns {Promise<string>} 任务 ID
42
+ */
43
+ async function startResearchTask(topic) {
44
+ const cmd = [
45
+ 'openclaw agent',
46
+ '--message', `/cue ${topic}`,
47
+ '--deliver',
48
+ '--json'
49
+ ].join(' ');
50
+
51
+ const output = execSync(cmd, { encoding: 'utf-8' });
52
+ const result = JSON.parse(output);
53
+
54
+ return result.taskId || result.runId || `research_${Date.now()}`;
55
+ }
56
+
57
+ /**
58
+ * 启动监控检查任务
59
+ * @param {string} chatId - 用户 ID
60
+ * @returns {Promise<string>} 任务 ID
61
+ */
62
+ export async function startMonitorCheck(chatId) {
63
+ if (!isOpenClaw()) {
64
+ logger.warn('Not in OpenClaw environment, cannot start monitor check');
65
+ return null;
66
+ }
67
+
68
+ try {
69
+ // 使用 sessions_spawn 启动独立检查任务
70
+ const cmd = [
71
+ 'openclaw sessions spawn',
72
+ '--label', `monitor_${chatId}`,
73
+ '--message', `/cm check`,
74
+ '--timeout', '300'
75
+ ].join(' ');
76
+
77
+ execSync(cmd, { stdio: 'ignore' });
78
+ logger.info(`Started monitor check for ${chatId}`);
79
+
80
+ return `monitor_${chatId}_${Date.now()}`;
81
+ } catch (error) {
82
+ logger.error('Failed to start monitor check:', error.message);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 发送消息到 Subagent 会话
89
+ * @param {string} sessionKey - 会话 key
90
+ * @param {string} message - 消息
91
+ * @returns {Promise<boolean>}
92
+ */
93
+ export async function sendToSession(sessionKey, message) {
94
+ if (!isOpenClaw()) {
95
+ return false;
96
+ }
97
+
98
+ try {
99
+ const cmd = [
100
+ 'openclaw sessions send',
101
+ '--session', sessionKey,
102
+ '--message', message
103
+ ].join(' ');
104
+
105
+ execSync(cmd, { stdio: 'ignore' });
106
+ return true;
107
+ } catch (error) {
108
+ logger.error(`Failed to send to session ${sessionKey}:`, error.message);
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 列出所有 Subagent 会话
115
+ * @returns {Promise<Array>}
116
+ */
117
+ export async function listSessions() {
118
+ if (!isOpenClaw()) {
119
+ return [];
120
+ }
121
+
122
+ try {
123
+ const output = execSync('openclaw sessions list --json', { encoding: 'utf-8' });
124
+ return JSON.parse(output || '[]');
125
+ } catch (error) {
126
+ return [];
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 终止 Subagent 会话
132
+ * @param {string} sessionKey - 会话 key
133
+ * @returns {Promise<boolean>}
134
+ */
135
+ export async function terminateSession(sessionKey) {
136
+ if (!isOpenClaw()) {
137
+ return false;
138
+ }
139
+
140
+ try {
141
+ execSync(`openclaw sessions kill ${sessionKey}`, { stdio: 'ignore' });
142
+ return true;
143
+ } catch (error) {
144
+ logger.error(`Failed to terminate session ${sessionKey}:`, error.message);
145
+ return false;
146
+ }
147
+ }