team-anya-cli 0.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.
- package/README.md +38 -0
- package/anya/prompts/execution-guides/git-delivery.md +38 -0
- package/anya/prompts/execution-guides/testing-and-self-heal.md +28 -0
- package/anya/prompts/protocols/brief-assembly.md +55 -0
- package/anya/prompts/protocols/report.md +175 -0
- package/anya/prompts/protocols/review.md +90 -0
- package/anya/prompts/task-claude-md.template.md +32 -0
- package/apps/server/dist/broker/cc-broker.js +257 -0
- package/apps/server/dist/cli.js +296 -0
- package/apps/server/dist/config.js +76 -0
- package/apps/server/dist/daemon.js +51 -0
- package/apps/server/dist/gateway/chat-sync.js +135 -0
- package/apps/server/dist/gateway/command-router.js +114 -0
- package/apps/server/dist/gateway/commands/cancel.js +32 -0
- package/apps/server/dist/gateway/commands/help.js +16 -0
- package/apps/server/dist/gateway/commands/index.js +26 -0
- package/apps/server/dist/gateway/commands/restart.js +34 -0
- package/apps/server/dist/gateway/commands/status.js +34 -0
- package/apps/server/dist/gateway/commands/tasks.js +33 -0
- package/apps/server/dist/gateway/feishu-sender.js +346 -0
- package/apps/server/dist/gateway/feishu-ws.js +254 -0
- package/apps/server/dist/gateway/http.js +994 -0
- package/apps/server/dist/gateway/media-downloader.js +149 -0
- package/apps/server/dist/gateway/message-events.js +10 -0
- package/apps/server/dist/gateway/message-intake.js +50 -0
- package/apps/server/dist/gateway/message-queue.js +104 -0
- package/apps/server/dist/gateway/session-reader.js +142 -0
- package/apps/server/dist/gateway/ws-push.js +115 -0
- package/apps/server/dist/loid/brain.js +104 -0
- package/apps/server/dist/loid/clarifier.js +162 -0
- package/apps/server/dist/loid/context-builder.js +413 -0
- package/apps/server/dist/loid/mcp-server.js +104 -0
- package/apps/server/dist/loid/memory-settler.js +189 -0
- package/apps/server/dist/loid/opportunity-manager.js +148 -0
- package/apps/server/dist/loid/profile-updater.js +179 -0
- package/apps/server/dist/loid/reporter.js +148 -0
- package/apps/server/dist/loid/schemas.js +117 -0
- package/apps/server/dist/loid/self-calibrator.js +314 -0
- package/apps/server/dist/loid/session-manager.js +217 -0
- package/apps/server/dist/loid/session.js +271 -0
- package/apps/server/dist/loid/worktree-manager.js +191 -0
- package/apps/server/dist/main.js +337 -0
- package/apps/server/dist/tracing/index.js +2 -0
- package/apps/server/dist/tracing/trace-context.js +92 -0
- package/apps/server/dist/types/message.js +2 -0
- package/apps/server/dist/yor/yor-mcp-server.js +104 -0
- package/apps/server/dist/yor/yor-orchestrator.js +233 -0
- package/apps/web/dist/assets/index-CHIT0Dya.css +1 -0
- package/apps/web/dist/assets/index-CJzAjoVH.js +798 -0
- package/apps/web/dist/index.html +13 -0
- package/package.json +42 -0
- package/packages/cc-client/dist/claude-code-backend.js +664 -0
- package/packages/cc-client/dist/index.js +2 -0
- package/packages/cc-client/package.json +11 -0
- package/packages/core/dist/constants.js +59 -0
- package/packages/core/dist/errors.js +35 -0
- package/packages/core/dist/index.js +7 -0
- package/packages/core/dist/office-init.js +97 -0
- package/packages/core/dist/scope/checker.js +114 -0
- package/packages/core/dist/scope/defaults.js +40 -0
- package/packages/core/dist/scope/index.js +3 -0
- package/packages/core/dist/state-machine.js +85 -0
- package/packages/core/dist/types/audit.js +12 -0
- package/packages/core/dist/types/backend.js +2 -0
- package/packages/core/dist/types/commitment.js +17 -0
- package/packages/core/dist/types/communication.js +18 -0
- package/packages/core/dist/types/index.js +8 -0
- package/packages/core/dist/types/opportunity.js +27 -0
- package/packages/core/dist/types/org.js +26 -0
- package/packages/core/dist/types/task.js +46 -0
- package/packages/core/package.json +10 -0
- package/packages/db/dist/client.js +69 -0
- package/packages/db/dist/index.js +603 -0
- package/packages/db/dist/schema/audit-events.js +13 -0
- package/packages/db/dist/schema/cc-sessions.js +14 -0
- package/packages/db/dist/schema/chats.js +33 -0
- package/packages/db/dist/schema/commitments.js +18 -0
- package/packages/db/dist/schema/communication-events.js +14 -0
- package/packages/db/dist/schema/index.js +12 -0
- package/packages/db/dist/schema/message-log.js +20 -0
- package/packages/db/dist/schema/opportunities.js +23 -0
- package/packages/db/dist/schema/org.js +36 -0
- package/packages/db/dist/schema/projects.js +23 -0
- package/packages/db/dist/schema/tasks.js +46 -0
- package/packages/db/dist/schema/trace-spans.js +19 -0
- package/packages/db/package.json +12 -0
- package/packages/db/src/migrations/0000_simple_magneto.sql +148 -0
- package/packages/db/src/migrations/0001_nifty_morph.sql +42 -0
- package/packages/db/src/migrations/0002_common_joshua_kane.sql +20 -0
- package/packages/db/src/migrations/0003_add_cc_sessions.sql +13 -0
- package/packages/db/src/migrations/0004_jittery_triathlon.sql +1 -0
- package/packages/db/src/migrations/meta/0000_snapshot.json +987 -0
- package/packages/db/src/migrations/meta/0001_snapshot.json +1280 -0
- package/packages/db/src/migrations/meta/0002_snapshot.json +1417 -0
- package/packages/db/src/migrations/meta/0004_snapshot.json +1505 -0
- package/packages/db/src/migrations/meta/_journal.json +41 -0
- package/packages/mcp-tools/dist/index.js +41 -0
- package/packages/mcp-tools/dist/layer1/audit-append.js +38 -0
- package/packages/mcp-tools/dist/layer1/audit-query.js +51 -0
- package/packages/mcp-tools/dist/layer1/memory-brief.js +168 -0
- package/packages/mcp-tools/dist/layer1/memory-context.js +124 -0
- package/packages/mcp-tools/dist/layer1/memory-digest.js +126 -0
- package/packages/mcp-tools/dist/layer1/memory-forget.js +108 -0
- package/packages/mcp-tools/dist/layer1/memory-learn.js +63 -0
- package/packages/mcp-tools/dist/layer1/memory-recall.js +287 -0
- package/packages/mcp-tools/dist/layer1/memory-reflect.js +80 -0
- package/packages/mcp-tools/dist/layer1/memory-remember.js +119 -0
- package/packages/mcp-tools/dist/layer1/memory-search.js +263 -0
- package/packages/mcp-tools/dist/layer1/memory-write.js +21 -0
- package/packages/mcp-tools/dist/layer1/org-lookup.js +47 -0
- package/packages/mcp-tools/dist/layer1/project-get.js +28 -0
- package/packages/mcp-tools/dist/layer1/project-list.js +20 -0
- package/packages/mcp-tools/dist/layer1/report-daily.js +68 -0
- package/packages/mcp-tools/dist/layer1/task-get.js +29 -0
- package/packages/mcp-tools/dist/layer1/task-update.js +34 -0
- package/packages/mcp-tools/dist/layer2/loid/decision-log.js +15 -0
- package/packages/mcp-tools/dist/layer2/loid/decision-no-action.js +15 -0
- package/packages/mcp-tools/dist/layer2/loid/delivery-create-pr.js +30 -0
- package/packages/mcp-tools/dist/layer2/loid/delivery-share.js +12 -0
- package/packages/mcp-tools/dist/layer2/loid/delivery-submit.js +77 -0
- package/packages/mcp-tools/dist/layer2/loid/delivery-upload.js +18 -0
- package/packages/mcp-tools/dist/layer2/loid/project-remove.js +16 -0
- package/packages/mcp-tools/dist/layer2/loid/project-upsert.js +33 -0
- package/packages/mcp-tools/dist/layer2/loid/task-dispatch.js +177 -0
- package/packages/mcp-tools/dist/layer2/loid/task-lookup.js +38 -0
- package/packages/mcp-tools/dist/layer2/loid/yor-approve.js +8 -0
- package/packages/mcp-tools/dist/layer2/loid/yor-kill.js +7 -0
- package/packages/mcp-tools/dist/layer2/loid/yor-rework.js +7 -0
- package/packages/mcp-tools/dist/layer2/loid/yor-spawn.js +15 -0
- package/packages/mcp-tools/dist/layer2/loid/yor-status.js +8 -0
- package/packages/mcp-tools/dist/layer2/yor/task-block.js +11 -0
- package/packages/mcp-tools/dist/layer2/yor/task-deliver.js +35 -0
- package/packages/mcp-tools/dist/layer2/yor/task-progress.js +21 -0
- package/packages/mcp-tools/dist/layer3/adapters/feishu-adapter.js +191 -0
- package/packages/mcp-tools/dist/layer3/adapters/types.js +28 -0
- package/packages/mcp-tools/dist/layer3/channel-receive.js +11 -0
- package/packages/mcp-tools/dist/layer3/channel-send.js +90 -0
- package/packages/mcp-tools/dist/layer3/file-upload.js +44 -0
- package/packages/mcp-tools/dist/registry.js +779 -0
- package/packages/mcp-tools/package.json +13 -0
- package/workspace/.claude/settings.local.json +9 -0
- package/workspace/.mcp.json +12 -0
- package/workspace/CHARTER.md +73 -0
- package/workspace/CLAUDE.md +49 -0
- package/workspace/PROTOCOL.md +126 -0
- package/workspace/TOOLS.md +464 -0
- package/workspace/audit/.gitkeep +0 -0
- package/workspace/loid/CLAUDE.md +12 -0
- package/workspace/loid/PLAYBOOK.md +198 -0
- package/workspace/loid/PROFILE.md +78 -0
- package/workspace/memory/commitments/.gitkeep +0 -0
- package/workspace/memory/execution/.gitkeep +0 -0
- package/workspace/memory/people/.gitkeep +0 -0
- package/workspace/memory/projects/.gitkeep +0 -0
- package/workspace/memory/self/.gitkeep +0 -0
- package/workspace/reference/identity/.gitkeep +0 -0
- package/workspace/reference/org/escalation.yaml +24 -0
- package/workspace/reference/org/ownership.yaml +28 -0
- package/workspace/reports/.gitkeep +0 -0
- package/workspace/yor/CLAUDE.md +22 -0
- package/workspace/yor/PLAYBOOK.md +73 -0
- package/workspace/yor/PROFILE.md +52 -0
- package/workspace/yor/SELF-HEAL.md +39 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
2
|
+
import { insertMessageLog } from '@team-anya/db';
|
|
3
|
+
import { messageEvents } from './message-events.js';
|
|
4
|
+
// ── markdownToFeishuCard ──
|
|
5
|
+
/**
|
|
6
|
+
* 将 markdown 内容转换为飞书交互式卡片格式
|
|
7
|
+
*/
|
|
8
|
+
export function markdownToFeishuCard(content, title) {
|
|
9
|
+
return {
|
|
10
|
+
schema: '2.0',
|
|
11
|
+
config: { wide_screen_mode: true },
|
|
12
|
+
header: {
|
|
13
|
+
title: { content: title, tag: 'plain_text' },
|
|
14
|
+
},
|
|
15
|
+
body: {
|
|
16
|
+
elements: [
|
|
17
|
+
{ tag: 'markdown', content },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ── FeishuSender(基于官方 SDK)──
|
|
23
|
+
/**
|
|
24
|
+
* 飞书消息发送器
|
|
25
|
+
*
|
|
26
|
+
* 使用 @larksuiteoapi/node-sdk 的 Client,SDK 内部自动处理 token 获取和缓存。
|
|
27
|
+
*
|
|
28
|
+
* 出站场景:
|
|
29
|
+
* - sendText / sendReply: 日常对话(纯文本,像正常同事说话)
|
|
30
|
+
* - sendBlockedAlert: 任务阻塞提醒
|
|
31
|
+
* - sendDailyReport: 日报推送(卡片,内容较长)
|
|
32
|
+
* - sendTaskDone: 任务完成通知(纯文本)
|
|
33
|
+
*/
|
|
34
|
+
export class FeishuSender {
|
|
35
|
+
client;
|
|
36
|
+
db;
|
|
37
|
+
constructor(config) {
|
|
38
|
+
this.client = new Lark.Client({
|
|
39
|
+
appId: config.appId,
|
|
40
|
+
appSecret: config.appSecret,
|
|
41
|
+
appType: Lark.AppType.SelfBuild,
|
|
42
|
+
domain: Lark.Domain.Feishu,
|
|
43
|
+
});
|
|
44
|
+
this.db = config.db ?? null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 写入 outbound message_log 并 emit 事件
|
|
48
|
+
*/
|
|
49
|
+
logOutbound(params) {
|
|
50
|
+
if (!this.db)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
const chatId = params.receiveIdType === 'chat_id' ? params.receiver : null;
|
|
54
|
+
const chatType = params.receiveIdType === 'chat_id' ? 'group'
|
|
55
|
+
: params.receiveIdType === 'open_id' ? 'p2p' : null;
|
|
56
|
+
const entry = insertMessageLog(this.db, {
|
|
57
|
+
direction: 'outbound',
|
|
58
|
+
source_type: 'feishu',
|
|
59
|
+
sender: 'anya',
|
|
60
|
+
receiver: params.receiver,
|
|
61
|
+
chat_id: chatId,
|
|
62
|
+
chat_type: chatType,
|
|
63
|
+
content: params.content,
|
|
64
|
+
message_type: params.message_type,
|
|
65
|
+
related_task_id: params.related_task_id ?? null,
|
|
66
|
+
});
|
|
67
|
+
messageEvents.emit('message', {
|
|
68
|
+
id: entry.id,
|
|
69
|
+
direction: 'outbound',
|
|
70
|
+
source_type: entry.source_type,
|
|
71
|
+
sender: entry.sender,
|
|
72
|
+
receiver: entry.receiver,
|
|
73
|
+
content: entry.content,
|
|
74
|
+
message_type: entry.message_type,
|
|
75
|
+
related_task_id: entry.related_task_id,
|
|
76
|
+
chat_id: entry.chat_id ?? null,
|
|
77
|
+
chat_type: entry.chat_type,
|
|
78
|
+
created_at: entry.created_at,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 日志写入失败不应阻断消息发送
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// ── 消息发送 ──
|
|
86
|
+
async sendCard(params) {
|
|
87
|
+
const content = JSON.stringify(params.card);
|
|
88
|
+
// 回复消息
|
|
89
|
+
if (params.replyToMessageId) {
|
|
90
|
+
const response = await this.client.im.message.reply({
|
|
91
|
+
path: { message_id: params.replyToMessageId },
|
|
92
|
+
data: {
|
|
93
|
+
content,
|
|
94
|
+
msg_type: 'interactive',
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
if (response.code !== 0) {
|
|
98
|
+
throw new Error(`飞书回复失败: ${response.msg || `code ${response.code}`}`);
|
|
99
|
+
}
|
|
100
|
+
return response.data?.message_id ?? 'unknown';
|
|
101
|
+
}
|
|
102
|
+
// 发送新消息
|
|
103
|
+
const response = await this.client.im.message.create({
|
|
104
|
+
params: { receive_id_type: params.receiveIdType },
|
|
105
|
+
data: {
|
|
106
|
+
receive_id: params.receiveId,
|
|
107
|
+
content,
|
|
108
|
+
msg_type: 'interactive',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
if (response.code !== 0) {
|
|
112
|
+
throw new Error(`飞书发送失败: ${response.msg || `code ${response.code}`}`);
|
|
113
|
+
}
|
|
114
|
+
return response.data?.message_id ?? 'unknown';
|
|
115
|
+
}
|
|
116
|
+
// ── 发送纯文本 ──
|
|
117
|
+
async sendText(params) {
|
|
118
|
+
const response = await this.client.im.message.create({
|
|
119
|
+
params: { receive_id_type: params.receiveIdType },
|
|
120
|
+
data: {
|
|
121
|
+
receive_id: params.receiveId,
|
|
122
|
+
content: JSON.stringify({ text: params.text }),
|
|
123
|
+
msg_type: 'text',
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
if (response.code !== 0) {
|
|
127
|
+
throw new Error(`飞书发送失败: ${response.msg || `code ${response.code}`}`);
|
|
128
|
+
}
|
|
129
|
+
return response.data?.message_id ?? 'unknown';
|
|
130
|
+
}
|
|
131
|
+
// ── 回复消息(纯文本,像正常同事说话)──
|
|
132
|
+
async sendReply(params) {
|
|
133
|
+
const response = await this.client.im.message.reply({
|
|
134
|
+
path: { message_id: params.replyToMessageId },
|
|
135
|
+
data: {
|
|
136
|
+
content: JSON.stringify({ text: params.text }),
|
|
137
|
+
msg_type: 'text',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
if (response.code !== 0) {
|
|
141
|
+
throw new Error(`飞书回复失败: ${response.msg || `code ${response.code}`}`);
|
|
142
|
+
}
|
|
143
|
+
return response.data?.message_id ?? 'unknown';
|
|
144
|
+
}
|
|
145
|
+
// ── 图片上传与发送 ──
|
|
146
|
+
/**
|
|
147
|
+
* 上传图片到飞书(获取 image_key)
|
|
148
|
+
*
|
|
149
|
+
* 调用 POST /im/v1/images 接口。
|
|
150
|
+
* 支持格式: png, jpg, jpeg, gif, bmp, webp, ico, tiff, tif
|
|
151
|
+
*/
|
|
152
|
+
async uploadImage(filePath) {
|
|
153
|
+
const fs = await import('node:fs');
|
|
154
|
+
const fileStream = fs.createReadStream(filePath);
|
|
155
|
+
const response = await this.client.im.image.create({
|
|
156
|
+
data: {
|
|
157
|
+
image_type: 'message',
|
|
158
|
+
image: fileStream,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
const result = response;
|
|
162
|
+
if (result.code != null && result.code !== 0) {
|
|
163
|
+
throw new Error(`飞书图片上传失败: ${result.msg || `code ${result.code}`}`);
|
|
164
|
+
}
|
|
165
|
+
const imageKey = result.data?.image_key ?? result.image_key;
|
|
166
|
+
if (!imageKey) {
|
|
167
|
+
throw new Error('飞书图片上传成功但未返回 image_key');
|
|
168
|
+
}
|
|
169
|
+
return imageKey;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 发送图片消息
|
|
173
|
+
*/
|
|
174
|
+
async sendImage(params) {
|
|
175
|
+
const content = JSON.stringify({ image_key: params.imageKey });
|
|
176
|
+
if (params.replyToMessageId) {
|
|
177
|
+
const response = await this.client.im.message.reply({
|
|
178
|
+
path: { message_id: params.replyToMessageId },
|
|
179
|
+
data: {
|
|
180
|
+
content,
|
|
181
|
+
msg_type: 'image',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
if (response.code !== 0) {
|
|
185
|
+
throw new Error(`飞书图片回复失败: ${response.msg || `code ${response.code}`}`);
|
|
186
|
+
}
|
|
187
|
+
return response.data?.message_id ?? 'unknown';
|
|
188
|
+
}
|
|
189
|
+
const response = await this.client.im.message.create({
|
|
190
|
+
params: { receive_id_type: params.receiveIdType },
|
|
191
|
+
data: {
|
|
192
|
+
receive_id: params.receiveId,
|
|
193
|
+
content,
|
|
194
|
+
msg_type: 'image',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
if (response.code !== 0) {
|
|
198
|
+
throw new Error(`飞书图片发送失败: ${response.msg || `code ${response.code}`}`);
|
|
199
|
+
}
|
|
200
|
+
return response.data?.message_id ?? 'unknown';
|
|
201
|
+
}
|
|
202
|
+
// ── 文件上传与发送 ──
|
|
203
|
+
/**
|
|
204
|
+
* 上传文件到飞书(获取 file_key)
|
|
205
|
+
*
|
|
206
|
+
* 调用 POST /im/v1/files 接口。
|
|
207
|
+
*/
|
|
208
|
+
async uploadFile(params) {
|
|
209
|
+
const fs = await import('node:fs');
|
|
210
|
+
const path = await import('node:path');
|
|
211
|
+
const fileStream = fs.createReadStream(params.filePath);
|
|
212
|
+
const finalName = params.fileName || path.basename(params.filePath);
|
|
213
|
+
const fileType = params.fileType || 'stream';
|
|
214
|
+
const response = await this.client.im.file.create({
|
|
215
|
+
data: {
|
|
216
|
+
file_type: fileType,
|
|
217
|
+
file_name: finalName,
|
|
218
|
+
file: fileStream,
|
|
219
|
+
...(params.duration != null ? { duration: params.duration } : {}),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
// im.file.create 返回 { file_key?: string } 或含错误信息
|
|
223
|
+
const result = response;
|
|
224
|
+
if (result.code != null && result.code !== 0) {
|
|
225
|
+
throw new Error(`飞书文件上传失败: ${result.msg || `code ${result.code}`}`);
|
|
226
|
+
}
|
|
227
|
+
const fileKey = result.data?.file_key ?? result.file_key;
|
|
228
|
+
if (!fileKey) {
|
|
229
|
+
throw new Error('飞书文件上传成功但未返回 file_key');
|
|
230
|
+
}
|
|
231
|
+
return fileKey;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 发送文件消息
|
|
235
|
+
*
|
|
236
|
+
* @param msgType - 消息类型:'file'(默认)或 'audio'(opus 语音)
|
|
237
|
+
*/
|
|
238
|
+
async sendFile(params) {
|
|
239
|
+
const msgType = params.msgType ?? 'file';
|
|
240
|
+
const content = JSON.stringify({ file_key: params.fileKey });
|
|
241
|
+
if (params.replyToMessageId) {
|
|
242
|
+
const response = await this.client.im.message.reply({
|
|
243
|
+
path: { message_id: params.replyToMessageId },
|
|
244
|
+
data: {
|
|
245
|
+
content,
|
|
246
|
+
msg_type: msgType,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
if (response.code !== 0) {
|
|
250
|
+
throw new Error(`飞书${msgType === 'audio' ? '语音' : '文件'}回复失败: ${response.msg || `code ${response.code}`}`);
|
|
251
|
+
}
|
|
252
|
+
return response.data?.message_id ?? 'unknown';
|
|
253
|
+
}
|
|
254
|
+
const response = await this.client.im.message.create({
|
|
255
|
+
params: { receive_id_type: params.receiveIdType },
|
|
256
|
+
data: {
|
|
257
|
+
receive_id: params.receiveId,
|
|
258
|
+
content,
|
|
259
|
+
msg_type: msgType,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (response.code !== 0) {
|
|
263
|
+
throw new Error(`飞书${msgType === 'audio' ? '语音' : '文件'}发送失败: ${response.msg || `code ${response.code}`}`);
|
|
264
|
+
}
|
|
265
|
+
return response.data?.message_id ?? 'unknown';
|
|
266
|
+
}
|
|
267
|
+
// ── 表情回复(Reaction)──
|
|
268
|
+
/**
|
|
269
|
+
* 给消息添加表情回复
|
|
270
|
+
*
|
|
271
|
+
* 用于收到用户消息后立即反馈"已收到",无需等待 Agent 处理完成。
|
|
272
|
+
* 调用飞书 POST /im/v1/messages/{message_id}/reactions
|
|
273
|
+
*/
|
|
274
|
+
async addReaction(messageId, emojiType = 'OnIt') {
|
|
275
|
+
try {
|
|
276
|
+
const response = await this.client.im.messageReaction.create({
|
|
277
|
+
path: { message_id: messageId },
|
|
278
|
+
data: {
|
|
279
|
+
reaction_type: { emoji_type: emojiType },
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
if (response.code !== 0) {
|
|
283
|
+
console.warn(`[feishu-sender] 添加表情回复失败: ${response.msg || `code ${response.code}`}`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
return response.data?.reaction_id ?? null;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
console.warn('[feishu-sender] 添加表情回复异常:', err);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// ── 出站场景 ──
|
|
294
|
+
async sendBlockedAlert(params) {
|
|
295
|
+
// 用自然语言,不用制式模板
|
|
296
|
+
const text = `${params.taskId} 卡住了——${params.reason}`;
|
|
297
|
+
const results = await Promise.allSettled(params.targetUserIds.map(userId => this.sendText({
|
|
298
|
+
receiveIdType: 'open_id',
|
|
299
|
+
receiveId: userId,
|
|
300
|
+
text,
|
|
301
|
+
})));
|
|
302
|
+
for (const userId of params.targetUserIds) {
|
|
303
|
+
this.logOutbound({
|
|
304
|
+
receiver: userId,
|
|
305
|
+
content: text,
|
|
306
|
+
message_type: 'alert',
|
|
307
|
+
related_task_id: params.taskId,
|
|
308
|
+
receiveIdType: 'open_id',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return results;
|
|
312
|
+
}
|
|
313
|
+
async sendDailyReport(params) {
|
|
314
|
+
// 日报内容较长,保留卡片格式便于阅读
|
|
315
|
+
const card = markdownToFeishuCard(params.reportContent, `${params.date} 工作小结`);
|
|
316
|
+
await this.sendCard({
|
|
317
|
+
receiveIdType: 'chat_id',
|
|
318
|
+
receiveId: params.chatId,
|
|
319
|
+
card,
|
|
320
|
+
});
|
|
321
|
+
this.logOutbound({
|
|
322
|
+
receiver: params.chatId,
|
|
323
|
+
content: params.reportContent,
|
|
324
|
+
message_type: 'report',
|
|
325
|
+
receiveIdType: 'chat_id',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async sendTaskDone(params) {
|
|
329
|
+
// 用自然语言,像同事在群里说"搞定了"
|
|
330
|
+
const prPart = params.prUrl ? `,PR: ${params.prUrl}` : '';
|
|
331
|
+
const text = `${params.taskId} 搞定了——${params.summary}${prPart}`;
|
|
332
|
+
await this.sendText({
|
|
333
|
+
receiveIdType: 'chat_id',
|
|
334
|
+
receiveId: params.chatId,
|
|
335
|
+
text,
|
|
336
|
+
});
|
|
337
|
+
this.logOutbound({
|
|
338
|
+
receiver: params.chatId,
|
|
339
|
+
content: text,
|
|
340
|
+
message_type: 'task_done',
|
|
341
|
+
related_task_id: params.taskId,
|
|
342
|
+
receiveIdType: 'chat_id',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
//# sourceMappingURL=feishu-sender.js.map
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
* 解析飞书消息事件为统一的 IncomingMessage 格式
|
|
7
|
+
*
|
|
8
|
+
* downloader 为可选参数,不传时退化为占位符模式(向后兼容,测试友好)
|
|
9
|
+
*/
|
|
10
|
+
export async function parseFeishuMessage(event, eventId, downloader) {
|
|
11
|
+
const msg = event.message;
|
|
12
|
+
const sender = event.sender;
|
|
13
|
+
const { text, media } = await extractContent(msg.message_type, msg.content, msg.message_id, downloader);
|
|
14
|
+
const hasMentionInList = msg.mentions?.some(m => /anya/i.test(m.name)) ?? false;
|
|
15
|
+
const hasMentionInContent = ANYA_MENTION_PATTERN.test(text);
|
|
16
|
+
return {
|
|
17
|
+
content: text,
|
|
18
|
+
media,
|
|
19
|
+
sender: sender?.sender_id.open_id,
|
|
20
|
+
chatId: msg.chat_id,
|
|
21
|
+
isDirectMessage: msg.chat_type === 'p2p',
|
|
22
|
+
mentionsAnya: hasMentionInList || hasMentionInContent,
|
|
23
|
+
metadata: {
|
|
24
|
+
...(eventId ? { event_id: eventId } : {}),
|
|
25
|
+
message_id: msg.message_id,
|
|
26
|
+
message_type: msg.message_type,
|
|
27
|
+
source_type: 'feishu',
|
|
28
|
+
...(msg.parent_id ? { parent_message_id: msg.parent_id } : {}),
|
|
29
|
+
...(msg.root_id ? { root_message_id: msg.root_id } : {}),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 从飞书消息内容中提取文本和媒体附件
|
|
35
|
+
*/
|
|
36
|
+
async function extractContent(messageType, rawContent, messageId, downloader) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(rawContent);
|
|
39
|
+
switch (messageType) {
|
|
40
|
+
case 'text':
|
|
41
|
+
return { text: parsed.text ?? '', media: [] };
|
|
42
|
+
case 'post': {
|
|
43
|
+
const title = parsed.title ?? '';
|
|
44
|
+
const textParts = [];
|
|
45
|
+
if (title)
|
|
46
|
+
textParts.push(title);
|
|
47
|
+
const content = parsed.content;
|
|
48
|
+
if (Array.isArray(content)) {
|
|
49
|
+
for (const paragraph of content) {
|
|
50
|
+
if (Array.isArray(paragraph)) {
|
|
51
|
+
for (const element of paragraph) {
|
|
52
|
+
if (element.tag === 'text' && element.text) {
|
|
53
|
+
textParts.push(element.text);
|
|
54
|
+
}
|
|
55
|
+
else if (element.tag === 'a' && element.text) {
|
|
56
|
+
textParts.push(element.text);
|
|
57
|
+
}
|
|
58
|
+
else if (element.tag === 'at' && element.user_name) {
|
|
59
|
+
textParts.push(`@${element.user_name}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { text: textParts.join(' ').trim(), media: [] };
|
|
66
|
+
}
|
|
67
|
+
case 'image':
|
|
68
|
+
case 'file':
|
|
69
|
+
case 'audio':
|
|
70
|
+
case 'video': {
|
|
71
|
+
if (downloader) {
|
|
72
|
+
const attachment = await downloader.download(messageId, messageType, parsed);
|
|
73
|
+
if (attachment) {
|
|
74
|
+
return {
|
|
75
|
+
text: `[${messageType}: ${attachment.originalName}] (本地路径: ${attachment.localPath})`,
|
|
76
|
+
media: [attachment],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 降级:无 downloader 或下载失败
|
|
81
|
+
const fallbackKey = parsed.image_key ?? parsed.file_name ?? parsed.file_key ?? 'unknown';
|
|
82
|
+
return {
|
|
83
|
+
text: `[${messageType}: 未能下载, key=${fallbackKey}]`,
|
|
84
|
+
media: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
return { text: `[${messageType}]`, media: [] };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { text: rawContent, media: [] };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── 用户信息解析 ──
|
|
96
|
+
/**
|
|
97
|
+
* 通过飞书 API 查询用户名称,优先从本地 DB 缓存读取
|
|
98
|
+
*
|
|
99
|
+
* 流程:DB 缓存命中 → 直接返回 | 未命中 → 飞书 API 查询 → 写入 DB → 返回
|
|
100
|
+
* 异常时降级返回 undefined,不阻断消息处理
|
|
101
|
+
*/
|
|
102
|
+
export async function resolveFeishuUser(client, openId, db, logger) {
|
|
103
|
+
try {
|
|
104
|
+
// 1. 查本地缓存
|
|
105
|
+
const cached = getOrgMember(db, openId);
|
|
106
|
+
if (cached)
|
|
107
|
+
return cached.name;
|
|
108
|
+
// 2. 调飞书 API
|
|
109
|
+
const resp = await client.contact.user.get({
|
|
110
|
+
path: { user_id: openId },
|
|
111
|
+
params: { user_id_type: 'open_id' },
|
|
112
|
+
});
|
|
113
|
+
const user = resp?.data?.user;
|
|
114
|
+
const name = user?.name;
|
|
115
|
+
if (!name) {
|
|
116
|
+
logger?.warn({ openId, fields: Object.keys(user ?? {}) }, '飞书 API 未返回 name(可能缺少 contact:user.base:readonly 权限)');
|
|
117
|
+
}
|
|
118
|
+
if (!name)
|
|
119
|
+
return undefined;
|
|
120
|
+
// 3. 写入缓存(含扩展字段)
|
|
121
|
+
upsertOrgMember(db, {
|
|
122
|
+
member_id: openId,
|
|
123
|
+
name,
|
|
124
|
+
platform: 'feishu',
|
|
125
|
+
union_id: user?.union_id ?? undefined,
|
|
126
|
+
user_id: user?.user_id ?? undefined,
|
|
127
|
+
en_name: user?.en_name ?? undefined,
|
|
128
|
+
email: user?.email ?? undefined,
|
|
129
|
+
employee_no: user?.employee_no ?? undefined,
|
|
130
|
+
avatar_url: user?.avatar?.avatar_origin ?? user?.avatar?.avatar_72 ?? undefined,
|
|
131
|
+
last_synced_at: new Date().toISOString(),
|
|
132
|
+
});
|
|
133
|
+
logger?.info({ openId, name }, '新用户缓存');
|
|
134
|
+
return name;
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger?.warn({ openId, err }, '解析用户信息失败');
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ── FeishuWSClient(基于官方 SDK)──
|
|
142
|
+
/**
|
|
143
|
+
* 飞书 WebSocket 长连接客户端
|
|
144
|
+
*
|
|
145
|
+
* 使用 @larksuiteoapi/node-sdk 的 WSClient 和 EventDispatcher
|
|
146
|
+
* SDK 内部自动处理 token 获取、刷新、断线重连
|
|
147
|
+
*/
|
|
148
|
+
export class FeishuWSClient {
|
|
149
|
+
wsClient;
|
|
150
|
+
eventDispatcher;
|
|
151
|
+
onMessageCallback;
|
|
152
|
+
mediaDownloader;
|
|
153
|
+
larkClient;
|
|
154
|
+
db;
|
|
155
|
+
chatSyncService;
|
|
156
|
+
feishuSender;
|
|
157
|
+
log;
|
|
158
|
+
started = false;
|
|
159
|
+
constructor(config, options) {
|
|
160
|
+
this.onMessageCallback = options.onMessage;
|
|
161
|
+
this.mediaDownloader = options.mediaDownloader;
|
|
162
|
+
this.larkClient = options.client;
|
|
163
|
+
this.db = options.db;
|
|
164
|
+
this.chatSyncService = options.chatSyncService;
|
|
165
|
+
this.feishuSender = options.feishuSender;
|
|
166
|
+
this.log = options.logger ?? {
|
|
167
|
+
info: (obj, msg) => console.log('[feishu-ws]', msg ?? '', obj),
|
|
168
|
+
warn: (obj, msg) => console.warn('[feishu-ws]', msg ?? '', obj),
|
|
169
|
+
error: (obj, msg) => console.error('[feishu-ws]', msg ?? '', obj),
|
|
170
|
+
};
|
|
171
|
+
// 创建事件分发器
|
|
172
|
+
this.eventDispatcher = new Lark.EventDispatcher({});
|
|
173
|
+
// 注册消息接收事件
|
|
174
|
+
this.eventDispatcher.register({
|
|
175
|
+
'im.message.receive_v1': async (data) => {
|
|
176
|
+
try {
|
|
177
|
+
this.log.info({ raw_event: data }, '收到飞书消息事件');
|
|
178
|
+
const event = data;
|
|
179
|
+
if (!event.message)
|
|
180
|
+
return;
|
|
181
|
+
const eventId = event.header?.event_id;
|
|
182
|
+
const message = await parseFeishuMessage(event, eventId, this.mediaDownloader);
|
|
183
|
+
// 立即添加表情回复(fire-and-forget,不阻断消息处理)
|
|
184
|
+
if (this.feishuSender && event.message.message_id) {
|
|
185
|
+
this.feishuSender.addReaction(event.message.message_id, 'OnIt').catch(err => {
|
|
186
|
+
this.log.warn({ err }, '表情回复失败');
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// 解析发送者名称(异步,不阻断消息处理)
|
|
190
|
+
if (message.sender && this.larkClient && this.db) {
|
|
191
|
+
message.senderName = await resolveFeishuUser(this.larkClient, message.sender, this.db, this.log);
|
|
192
|
+
}
|
|
193
|
+
// 异步触发群信息同步(群聊走完整同步,p2p 私聊直接写入 chats 表)
|
|
194
|
+
if (message.chatId && !message.isDirectMessage && this.chatSyncService && this.chatSyncService.needsSync(message.chatId)) {
|
|
195
|
+
this.chatSyncService.syncChatFull(message.chatId).catch(err => {
|
|
196
|
+
this.log.warn({ err, chatId: message.chatId }, '群信息同步失败');
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (message.chatId && message.isDirectMessage && this.db) {
|
|
200
|
+
// p2p 私聊:直接写入 chats 表,无需调飞书群 API
|
|
201
|
+
try {
|
|
202
|
+
const senderLabel = message.senderName ?? message.sender ?? 'unknown';
|
|
203
|
+
upsertChat(this.db, {
|
|
204
|
+
chat_id: message.chatId,
|
|
205
|
+
platform: 'feishu',
|
|
206
|
+
name: `p2p: ${senderLabel}`,
|
|
207
|
+
chat_type: 'p2p',
|
|
208
|
+
user_count: 2,
|
|
209
|
+
last_synced_at: new Date().toISOString(),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
this.log.warn({ err, chatId: message.chatId }, 'p2p chat 写入失败');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
this.log.info({ parsed_message: message }, '消息解析完成');
|
|
217
|
+
// 非阻塞:不 await,让 SDK 在 3 秒内确认收到
|
|
218
|
+
this.onMessageCallback(message).catch(err => {
|
|
219
|
+
this.log.error({ err }, '消息处理失败');
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
this.log.error({ err }, '消息处理异常');
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
// 创建 WebSocket 客户端
|
|
228
|
+
this.wsClient = new Lark.WSClient({
|
|
229
|
+
appId: config.appId,
|
|
230
|
+
appSecret: config.appSecret,
|
|
231
|
+
domain: Lark.Domain.Feishu,
|
|
232
|
+
loggerLevel: Lark.LoggerLevel.warn,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
get isConnected() {
|
|
236
|
+
return this.started;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 启动 WebSocket 长连接
|
|
240
|
+
*/
|
|
241
|
+
async connect() {
|
|
242
|
+
this.wsClient.start({ eventDispatcher: this.eventDispatcher });
|
|
243
|
+
this.started = true;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* 关闭连接
|
|
247
|
+
* 设置 started=false 并将回调置为 no-op,防止关闭后仍处理消息
|
|
248
|
+
*/
|
|
249
|
+
async close() {
|
|
250
|
+
this.started = false;
|
|
251
|
+
this.onMessageCallback = async () => { };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=feishu-ws.js.map
|