flashclaw 1.0.0 → 1.1.0

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.
Files changed (47) hide show
  1. package/README.md +2 -1
  2. package/dist/agent-runner.d.ts +2 -0
  3. package/dist/agent-runner.d.ts.map +1 -1
  4. package/dist/agent-runner.js +27 -4
  5. package/dist/agent-runner.js.map +1 -1
  6. package/dist/cli.js +48 -48
  7. package/dist/commands.d.ts.map +1 -1
  8. package/dist/commands.js +2 -1
  9. package/dist/commands.js.map +1 -1
  10. package/dist/config-schema.d.ts +1 -1
  11. package/dist/config.d.ts +24 -2
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +34 -18
  14. package/dist/config.js.map +1 -1
  15. package/dist/core/api-client.d.ts.map +1 -1
  16. package/dist/core/api-client.js +109 -0
  17. package/dist/core/api-client.js.map +1 -1
  18. package/dist/core/memory.d.ts +1 -1
  19. package/dist/core/memory.d.ts.map +1 -1
  20. package/dist/core/memory.js +16 -3
  21. package/dist/core/memory.js.map +1 -1
  22. package/dist/db.d.ts.map +1 -1
  23. package/dist/db.js +4 -1
  24. package/dist/db.js.map +1 -1
  25. package/dist/index.d.ts +19 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +264 -150
  28. package/dist/index.js.map +1 -1
  29. package/dist/logger.d.ts +1 -1
  30. package/dist/logger.d.ts.map +1 -1
  31. package/dist/logger.js +49 -8
  32. package/dist/logger.js.map +1 -1
  33. package/dist/plugins/installer.d.ts.map +1 -1
  34. package/dist/plugins/installer.js +20 -0
  35. package/dist/plugins/installer.js.map +1 -1
  36. package/dist/plugins/types.d.ts +2 -0
  37. package/dist/plugins/types.d.ts.map +1 -1
  38. package/dist/plugins/types.js.map +1 -1
  39. package/dist/task-scheduler.d.ts.map +1 -1
  40. package/dist/task-scheduler.js +5 -12
  41. package/dist/task-scheduler.js.map +1 -1
  42. package/dist/types.d.ts +1 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +5 -2
  45. package/plugins/feishu/index.ts +77 -20
  46. package/plugins/send-message/index.ts +83 -17
  47. package/scripts/postinstall.js +21 -0
package/dist/index.js CHANGED
@@ -7,14 +7,16 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { isIP } from 'net';
9
9
  import pino from 'pino';
10
+ import { z } from 'zod';
10
11
  import { paths, ensureDirectories, getBuiltinPluginsDir } from './paths.js';
11
12
  import { pluginManager } from './plugins/manager.js';
12
13
  import { loadFromDir, watchPlugins, stopWatching } from './plugins/loader.js';
13
14
  import { getApiClient } from './core/api-client.js';
14
15
  import { getMemoryManager } from './core/memory.js';
15
- import { BOT_NAME, DATA_DIR, MAIN_GROUP_FOLDER, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
16
+ import { BOT_NAME, MAIN_GROUP_FOLDER, IPC_POLL_INTERVAL, TIMEZONE, HISTORY_CONTEXT_LIMIT, THINKING_THRESHOLD_MS, MAX_DIRECT_FETCH_CHARS, MAX_IPC_FILE_BYTES, MAX_IPC_MESSAGE_CHARS, MAX_IPC_CHAT_ID_CHARS, MAX_IMAGE_BYTES, MESSAGE_QUEUE_MAX_SIZE, MESSAGE_QUEUE_MAX_CONCURRENT, MESSAGE_QUEUE_PROCESSING_TIMEOUT_MS, MESSAGE_QUEUE_MAX_RETRIES } from './config.js';
16
17
  import { initDatabase, storeMessage, storeChatMetadata, getMessagesSince, getChatHistory, messageExists, getAllTasks, getAllChats } from './db.js';
17
- import { startSchedulerLoop, stopScheduler } from './task-scheduler.js';
18
+ import { startSchedulerLoop, stopScheduler, wake } from './task-scheduler.js';
19
+ import { startHealthServer, stopHealthServer } from './health.js';
18
20
  import { runAgent, writeTasksSnapshot, writeGroupsSnapshot } from './agent-runner.js';
19
21
  import { loadJson, saveJson } from './utils.js';
20
22
  import { MessageQueue } from './message-queue.js';
@@ -59,7 +61,8 @@ class ChannelManager {
59
61
  try {
60
62
  return await channel.sendMessage(chatId, content);
61
63
  }
62
- catch {
64
+ catch (err) {
65
+ logger.debug({ channel: channel.name, chatId, err }, '渠道发送消息失败,尝试下一个');
63
66
  continue;
64
67
  }
65
68
  }
@@ -81,7 +84,8 @@ class ChannelManager {
81
84
  await channel.updateMessage(messageId, content);
82
85
  return;
83
86
  }
84
- catch {
87
+ catch (err) {
88
+ logger.debug({ channel: channel.name, messageId, err }, '渠道更新消息失败,尝试下一个');
85
89
  continue;
86
90
  }
87
91
  }
@@ -101,11 +105,39 @@ class ChannelManager {
101
105
  await channel.deleteMessage(messageId);
102
106
  return;
103
107
  }
104
- catch {
108
+ catch (err) {
109
+ logger.debug({ channel: channel.name, messageId, err }, '渠道删除消息失败,尝试下一个');
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+ }
115
+ async sendImage(chatId, imageData, caption, platform) {
116
+ // 如果指定了平台,使用指定的渠道
117
+ if (platform) {
118
+ const channel = this.channels.find(c => c.name === platform);
119
+ if (channel?.sendImage) {
120
+ return await channel.sendImage(chatId, imageData, caption);
121
+ }
122
+ // 渠道不支持发送图片,降级为发送文本提示
123
+ logger.warn({ platform, chatId }, '渠道不支持发送图片,已降级');
124
+ return await this.sendMessage(chatId, caption || '[图片无法显示]', platform);
125
+ }
126
+ // 尝试所有支持图片的渠道
127
+ for (const channel of this.channels) {
128
+ if (channel.sendImage) {
129
+ try {
130
+ return await channel.sendImage(chatId, imageData, caption);
131
+ }
132
+ catch (err) {
133
+ logger.debug({ channel: channel.name, chatId, err }, '渠道发送图片失败,尝试下一个');
105
134
  continue;
106
135
  }
107
136
  }
108
137
  }
138
+ // 所有渠道都不支持,降级为文本
139
+ logger.warn({ chatId }, '没有渠道支持发送图片,已降级');
140
+ return await this.sendMessage(chatId, caption || '[图片无法显示]', platform);
109
141
  }
110
142
  getEnabledPlatforms() {
111
143
  return this.enabledPlatforms;
@@ -132,24 +164,12 @@ let registeredGroups = {};
132
164
  let lastAgentTimestamp = {};
133
165
  let messageQueue;
134
166
  let isShuttingDown = false;
135
- // 消息历史上下文配置
136
- // 现在由 MemoryManager 基于 token 自动管理,这里只是一个备用限制
137
- const HISTORY_CONTEXT_LIMIT = 500;
138
- // "正在思考..." 提示配置
139
- // 注意:飞书不支持更新普通文本消息,所以默认禁用此功能
140
- // 如需启用,设置环境变量 THINKING_THRESHOLD_MS=2500(毫秒)
141
- const THINKING_THRESHOLD_MS = Number(process.env.THINKING_THRESHOLD_MS ?? 0);
142
167
  // 直接网页抓取触发(避免模型不触发工具)
143
168
  const WEB_FETCH_TOOL_NAME = 'web_fetch';
144
169
  const WEB_FETCH_INTENT_RE = /(抓取|获取|读取|访问|打开|爬取|网页|网站|链接|fetch|web)/i;
145
170
  const WEB_FETCH_URL_RE = /https?:\/\/[^\s<>()]+/i;
146
171
  const WEB_FETCH_DOMAIN_RE = /(?:^|[^A-Za-z0-9.-])((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})(:\d{2,5})?(\/[^\s<>()]*)?/i;
147
172
  const TRAILING_PUNCT_RE = /[)\],.。,;;!!??]+$/;
148
- const MAX_DIRECT_FETCH_CHARS = 4000;
149
- const MAX_IPC_FILE_BYTES = 1024 * 1024;
150
- const MAX_IPC_MESSAGE_CHARS = 10000;
151
- const MAX_IPC_CHAT_ID_CHARS = 256;
152
- const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
153
173
  // ==================== 状态管理 ====================
154
174
  // 默认的 main 群组配置模板(用于自动注册新会话)
155
175
  const DEFAULT_MAIN_GROUP = {
@@ -159,11 +179,12 @@ const DEFAULT_MAIN_GROUP = {
159
179
  added_at: new Date().toISOString()
160
180
  };
161
181
  function loadState() {
162
- const statePath = path.join(DATA_DIR, 'router_state.json');
182
+ const dataDir = paths.data();
183
+ const statePath = path.join(dataDir, 'router_state.json');
163
184
  const state = loadJson(statePath, {});
164
185
  lastAgentTimestamp = state.last_agent_timestamp || {};
165
- sessions = loadJson(path.join(DATA_DIR, 'sessions.json'), {});
166
- registeredGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
186
+ sessions = loadJson(path.join(dataDir, 'sessions.json'), {});
187
+ registeredGroups = loadJson(path.join(dataDir, 'registered_groups.json'), {});
167
188
  // 确保有 main 群组配置模板(用于自动注册)
168
189
  const hasMainGroup = Object.values(registeredGroups).some(g => g.folder === MAIN_GROUP_FOLDER);
169
190
  if (!hasMainGroup) {
@@ -174,12 +195,17 @@ function loadState() {
174
195
  logger.info({ groupCount: Object.keys(registeredGroups).length }, '⚡ 状态已加载');
175
196
  }
176
197
  function saveState() {
177
- saveJson(path.join(DATA_DIR, 'router_state.json'), { last_agent_timestamp: lastAgentTimestamp });
178
- saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
198
+ const dataDir = paths.data();
199
+ saveJson(path.join(dataDir, 'router_state.json'), { last_agent_timestamp: lastAgentTimestamp });
200
+ saveJson(path.join(dataDir, 'sessions.json'), sessions);
179
201
  }
180
202
  function registerGroup(chatId, group) {
181
203
  registeredGroups[chatId] = group;
182
- saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
204
+ saveJson(path.join(paths.data(), 'registered_groups.json'), registeredGroups);
205
+ // 同步更新全局 Map
206
+ if (global.__flashclaw_registered_groups) {
207
+ global.__flashclaw_registered_groups.set(chatId, group);
208
+ }
183
209
  // 创建群组文件夹
184
210
  const groupDir = path.join(paths.groups(), group.folder);
185
211
  fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
@@ -224,7 +250,7 @@ function shouldTriggerAgent(msg, group) {
224
250
  }
225
251
  return false;
226
252
  }
227
- function extractFirstUrl(text) {
253
+ export function extractFirstUrl(text) {
228
254
  const match = text.match(WEB_FETCH_URL_RE);
229
255
  if (match) {
230
256
  return match[0].replace(TRAILING_PUNCT_RE, '');
@@ -238,7 +264,7 @@ function extractFirstUrl(text) {
238
264
  const candidate = `https://${host}${port}${path}`;
239
265
  return candidate.replace(TRAILING_PUNCT_RE, '');
240
266
  }
241
- function isPrivateIpv4(ip) {
267
+ export function isPrivateIpv4(ip) {
242
268
  const parts = ip.split('.').map((part) => Number(part));
243
269
  if (parts.length !== 4 || parts.some((part) => Number.isNaN(part)))
244
270
  return false;
@@ -259,7 +285,7 @@ function isPrivateIpv4(ip) {
259
285
  return true;
260
286
  return false;
261
287
  }
262
- function isPrivateIpv6(ip) {
288
+ export function isPrivateIpv6(ip) {
263
289
  const normalized = ip.toLowerCase();
264
290
  if (normalized === '::' || normalized === '::1')
265
291
  return true;
@@ -276,7 +302,7 @@ function isPrivateIpv6(ip) {
276
302
  }
277
303
  return false;
278
304
  }
279
- function isPrivateIp(ip) {
305
+ export function isPrivateIp(ip) {
280
306
  const family = isIP(ip);
281
307
  if (family === 4)
282
308
  return isPrivateIpv4(ip);
@@ -284,7 +310,7 @@ function isPrivateIp(ip) {
284
310
  return isPrivateIpv6(ip);
285
311
  return false;
286
312
  }
287
- function isBlockedHostname(hostname) {
313
+ export function isBlockedHostname(hostname) {
288
314
  const normalized = hostname.trim().toLowerCase();
289
315
  if (normalized === 'localhost')
290
316
  return true;
@@ -292,7 +318,7 @@ function isBlockedHostname(hostname) {
292
318
  normalized.endsWith('.local') ||
293
319
  normalized.endsWith('.internal'));
294
320
  }
295
- function estimateBase64Bytes(content) {
321
+ export function estimateBase64Bytes(content) {
296
322
  if (!content)
297
323
  return null;
298
324
  const raw = content.startsWith('data:') ? content.split(',')[1] ?? '' : content;
@@ -302,13 +328,13 @@ function estimateBase64Bytes(content) {
302
328
  const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0;
303
329
  return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
304
330
  }
305
- function truncateText(text, maxLength) {
331
+ export function truncateText(text, maxLength) {
306
332
  if (text.length <= maxLength) {
307
333
  return { text, truncated: false };
308
334
  }
309
335
  return { text: `${text.slice(0, maxLength)}\n\n...(内容已截断)`, truncated: true };
310
336
  }
311
- function formatDirectWebFetchResponse(url, result) {
337
+ export function formatDirectWebFetchResponse(url, result) {
312
338
  if (!result.success) {
313
339
  return `❌ 抓取失败: ${result.error || '未知错误'}`;
314
340
  }
@@ -375,6 +401,9 @@ async function tryHandleDirectWebFetch(msg, group) {
375
401
  userId: msg.senderId,
376
402
  sendMessage: async (text) => {
377
403
  await sendMessage(msg.chatId, `${BOT_NAME}: ${text}`, msg.platform);
404
+ },
405
+ sendImage: async (imageData, caption) => {
406
+ await channelManager.sendImage(msg.chatId, imageData, caption, msg.platform);
378
407
  }
379
408
  };
380
409
  const normalizedUrl = urlObj.toString();
@@ -469,8 +498,8 @@ async function processQueuedMessage(queuedMsg) {
469
498
  logger.debug({ chatId, messageId: placeholderMessageId }, '已发送思考提示');
470
499
  }
471
500
  }
472
- catch {
473
- // 忽略错误
501
+ catch (err) {
502
+ logger.debug({ chatId, err }, '发送思考提示失败');
474
503
  }
475
504
  }, THINKING_THRESHOLD_MS) : null;
476
505
  try {
@@ -492,12 +521,15 @@ async function processQueuedMessage(queuedMsg) {
492
521
  await channelManager.updateMessage(placeholderMessageId, finalText, msg.platform);
493
522
  logger.info({ chatId, messageId: placeholderMessageId }, '⚡ 消息已更新');
494
523
  }
495
- catch {
524
+ catch (updateErr) {
496
525
  // 更新失败,尝试删除并发送新消息
526
+ logger.debug({ chatId, messageId: placeholderMessageId, err: updateErr }, '更新占位消息失败,尝试删除并重发');
497
527
  try {
498
528
  await channelManager.deleteMessage(placeholderMessageId, msg.platform);
499
529
  }
500
- catch { }
530
+ catch (deleteErr) {
531
+ logger.debug({ chatId, messageId: placeholderMessageId, err: deleteErr }, '删除占位消息失败');
532
+ }
501
533
  await sendMessage(chatId, finalText, msg.platform);
502
534
  }
503
535
  }
@@ -520,7 +552,9 @@ async function processQueuedMessage(queuedMsg) {
520
552
  try {
521
553
  await channelManager.deleteMessage(placeholderMessageId, msg.platform);
522
554
  }
523
- catch { }
555
+ catch (deleteErr) {
556
+ logger.debug({ chatId, messageId: placeholderMessageId, err: deleteErr }, '删除占位消息失败(无响应)');
557
+ }
524
558
  }
525
559
  }
526
560
  catch (err) {
@@ -533,7 +567,9 @@ async function processQueuedMessage(queuedMsg) {
533
567
  try {
534
568
  await channelManager.deleteMessage(placeholderMessageId, msg.platform);
535
569
  }
536
- catch { }
570
+ catch (deleteErr) {
571
+ logger.debug({ chatId, messageId: placeholderMessageId, err: deleteErr }, '删除占位消息失败(错误恢复)');
572
+ }
537
573
  }
538
574
  throw err;
539
575
  }
@@ -726,7 +762,7 @@ async function executeAgent(group, prompt, chatId, options) {
726
762
  });
727
763
  if (output.newSessionId) {
728
764
  sessions[group.folder] = output.newSessionId;
729
- saveJson(path.join(DATA_DIR, 'sessions.json'), sessions);
765
+ saveJson(path.join(paths.data(), 'sessions.json'), sessions);
730
766
  }
731
767
  if (output.status === 'error') {
732
768
  logger.error({ group: group.name, error: output.error }, 'Agent 错误');
@@ -764,7 +800,7 @@ function quarantineIpcFile(ipcBaseDir, sourceGroup, filePath, reason, err) {
764
800
  logger.warn({ file: fileName, sourceGroup, reason, err }, 'IPC 文件已隔离');
765
801
  }
766
802
  function startIpcWatcher() {
767
- const ipcBaseDir = path.join(DATA_DIR, 'ipc');
803
+ const ipcBaseDir = path.join(paths.data(), 'ipc');
768
804
  fs.mkdirSync(ipcBaseDir, { recursive: true });
769
805
  const processIpcFiles = async () => {
770
806
  let groupFolders;
@@ -816,6 +852,28 @@ function startIpcWatcher() {
816
852
  logger.warn({ chatId: data.chatJid, sourceGroup }, '未授权的 IPC 消息被阻止');
817
853
  }
818
854
  }
855
+ else if (data.type === 'image' && data.chatJid && data.imageData) {
856
+ // 处理图片消息
857
+ if (typeof data.chatJid !== 'string' || data.chatJid.length > MAX_IPC_CHAT_ID_CHARS) {
858
+ logger.warn({ sourceGroup }, 'IPC 图片消息 chatJid 格式不合法');
859
+ fs.unlinkSync(filePath);
860
+ continue;
861
+ }
862
+ if (typeof data.imageData !== 'string') {
863
+ logger.warn({ sourceGroup }, 'IPC 图片消息 imageData 格式不合法');
864
+ fs.unlinkSync(filePath);
865
+ continue;
866
+ }
867
+ const targetGroup = registeredGroups[data.chatJid];
868
+ if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) {
869
+ const caption = data.caption ? `${BOT_NAME}: ${data.caption}` : undefined;
870
+ await channelManager.sendImage(data.chatJid, data.imageData, caption);
871
+ logger.info({ chatId: data.chatJid, sourceGroup }, 'IPC 图片已发送');
872
+ }
873
+ else {
874
+ logger.warn({ chatId: data.chatJid, sourceGroup }, '未授权的 IPC 图片被阻止');
875
+ }
876
+ }
819
877
  fs.unlinkSync(filePath);
820
878
  }
821
879
  catch (err) {
@@ -860,128 +918,171 @@ function startIpcWatcher() {
860
918
  processIpcFiles();
861
919
  logger.info('⚡ IPC 监听已启动');
862
920
  }
863
- async function processTaskIpc(data, sourceGroup, isMain) {
921
+ // ==================== IPC Schema 验证 ====================
922
+ /** 基础 IPC 消息 schema */
923
+ const IpcBaseSchema = z.object({
924
+ type: z.string().min(1).max(50),
925
+ });
926
+ /** schedule_task IPC schema */
927
+ const IpcScheduleTaskSchema = IpcBaseSchema.extend({
928
+ type: z.literal('schedule_task'),
929
+ prompt: z.string().min(1).max(10000),
930
+ schedule_type: z.enum(['cron', 'interval', 'once']),
931
+ schedule_value: z.string().min(1).max(200),
932
+ groupFolder: z.string().min(1).max(100),
933
+ context_mode: z.enum(['group', 'isolated']).optional(),
934
+ max_retries: z.number().int().min(0).max(10).optional(),
935
+ timeout_ms: z.number().int().min(1000).max(3600000).optional(),
936
+ });
937
+ /** pause/resume/cancel task IPC schema */
938
+ const IpcTaskActionSchema = IpcBaseSchema.extend({
939
+ type: z.enum(['pause_task', 'resume_task', 'cancel_task']),
940
+ taskId: z.string().min(1).max(100),
941
+ });
942
+ /** register_group IPC schema */
943
+ const IpcRegisterGroupSchema = IpcBaseSchema.extend({
944
+ type: z.literal('register_group'),
945
+ jid: z.string().min(1).max(256),
946
+ name: z.string().min(1).max(200),
947
+ folder: z.string().min(1).max(100).regex(/^[a-zA-Z0-9_-]+$/),
948
+ trigger: z.string().min(1).max(50),
949
+ agentConfig: z.object({
950
+ timeout: z.number().int().min(1000).max(3600000).optional(),
951
+ env: z.record(z.string(), z.string()).optional(),
952
+ }).optional(),
953
+ });
954
+ /** 联合 IPC schema */
955
+ const IpcMessageSchema = z.discriminatedUnion('type', [
956
+ IpcScheduleTaskSchema,
957
+ IpcTaskActionSchema.extend({ type: z.literal('pause_task') }),
958
+ IpcTaskActionSchema.extend({ type: z.literal('resume_task') }),
959
+ IpcTaskActionSchema.extend({ type: z.literal('cancel_task') }),
960
+ IpcRegisterGroupSchema,
961
+ ]);
962
+ async function processTaskIpc(rawData, sourceGroup, isMain) {
963
+ // Zod schema 验证
964
+ const parseResult = IpcMessageSchema.safeParse(rawData);
965
+ if (!parseResult.success) {
966
+ logger.warn({
967
+ sourceGroup,
968
+ errors: parseResult.error.flatten().fieldErrors
969
+ }, 'IPC 消息验证失败');
970
+ return;
971
+ }
972
+ const data = parseResult.data;
864
973
  const { createTask, updateTask, deleteTask, getTaskById: getTask } = await import('./db.js');
865
974
  const { CronExpressionParser } = await import('cron-parser');
866
975
  switch (data.type) {
867
- case 'schedule_task':
868
- if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) {
869
- const targetGroup = data.groupFolder;
870
- if (!isMain && targetGroup !== sourceGroup) {
871
- logger.warn({ sourceGroup, targetGroup }, '未授权的 schedule_task 被阻止');
872
- break;
976
+ case 'schedule_task': {
977
+ // Zod 已验证必填字段,直接使用
978
+ const targetGroup = data.groupFolder;
979
+ if (!isMain && targetGroup !== sourceGroup) {
980
+ logger.warn({ sourceGroup, targetGroup }, '未授权的 schedule_task 被阻止');
981
+ break;
982
+ }
983
+ const targetChatId = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup)?.[0];
984
+ if (!targetChatId) {
985
+ logger.warn({ targetGroup }, '无法创建任务:目标群组未注册');
986
+ break;
987
+ }
988
+ const scheduleType = data.schedule_type;
989
+ let nextRun = null;
990
+ if (scheduleType === 'cron') {
991
+ try {
992
+ const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE });
993
+ nextRun = interval.next().toISOString();
873
994
  }
874
- const targetChatId = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup)?.[0];
875
- if (!targetChatId) {
876
- logger.warn({ targetGroup }, '无法创建任务:目标群组未注册');
995
+ catch {
996
+ logger.warn({ scheduleValue: data.schedule_value }, '无效的 cron 表达式');
877
997
  break;
878
998
  }
879
- const scheduleType = data.schedule_type;
880
- let nextRun = null;
881
- if (scheduleType === 'cron') {
882
- try {
883
- const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE });
884
- nextRun = interval.next().toISOString();
885
- }
886
- catch {
887
- logger.warn({ scheduleValue: data.schedule_value }, '无效的 cron 表达式');
888
- break;
889
- }
890
- }
891
- else if (scheduleType === 'interval') {
892
- const ms = parseInt(data.schedule_value, 10);
893
- if (isNaN(ms) || ms <= 0) {
894
- logger.warn({ scheduleValue: data.schedule_value }, '无效的间隔值');
895
- break;
896
- }
897
- nextRun = new Date(Date.now() + ms).toISOString();
999
+ }
1000
+ else if (scheduleType === 'interval') {
1001
+ const ms = parseInt(data.schedule_value, 10);
1002
+ if (isNaN(ms) || ms <= 0) {
1003
+ logger.warn({ scheduleValue: data.schedule_value }, '无效的间隔值');
1004
+ break;
898
1005
  }
899
- else if (scheduleType === 'once') {
900
- const scheduled = new Date(data.schedule_value);
901
- if (isNaN(scheduled.getTime())) {
902
- logger.warn({ scheduleValue: data.schedule_value }, '无效的时间戳');
903
- break;
904
- }
905
- nextRun = scheduled.toISOString();
1006
+ nextRun = new Date(Date.now() + ms).toISOString();
1007
+ }
1008
+ else if (scheduleType === 'once') {
1009
+ const scheduled = new Date(data.schedule_value);
1010
+ if (isNaN(scheduled.getTime())) {
1011
+ logger.warn({ scheduleValue: data.schedule_value }, '无效的时间戳');
1012
+ break;
906
1013
  }
907
- const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
908
- const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated')
909
- ? data.context_mode
910
- : 'isolated';
911
- createTask({
912
- id: taskId,
913
- group_folder: targetGroup,
914
- chat_jid: targetChatId,
915
- prompt: data.prompt,
916
- schedule_type: scheduleType,
917
- schedule_value: data.schedule_value,
918
- context_mode: contextMode,
919
- next_run: nextRun,
920
- status: 'active',
921
- created_at: new Date().toISOString(),
922
- retry_count: 0,
923
- max_retries: data.max_retries ?? 3,
924
- timeout_ms: data.timeout_ms ?? 300000
925
- });
926
- logger.info({ taskId, sourceGroup, targetGroup, contextMode }, '⚡ 任务已创建');
1014
+ nextRun = scheduled.toISOString();
927
1015
  }
1016
+ const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1017
+ const contextMode = data.context_mode ?? 'isolated';
1018
+ createTask({
1019
+ id: taskId,
1020
+ group_folder: targetGroup,
1021
+ chat_jid: targetChatId,
1022
+ prompt: data.prompt,
1023
+ schedule_type: scheduleType,
1024
+ schedule_value: data.schedule_value,
1025
+ context_mode: contextMode,
1026
+ next_run: nextRun,
1027
+ status: 'active',
1028
+ created_at: new Date().toISOString(),
1029
+ retry_count: 0,
1030
+ max_retries: data.max_retries ?? 3,
1031
+ timeout_ms: data.timeout_ms ?? 300000
1032
+ });
1033
+ // 唤醒调度器,确保新任务立即生效
1034
+ wake();
1035
+ logger.info({ taskId, sourceGroup, targetGroup, contextMode }, '⚡ 任务已创建');
928
1036
  break;
929
- case 'pause_task':
930
- if (data.taskId) {
931
- const task = getTask(data.taskId);
932
- if (task && (isMain || task.group_folder === sourceGroup)) {
933
- updateTask(data.taskId, { status: 'paused' });
934
- logger.info({ taskId: data.taskId, sourceGroup }, '任务已暂停');
935
- }
936
- else {
937
- logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务暂停操作');
938
- }
1037
+ }
1038
+ case 'pause_task': {
1039
+ const task = getTask(data.taskId);
1040
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1041
+ updateTask(data.taskId, { status: 'paused' });
1042
+ logger.info({ taskId: data.taskId, sourceGroup }, '任务已暂停');
1043
+ }
1044
+ else {
1045
+ logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务暂停操作');
939
1046
  }
940
1047
  break;
941
- case 'resume_task':
942
- if (data.taskId) {
943
- const task = getTask(data.taskId);
944
- if (task && (isMain || task.group_folder === sourceGroup)) {
945
- updateTask(data.taskId, { status: 'active' });
946
- logger.info({ taskId: data.taskId, sourceGroup }, '任务已恢复');
947
- }
948
- else {
949
- logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务恢复操作');
950
- }
1048
+ }
1049
+ case 'resume_task': {
1050
+ const task = getTask(data.taskId);
1051
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1052
+ updateTask(data.taskId, { status: 'active' });
1053
+ logger.info({ taskId: data.taskId, sourceGroup }, '任务已恢复');
1054
+ }
1055
+ else {
1056
+ logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务恢复操作');
951
1057
  }
952
1058
  break;
953
- case 'cancel_task':
954
- if (data.taskId) {
955
- const task = getTask(data.taskId);
956
- if (task && (isMain || task.group_folder === sourceGroup)) {
957
- deleteTask(data.taskId);
958
- logger.info({ taskId: data.taskId, sourceGroup }, '任务已取消');
959
- }
960
- else {
961
- logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务取消操作');
962
- }
1059
+ }
1060
+ case 'cancel_task': {
1061
+ const task = getTask(data.taskId);
1062
+ if (task && (isMain || task.group_folder === sourceGroup)) {
1063
+ deleteTask(data.taskId);
1064
+ logger.info({ taskId: data.taskId, sourceGroup }, '任务已取消');
1065
+ }
1066
+ else {
1067
+ logger.warn({ taskId: data.taskId, sourceGroup }, '未授权的任务取消操作');
963
1068
  }
964
1069
  break;
965
- case 'register_group':
1070
+ }
1071
+ case 'register_group': {
966
1072
  if (!isMain) {
967
1073
  logger.warn({ sourceGroup }, '未授权的 register_group 被阻止');
968
1074
  break;
969
1075
  }
970
- if (data.jid && data.name && data.folder && data.trigger) {
971
- registerGroup(data.jid, {
972
- name: data.name,
973
- folder: data.folder,
974
- trigger: data.trigger,
975
- added_at: new Date().toISOString(),
976
- agentConfig: data.agentConfig
977
- });
978
- }
979
- else {
980
- logger.warn({ data }, '无效的 register_group 请求');
981
- }
1076
+ // Zod 已验证必填字段,直接使用
1077
+ registerGroup(data.jid, {
1078
+ name: data.name,
1079
+ folder: data.folder,
1080
+ trigger: data.trigger,
1081
+ added_at: new Date().toISOString(),
1082
+ agentConfig: data.agentConfig
1083
+ });
982
1084
  break;
983
- default:
984
- logger.warn({ type: data.type }, '未知的 IPC 任务类型');
1085
+ }
985
1086
  }
986
1087
  }
987
1088
  // ==================== 启动横幅 ====================
@@ -1074,12 +1175,15 @@ export async function main() {
1074
1175
  logger.info({ platforms: enabledPlatforms }, '⚡ 渠道管理器已初始化');
1075
1176
  // 加载状态
1076
1177
  loadState();
1178
+ // 注入全局变量,供 Web UI 等插件使用
1179
+ global.__flashclaw_run_agent = runAgent;
1180
+ global.__flashclaw_registered_groups = new Map(Object.entries(registeredGroups));
1077
1181
  // 初始化消息队列
1078
1182
  messageQueue = new MessageQueue(processQueuedMessage, {
1079
- maxQueueSize: 100,
1080
- maxConcurrent: 3,
1081
- processingTimeout: 300000,
1082
- maxRetries: 2
1183
+ maxQueueSize: MESSAGE_QUEUE_MAX_SIZE,
1184
+ maxConcurrent: MESSAGE_QUEUE_MAX_CONCURRENT,
1185
+ processingTimeout: MESSAGE_QUEUE_PROCESSING_TIMEOUT_MS,
1186
+ maxRetries: MESSAGE_QUEUE_MAX_RETRIES
1083
1187
  });
1084
1188
  messageQueue.start();
1085
1189
  logger.info('⚡ 消息队列已初始化');
@@ -1101,6 +1205,11 @@ export async function main() {
1101
1205
  platforms: enabledPlatforms,
1102
1206
  groups: groupCount
1103
1207
  }, '⚡ FlashClaw 已启动');
1208
+ // 启动健康检查服务(可通过 HEALTH_PORT 环境变量配置端口,默认 9090)
1209
+ const healthPort = parseInt(process.env.HEALTH_PORT || '9090', 10);
1210
+ if (healthPort > 0) {
1211
+ startHealthServer(healthPort);
1212
+ }
1104
1213
  // 注册优雅关闭处理
1105
1214
  setupGracefulShutdown();
1106
1215
  }
@@ -1144,7 +1253,10 @@ async function gracefulShutdown(signal) {
1144
1253
  // 7. 卸载插件
1145
1254
  logger.info('⚡ 卸载插件...');
1146
1255
  await pluginManager.clear();
1147
- // 8. 保存状态
1256
+ // 8. 停止健康检查服务
1257
+ logger.info('⚡ 停止健康检查服务...');
1258
+ stopHealthServer();
1259
+ // 9. 保存状态
1148
1260
  logger.info('⚡ 保存状态...');
1149
1261
  saveState();
1150
1262
  logger.info('⚡ FlashClaw 已安全关闭');
@@ -1173,9 +1285,11 @@ function setupGracefulShutdown() {
1173
1285
  logger.error({ reason }, '未处理的 Promise 拒绝');
1174
1286
  });
1175
1287
  }
1176
- // 直接运行时启动
1177
- main().catch(err => {
1178
- logger.error({ err }, '⚡ FlashClaw 启动失败');
1179
- process.exit(1);
1180
- });
1288
+ // 直接运行时启动(测试环境可通过 FLASHCLAW_SKIP_MAIN=1 禁用)
1289
+ if (process.env.FLASHCLAW_SKIP_MAIN !== '1') {
1290
+ main().catch(err => {
1291
+ logger.error({ err }, '⚡ FlashClaw 启动失败');
1292
+ process.exit(1);
1293
+ });
1294
+ }
1181
1295
  //# sourceMappingURL=index.js.map