team-anya 0.2.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 (145) hide show
  1. package/README.md +38 -0
  2. package/apps/server/dist/broker/cc-broker.js +267 -0
  3. package/apps/server/dist/cli.js +296 -0
  4. package/apps/server/dist/config.js +78 -0
  5. package/apps/server/dist/daemon.js +51 -0
  6. package/apps/server/dist/franky/context-builder.js +161 -0
  7. package/apps/server/dist/franky/franky-mcp-server.js +110 -0
  8. package/apps/server/dist/franky/franky-orchestrator.js +629 -0
  9. package/apps/server/dist/franky/index.js +5 -0
  10. package/apps/server/dist/franky/topic-router.js +16 -0
  11. package/apps/server/dist/gateway/chat-sync.js +135 -0
  12. package/apps/server/dist/gateway/command-router.js +116 -0
  13. package/apps/server/dist/gateway/commands/cancel.js +32 -0
  14. package/apps/server/dist/gateway/commands/help.js +16 -0
  15. package/apps/server/dist/gateway/commands/index.js +26 -0
  16. package/apps/server/dist/gateway/commands/restart.js +43 -0
  17. package/apps/server/dist/gateway/commands/status.js +34 -0
  18. package/apps/server/dist/gateway/commands/tasks.js +33 -0
  19. package/apps/server/dist/gateway/feishu-sender.js +508 -0
  20. package/apps/server/dist/gateway/feishu-ws.js +353 -0
  21. package/apps/server/dist/gateway/health-monitor.js +154 -0
  22. package/apps/server/dist/gateway/http.js +1064 -0
  23. package/apps/server/dist/gateway/media-downloader.js +182 -0
  24. package/apps/server/dist/gateway/message-events.js +10 -0
  25. package/apps/server/dist/gateway/message-intake.js +72 -0
  26. package/apps/server/dist/gateway/message-queue.js +118 -0
  27. package/apps/server/dist/gateway/session-reader.js +142 -0
  28. package/apps/server/dist/gateway/ws-push.js +115 -0
  29. package/apps/server/dist/loid/brain.js +121 -0
  30. package/apps/server/dist/loid/clarifier.js +162 -0
  31. package/apps/server/dist/loid/context-builder.js +462 -0
  32. package/apps/server/dist/loid/mcp-server.js +119 -0
  33. package/apps/server/dist/loid/memory-settler.js +189 -0
  34. package/apps/server/dist/loid/opportunity-manager.js +148 -0
  35. package/apps/server/dist/loid/profile-updater.js +179 -0
  36. package/apps/server/dist/loid/project-registry.js +192 -0
  37. package/apps/server/dist/loid/reporter.js +148 -0
  38. package/apps/server/dist/loid/schemas.js +117 -0
  39. package/apps/server/dist/loid/self-calibrator.js +314 -0
  40. package/apps/server/dist/loid/session-manager.js +472 -0
  41. package/apps/server/dist/loid/session.js +276 -0
  42. package/apps/server/dist/main.js +528 -0
  43. package/apps/server/dist/tracing/index.js +2 -0
  44. package/apps/server/dist/tracing/trace-context.js +92 -0
  45. package/apps/server/dist/types/message.js +2 -0
  46. package/apps/server/dist/yor/yor-mcp-server.js +107 -0
  47. package/apps/server/dist/yor/yor-orchestrator.js +248 -0
  48. package/apps/web/dist/assets/index-BiiEB0qZ.css +1 -0
  49. package/apps/web/dist/assets/index-Dnb9LGZd.js +798 -0
  50. package/apps/web/dist/index.html +13 -0
  51. package/package.json +42 -0
  52. package/packages/cc-client/dist/claude-code-backend.js +792 -0
  53. package/packages/cc-client/dist/index.js +2 -0
  54. package/packages/cc-client/package.json +11 -0
  55. package/packages/core/dist/constants.js +60 -0
  56. package/packages/core/dist/errors.js +35 -0
  57. package/packages/core/dist/index.js +9 -0
  58. package/packages/core/dist/office-init.js +190 -0
  59. package/packages/core/dist/repo-cache.js +70 -0
  60. package/packages/core/dist/scope/checker.js +114 -0
  61. package/packages/core/dist/scope/defaults.js +55 -0
  62. package/packages/core/dist/scope/index.js +3 -0
  63. package/packages/core/dist/state-machine.js +86 -0
  64. package/packages/core/dist/types/audit.js +12 -0
  65. package/packages/core/dist/types/backend.js +2 -0
  66. package/packages/core/dist/types/commitment.js +17 -0
  67. package/packages/core/dist/types/communication.js +18 -0
  68. package/packages/core/dist/types/index.js +9 -0
  69. package/packages/core/dist/types/opportunity.js +27 -0
  70. package/packages/core/dist/types/org.js +26 -0
  71. package/packages/core/dist/types/task.js +46 -0
  72. package/packages/core/dist/types/workspace.js +39 -0
  73. package/packages/core/dist/workspace-manager.js +314 -0
  74. package/packages/core/package.json +10 -0
  75. package/packages/db/dist/client.js +69 -0
  76. package/packages/db/dist/index.js +756 -0
  77. package/packages/db/dist/schema/audit-events.js +13 -0
  78. package/packages/db/dist/schema/cc-sessions.js +14 -0
  79. package/packages/db/dist/schema/chats.js +35 -0
  80. package/packages/db/dist/schema/commitments.js +18 -0
  81. package/packages/db/dist/schema/communication-events.js +14 -0
  82. package/packages/db/dist/schema/index.js +14 -0
  83. package/packages/db/dist/schema/message-log.js +20 -0
  84. package/packages/db/dist/schema/opportunities.js +23 -0
  85. package/packages/db/dist/schema/org.js +36 -0
  86. package/packages/db/dist/schema/projects.js +23 -0
  87. package/packages/db/dist/schema/tasks.js +51 -0
  88. package/packages/db/dist/schema/topics.js +22 -0
  89. package/packages/db/dist/schema/trace-spans.js +19 -0
  90. package/packages/db/dist/schema/workspaces.js +15 -0
  91. package/packages/db/package.json +12 -0
  92. package/packages/db/src/migrations/0000_baseline.sql +251 -0
  93. package/packages/db/src/migrations/0001_workspaces.sql +19 -0
  94. package/packages/db/src/migrations/0002_workspace_parent.sql +1 -0
  95. package/packages/db/src/migrations/0003_chat_context.sql +3 -0
  96. package/packages/db/src/migrations/meta/_journal.json +34 -0
  97. package/packages/mcp-tools/dist/index.js +41 -0
  98. package/packages/mcp-tools/dist/layer1/audit-append.js +38 -0
  99. package/packages/mcp-tools/dist/layer1/audit-query.js +51 -0
  100. package/packages/mcp-tools/dist/layer1/memory-brief.js +168 -0
  101. package/packages/mcp-tools/dist/layer1/memory-context.js +124 -0
  102. package/packages/mcp-tools/dist/layer1/memory-digest.js +126 -0
  103. package/packages/mcp-tools/dist/layer1/memory-forget.js +108 -0
  104. package/packages/mcp-tools/dist/layer1/memory-learn.js +63 -0
  105. package/packages/mcp-tools/dist/layer1/memory-recall.js +287 -0
  106. package/packages/mcp-tools/dist/layer1/memory-reflect.js +80 -0
  107. package/packages/mcp-tools/dist/layer1/memory-remember.js +119 -0
  108. package/packages/mcp-tools/dist/layer1/memory-search.js +263 -0
  109. package/packages/mcp-tools/dist/layer1/memory-write.js +21 -0
  110. package/packages/mcp-tools/dist/layer1/org-lookup.js +47 -0
  111. package/packages/mcp-tools/dist/layer1/project-get.js +28 -0
  112. package/packages/mcp-tools/dist/layer1/project-list.js +20 -0
  113. package/packages/mcp-tools/dist/layer1/report-daily.js +68 -0
  114. package/packages/mcp-tools/dist/layer1/task-get.js +29 -0
  115. package/packages/mcp-tools/dist/layer1/task-update.js +34 -0
  116. package/packages/mcp-tools/dist/layer2/franky/topic-checkpoint.js +43 -0
  117. package/packages/mcp-tools/dist/layer2/franky/topic-escalate.js +19 -0
  118. package/packages/mcp-tools/dist/layer2/loid/decision-log.js +15 -0
  119. package/packages/mcp-tools/dist/layer2/loid/decision-no-action.js +15 -0
  120. package/packages/mcp-tools/dist/layer2/loid/delivery-create-pr.js +30 -0
  121. package/packages/mcp-tools/dist/layer2/loid/delivery-share.js +12 -0
  122. package/packages/mcp-tools/dist/layer2/loid/delivery-submit.js +77 -0
  123. package/packages/mcp-tools/dist/layer2/loid/delivery-upload.js +18 -0
  124. package/packages/mcp-tools/dist/layer2/loid/project-remove.js +16 -0
  125. package/packages/mcp-tools/dist/layer2/loid/project-upsert.js +33 -0
  126. package/packages/mcp-tools/dist/layer2/loid/task-dispatch.js +206 -0
  127. package/packages/mcp-tools/dist/layer2/loid/task-escalate-to-topic.js +170 -0
  128. package/packages/mcp-tools/dist/layer2/loid/task-lookup.js +45 -0
  129. package/packages/mcp-tools/dist/layer2/loid/topic-close.js +22 -0
  130. package/packages/mcp-tools/dist/layer2/loid/topic-create.js +60 -0
  131. package/packages/mcp-tools/dist/layer2/loid/yor-approve.js +8 -0
  132. package/packages/mcp-tools/dist/layer2/loid/yor-kill.js +7 -0
  133. package/packages/mcp-tools/dist/layer2/loid/yor-rework.js +7 -0
  134. package/packages/mcp-tools/dist/layer2/loid/yor-spawn.js +28 -0
  135. package/packages/mcp-tools/dist/layer2/loid/yor-status.js +8 -0
  136. package/packages/mcp-tools/dist/layer2/yor/task-block.js +11 -0
  137. package/packages/mcp-tools/dist/layer2/yor/task-deliver.js +35 -0
  138. package/packages/mcp-tools/dist/layer2/yor/task-progress.js +21 -0
  139. package/packages/mcp-tools/dist/layer3/adapters/feishu-adapter.js +203 -0
  140. package/packages/mcp-tools/dist/layer3/adapters/types.js +28 -0
  141. package/packages/mcp-tools/dist/layer3/channel-receive.js +11 -0
  142. package/packages/mcp-tools/dist/layer3/channel-send.js +75 -0
  143. package/packages/mcp-tools/dist/layer3/file-upload.js +44 -0
  144. package/packages/mcp-tools/dist/registry.js +911 -0
  145. package/packages/mcp-tools/package.json +13 -0
@@ -0,0 +1,353 @@
1
+ import * as Lark from '@larksuiteoapi/node-sdk';
2
+ import { getOrgMember, upsertOrgMember, upsertChat } from '@team-anya/db';
3
+ // ── 消息解析 ──
4
+ const ANYA_MENTION_PATTERN = /@anya\b/i;
5
+ /**
6
+ * 将飞书消息中的 @_user_N 占位符替换为 @真实用户名
7
+ * 飞书 text 消息内容中 @ 人显示为 @_user_1、@_user_2 等占位符,
8
+ * 真实信息在 mentions 数组中,需要做映射替换。
9
+ */
10
+ function resolveMentions(text, mentions) {
11
+ if (!mentions?.length)
12
+ return text;
13
+ let resolved = text;
14
+ for (const m of mentions) {
15
+ // m.key 形如 "@_user_1",替换为 "@真实名字"
16
+ resolved = resolved.replace(m.key, `@${m.name}`);
17
+ }
18
+ return resolved;
19
+ }
20
+ /**
21
+ * 解析飞书消息事件为统一的 IncomingMessage 格式
22
+ *
23
+ * downloader 为可选参数,不传时退化为占位符模式(向后兼容,测试友好)
24
+ */
25
+ export async function parseFeishuMessage(event, eventId, downloader) {
26
+ const msg = event.message;
27
+ const sender = event.sender;
28
+ const { text: rawText, media } = await extractContent(msg.message_type, msg.content, msg.message_id, downloader, msg.chat_id);
29
+ // 将 @_user_N 占位符替换为真实用户名(飞书 text 消息用占位符表示 @)
30
+ const text = resolveMentions(rawText, msg.mentions);
31
+ const hasMentionInList = msg.mentions?.some(m => /anya/i.test(m.name)) ?? false;
32
+ const hasMentionInContent = ANYA_MENTION_PATTERN.test(text);
33
+ return {
34
+ content: text,
35
+ media,
36
+ sender: sender?.sender_id.open_id,
37
+ chatId: msg.chat_id,
38
+ isDirectMessage: msg.chat_type === 'p2p',
39
+ mentionsAnya: hasMentionInList || hasMentionInContent,
40
+ metadata: {
41
+ ...(eventId ? { event_id: eventId } : {}),
42
+ message_id: msg.message_id,
43
+ message_type: msg.message_type,
44
+ source_type: 'feishu',
45
+ ...(event.header?.create_time ? { event_create_time: event.header.create_time } : {}),
46
+ ...(msg.parent_id ? { parent_message_id: msg.parent_id } : {}),
47
+ ...(msg.root_id ? { root_message_id: msg.root_id } : {}),
48
+ ...(msg.thread_id ? { thread_id: msg.thread_id } : {}),
49
+ },
50
+ };
51
+ }
52
+ /**
53
+ * 从飞书消息内容中提取文本和媒体附件
54
+ *
55
+ * @param depth 递归深度(用于 merge_forward 防无限递归),默认 0
56
+ */
57
+ async function extractContent(messageType, rawContent, messageId, downloader, chatId, depth = 0) {
58
+ try {
59
+ const parsed = JSON.parse(rawContent);
60
+ switch (messageType) {
61
+ case 'text':
62
+ return { text: parsed.text ?? '', media: [] };
63
+ case 'post': {
64
+ const title = parsed.title ?? '';
65
+ const textParts = [];
66
+ const mediaList = [];
67
+ if (title)
68
+ textParts.push(title);
69
+ const content = parsed.content;
70
+ if (Array.isArray(content)) {
71
+ for (const paragraph of content) {
72
+ if (Array.isArray(paragraph)) {
73
+ for (const element of paragraph) {
74
+ if (element.tag === 'text' && element.text) {
75
+ textParts.push(element.text);
76
+ }
77
+ else if (element.tag === 'a' && element.text) {
78
+ textParts.push(`[${element.text}](${element.href ?? ''})`);
79
+ }
80
+ else if (element.tag === 'at' && element.user_name) {
81
+ textParts.push(`@${element.user_name}`);
82
+ }
83
+ else if (element.tag === 'img' && element.image_key) {
84
+ // 富文本中的内嵌图片
85
+ if (downloader) {
86
+ const attachment = await downloader.download(messageId, 'image', { image_key: element.image_key }, chatId);
87
+ if (attachment) {
88
+ mediaList.push(attachment);
89
+ textParts.push(`[图片: ${attachment.originalName}] (本地路径: ${attachment.localPath})`);
90
+ }
91
+ else {
92
+ textParts.push(`[图片: 未能下载, key=${element.image_key}]`);
93
+ }
94
+ }
95
+ else {
96
+ textParts.push(`[图片: key=${element.image_key}]`);
97
+ }
98
+ }
99
+ else if (element.tag === 'media' && element.file_key) {
100
+ // 富文本中的内嵌视频/文件
101
+ if (downloader) {
102
+ const attachment = await downloader.download(messageId, 'video', { file_key: element.file_key }, chatId);
103
+ if (attachment) {
104
+ mediaList.push(attachment);
105
+ textParts.push(`[视频: ${attachment.originalName}] (本地路径: ${attachment.localPath})`);
106
+ }
107
+ else {
108
+ textParts.push(`[视频: 未能下载, key=${element.file_key}]`);
109
+ }
110
+ }
111
+ else {
112
+ textParts.push(`[视频: key=${element.file_key}]`);
113
+ }
114
+ }
115
+ else if (element.tag === 'code_block' && element.text) {
116
+ // 代码块
117
+ const lang = element.language?.toLowerCase() ?? '';
118
+ textParts.push(`\n\`\`\`${lang}\n${element.text}\n\`\`\`\n`);
119
+ }
120
+ else if (element.tag === 'emotion' && element.emoji_type) {
121
+ // 表情
122
+ textParts.push(`[${element.emoji_type}]`);
123
+ }
124
+ else if (element.tag === 'hr') {
125
+ // 分隔线
126
+ textParts.push('\n---\n');
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return { text: textParts.join(' ').trim(), media: mediaList };
133
+ }
134
+ case 'image':
135
+ case 'file':
136
+ case 'audio':
137
+ case 'video': {
138
+ if (downloader) {
139
+ const attachment = await downloader.download(messageId, messageType, parsed, chatId);
140
+ if (attachment) {
141
+ return {
142
+ text: `[${messageType}: ${attachment.originalName}] (本地路径: ${attachment.localPath})`,
143
+ media: [attachment],
144
+ };
145
+ }
146
+ }
147
+ // 降级:无 downloader 或下载失败
148
+ const fallbackKey = parsed.image_key ?? parsed.file_name ?? parsed.file_key ?? 'unknown';
149
+ return {
150
+ text: `[${messageType}: 未能下载, key=${fallbackKey}]`,
151
+ media: [],
152
+ };
153
+ }
154
+ case 'sticker':
155
+ return { text: '[表情包]', media: [] };
156
+ case 'location': {
157
+ const name = parsed.name ?? '未知位置';
158
+ const lng = parsed.longitude ?? '';
159
+ const lat = parsed.latitude ?? '';
160
+ const coords = lng && lat ? ` (${lng}, ${lat})` : '';
161
+ return { text: `[位置: ${name}${coords}]`, media: [] };
162
+ }
163
+ case 'merge_forward': {
164
+ if (!downloader || depth >= 2) {
165
+ return { text: '[合并转发消息]', media: [] };
166
+ }
167
+ const { items: subs, truncated, total } = await downloader.fetchSubMessages(messageId);
168
+ if (!subs.length) {
169
+ return { text: '[合并转发消息: 无法获取内容]', media: [] };
170
+ }
171
+ const parts = ['--- 转发消息 ---'];
172
+ const allMedia = [];
173
+ for (const sub of subs) {
174
+ const { text: subText, media: subMedia } = await extractContent(sub.msg_type, sub.content, sub.message_id, downloader, chatId, depth + 1);
175
+ const sender = sub.sender_name ?? '未知';
176
+ parts.push(`[${sender}] ${subText}`);
177
+ allMedia.push(...subMedia);
178
+ }
179
+ if (truncated) {
180
+ parts.push(`... 共 ${total} 条,仅展示前 ${subs.length} 条`);
181
+ }
182
+ parts.push('--- 转发消息结束 ---');
183
+ return { text: parts.join('\n'), media: allMedia };
184
+ }
185
+ default:
186
+ return { text: `[${messageType}]`, media: [] };
187
+ }
188
+ }
189
+ catch {
190
+ return { text: rawContent, media: [] };
191
+ }
192
+ }
193
+ // ── 用户信息解析 ──
194
+ /**
195
+ * 通过飞书 API 查询用户名称,优先从本地 DB 缓存读取
196
+ *
197
+ * 流程:DB 缓存命中 → 直接返回 | 未命中 → 飞书 API 查询 → 写入 DB → 返回
198
+ * 异常时降级返回 undefined,不阻断消息处理
199
+ */
200
+ export async function resolveFeishuUser(client, openId, db, logger) {
201
+ try {
202
+ // 1. 查本地缓存
203
+ const cached = getOrgMember(db, openId);
204
+ if (cached)
205
+ return cached.name;
206
+ // 2. 调飞书 API
207
+ const resp = await client.contact.user.get({
208
+ path: { user_id: openId },
209
+ params: { user_id_type: 'open_id' },
210
+ });
211
+ const user = resp?.data?.user;
212
+ const name = user?.name;
213
+ if (!name) {
214
+ logger?.warn({ openId, fields: Object.keys(user ?? {}) }, '飞书 API 未返回 name(可能缺少 contact:user.base:readonly 权限)');
215
+ }
216
+ if (!name)
217
+ return undefined;
218
+ // 3. 写入缓存(含扩展字段)
219
+ upsertOrgMember(db, {
220
+ member_id: openId,
221
+ name,
222
+ platform: 'feishu',
223
+ union_id: user?.union_id ?? undefined,
224
+ user_id: user?.user_id ?? undefined,
225
+ en_name: user?.en_name ?? undefined,
226
+ email: user?.email ?? undefined,
227
+ employee_no: user?.employee_no ?? undefined,
228
+ avatar_url: user?.avatar?.avatar_origin ?? user?.avatar?.avatar_72 ?? undefined,
229
+ last_synced_at: new Date().toISOString(),
230
+ });
231
+ logger?.info({ openId, name }, '新用户缓存');
232
+ return name;
233
+ }
234
+ catch (err) {
235
+ logger?.warn({ openId, err }, '解析用户信息失败');
236
+ return undefined;
237
+ }
238
+ }
239
+ // ── FeishuWSClient(基于官方 SDK)──
240
+ /**
241
+ * 飞书 WebSocket 长连接客户端
242
+ *
243
+ * 使用 @larksuiteoapi/node-sdk 的 WSClient 和 EventDispatcher
244
+ * SDK 内部自动处理 token 获取、刷新、断线重连
245
+ */
246
+ export class FeishuWSClient {
247
+ wsClient;
248
+ eventDispatcher;
249
+ onMessageCallback;
250
+ mediaDownloader;
251
+ larkClient;
252
+ db;
253
+ chatSyncService;
254
+ feishuSender;
255
+ log;
256
+ started = false;
257
+ constructor(config, options) {
258
+ this.onMessageCallback = options.onMessage;
259
+ this.mediaDownloader = options.mediaDownloader;
260
+ this.larkClient = options.client;
261
+ this.db = options.db;
262
+ this.chatSyncService = options.chatSyncService;
263
+ this.feishuSender = options.feishuSender;
264
+ this.log = options.logger ?? {
265
+ info: (obj, msg) => console.log('[feishu-ws]', msg ?? '', obj),
266
+ warn: (obj, msg) => console.warn('[feishu-ws]', msg ?? '', obj),
267
+ error: (obj, msg) => console.error('[feishu-ws]', msg ?? '', obj),
268
+ };
269
+ // 创建事件分发器
270
+ this.eventDispatcher = new Lark.EventDispatcher({});
271
+ // 注册消息接收事件
272
+ this.eventDispatcher.register({
273
+ 'im.message.receive_v1': async (data) => {
274
+ try {
275
+ this.log.info({ raw_event: data }, '收到飞书消息事件');
276
+ const event = data;
277
+ if (!event.message)
278
+ return;
279
+ const eventId = event.header?.event_id;
280
+ const message = await parseFeishuMessage(event, eventId, this.mediaDownloader);
281
+ // 立即添加"处理中"表情(fire-and-forget,大模型回复后会自动移除)
282
+ // 群消息且未 @ Anya 时不添加 Reaction
283
+ if (this.feishuSender && event.message.message_id && event.message.chat_id && (message.isDirectMessage || message.mentionsAnya)) {
284
+ this.feishuSender.addTypingReaction(event.message.chat_id, event.message.message_id).catch(err => {
285
+ this.log.warn({ err }, 'typing 表情添加失败');
286
+ });
287
+ }
288
+ // 解析发送者名称(异步,不阻断消息处理)
289
+ if (message.sender && this.larkClient && this.db) {
290
+ message.senderName = await resolveFeishuUser(this.larkClient, message.sender, this.db, this.log);
291
+ }
292
+ // 异步触发群信息同步(群聊走完整同步,p2p 私聊直接写入 chats 表)
293
+ if (message.chatId && !message.isDirectMessage && this.chatSyncService && this.chatSyncService.needsSync(message.chatId)) {
294
+ this.chatSyncService.syncChatFull(message.chatId).catch(err => {
295
+ this.log.warn({ err, chatId: message.chatId }, '群信息同步失败');
296
+ });
297
+ }
298
+ else if (message.chatId && message.isDirectMessage && this.db) {
299
+ // p2p 私聊:直接写入 chats 表,无需调飞书群 API
300
+ try {
301
+ const senderLabel = message.senderName ?? message.sender ?? 'unknown';
302
+ upsertChat(this.db, {
303
+ chat_id: message.chatId,
304
+ platform: 'feishu',
305
+ name: `p2p: ${senderLabel}`,
306
+ chat_type: 'p2p',
307
+ user_count: 2,
308
+ last_synced_at: new Date().toISOString(),
309
+ });
310
+ }
311
+ catch (err) {
312
+ this.log.warn({ err, chatId: message.chatId }, 'p2p chat 写入失败');
313
+ }
314
+ }
315
+ this.log.info({ parsed_message: message }, '消息解析完成');
316
+ // 非阻塞:不 await,让 SDK 在 3 秒内确认收到
317
+ this.onMessageCallback(message).catch(err => {
318
+ this.log.error({ err }, '消息处理失败');
319
+ });
320
+ }
321
+ catch (err) {
322
+ this.log.error({ err }, '消息处理异常');
323
+ }
324
+ },
325
+ });
326
+ // 创建 WebSocket 客户端
327
+ this.wsClient = new Lark.WSClient({
328
+ appId: config.appId,
329
+ appSecret: config.appSecret,
330
+ domain: Lark.Domain.Feishu,
331
+ loggerLevel: Lark.LoggerLevel.warn,
332
+ });
333
+ }
334
+ get isConnected() {
335
+ return this.started;
336
+ }
337
+ /**
338
+ * 启动 WebSocket 长连接
339
+ */
340
+ async connect() {
341
+ this.wsClient.start({ eventDispatcher: this.eventDispatcher });
342
+ this.started = true;
343
+ }
344
+ /**
345
+ * 关闭连接
346
+ * 设置 started=false 并将回调置为 no-op,防止关闭后仍处理消息
347
+ */
348
+ async close() {
349
+ this.started = false;
350
+ this.onMessageCallback = async () => { };
351
+ }
352
+ }
353
+ //# sourceMappingURL=feishu-ws.js.map
@@ -0,0 +1,154 @@
1
+ import { getRecentMessages } from '@team-anya/db';
2
+ // ── 话术池 ──
3
+ const SILENT_RESULT_REPLIES = [
4
+ '刚才处理完了但好像漏说了结果,你再问我一下?',
5
+ '不好意思,刚才干完活忘了汇报,你可以再@我一下',
6
+ '处理完了但我好像没回你,抱歉,你再说一声我接上',
7
+ ];
8
+ const STUCK_REPLIES = [
9
+ '我这边好像卡住了,正在尝试恢复,如果一直没反应你踢我一下',
10
+ '抱歉,我可能卡在某个环节了,你可以发条消息唤醒我试试',
11
+ '好像遇到了点问题转不动了,你再@我一下我重新来',
12
+ ];
13
+ function pickRandom(pool) {
14
+ return pool[Math.floor(Math.random() * pool.length)];
15
+ }
16
+ // ── HealthMonitor ──
17
+ /**
18
+ * 静默健康监控器
19
+ *
20
+ * 正常流程零感知,仅在异常时主动通知用户:
21
+ * 1. CC result 到了但没回复用户 → 兜底通知
22
+ * 2. CC 实例卡死(executing 但长时间无 progress) → 告警
23
+ */
24
+ export class HealthMonitor {
25
+ deps;
26
+ config;
27
+ logger;
28
+ scanTimer = null;
29
+ pendingChecks = new Map(); // instanceId → timer
30
+ /** 已通知过卡死的实例,防止重复告警 */
31
+ stuckNotified = new Set();
32
+ constructor(deps, config = {}) {
33
+ this.deps = deps;
34
+ this.logger = deps.logger ?? { info: console.log, error: console.error };
35
+ this.config = {
36
+ resultCheckDelayMs: config.resultCheckDelayMs ?? 10_000,
37
+ stuckThresholdMs: config.stuckThresholdMs ?? 3 * 60_000,
38
+ scanIntervalMs: config.scanIntervalMs ?? 30_000,
39
+ recentOutboundWindowMs: config.recentOutboundWindowMs ?? 30_000,
40
+ };
41
+ }
42
+ /**
43
+ * 启动监控:监听 broker 事件 + 启动定时扫描
44
+ */
45
+ start() {
46
+ // 场景 2:监听 result 事件,延迟检查是否有回复
47
+ this.deps.broker.on('instance.result', (instanceId, role, _result) => {
48
+ this.scheduleResultCheck(instanceId, role);
49
+ });
50
+ // 场景 3:定时扫描卡死实例
51
+ this.scanTimer = setInterval(() => this.checkStuckInstances(), this.config.scanIntervalMs);
52
+ // 实例退出时清理追踪状态
53
+ this.deps.broker.on('instance.exited', (instanceId) => {
54
+ this.cleanup(instanceId);
55
+ });
56
+ this.deps.broker.on('instance.crash', (instanceId) => {
57
+ this.cleanup(instanceId);
58
+ });
59
+ this.logger.info('[HealthMonitor] 已启动');
60
+ }
61
+ /**
62
+ * 停止监控
63
+ */
64
+ stop() {
65
+ if (this.scanTimer) {
66
+ clearInterval(this.scanTimer);
67
+ this.scanTimer = null;
68
+ }
69
+ for (const timer of this.pendingChecks.values()) {
70
+ clearTimeout(timer);
71
+ }
72
+ this.pendingChecks.clear();
73
+ this.stuckNotified.clear();
74
+ this.logger.info('[HealthMonitor] 已停止');
75
+ }
76
+ // ── 场景 2:result 到了但没回复 ──
77
+ scheduleResultCheck(instanceId, role) {
78
+ // 如果已有待检查的 timer,先清掉(同一实例连续 result)
79
+ const existing = this.pendingChecks.get(instanceId);
80
+ if (existing)
81
+ clearTimeout(existing);
82
+ const timer = setTimeout(() => {
83
+ this.pendingChecks.delete(instanceId);
84
+ this.checkResultReply(instanceId, role);
85
+ }, this.config.resultCheckDelayMs);
86
+ this.pendingChecks.set(instanceId, timer);
87
+ }
88
+ checkResultReply(instanceId, role) {
89
+ const chatId = this.deps.resolveChatId(instanceId, role);
90
+ if (!chatId)
91
+ return; // 无法定位用户,跳过
92
+ if (this.hasRecentOutbound(chatId))
93
+ return; // 已回复,正常
94
+ // 没回复 → 发兜底通知
95
+ this.notify(chatId, 'silent_result');
96
+ this.logger.info(`[HealthMonitor] result 无回复兜底通知 (instance: ${instanceId}, chat: ${chatId})`);
97
+ }
98
+ // ── 场景 3:卡死检测 ──
99
+ checkStuckInstances() {
100
+ const now = Date.now();
101
+ const instances = this.deps.broker.status();
102
+ for (const inst of instances) {
103
+ if (inst.state !== 'executing') {
104
+ // 不在执行态,清除已通知标记
105
+ this.stuckNotified.delete(inst.id);
106
+ continue;
107
+ }
108
+ const lastActivity = new Date(inst.lastActivityAt).getTime();
109
+ const elapsed = now - lastActivity;
110
+ if (elapsed < this.config.stuckThresholdMs)
111
+ continue;
112
+ if (this.stuckNotified.has(inst.id))
113
+ continue; // 已通知过
114
+ const chatId = this.deps.resolveChatId(inst.id, inst.role);
115
+ if (!chatId)
116
+ continue;
117
+ this.stuckNotified.add(inst.id);
118
+ this.notify(chatId, 'stuck');
119
+ this.logger.info(`[HealthMonitor] 卡死告警 (instance: ${inst.id}, idle: ${Math.round(elapsed / 1000)}s)`);
120
+ }
121
+ }
122
+ // ── 工具方法 ──
123
+ hasRecentOutbound(chatId) {
124
+ const since = new Date(Date.now() - this.config.recentOutboundWindowMs).toISOString();
125
+ const messages = getRecentMessages(this.deps.db, {
126
+ direction: 'outbound',
127
+ chat_id: chatId,
128
+ since,
129
+ limit: 1,
130
+ });
131
+ return messages.length > 0;
132
+ }
133
+ notify(chatId, type) {
134
+ const text = type === 'silent_result'
135
+ ? pickRandom(SILENT_RESULT_REPLIES)
136
+ : pickRandom(STUCK_REPLIES);
137
+ this.deps.feishuSender.sendText({
138
+ receiveIdType: 'chat_id',
139
+ receiveId: chatId,
140
+ text,
141
+ }).catch(err => {
142
+ this.logger.error(`[HealthMonitor] 通知发送失败 (chat: ${chatId}):`, err);
143
+ });
144
+ }
145
+ cleanup(instanceId) {
146
+ const timer = this.pendingChecks.get(instanceId);
147
+ if (timer) {
148
+ clearTimeout(timer);
149
+ this.pendingChecks.delete(instanceId);
150
+ }
151
+ this.stuckNotified.delete(instanceId);
152
+ }
153
+ }
154
+ //# sourceMappingURL=health-monitor.js.map