@router-agent/channel 0.3.31 → 0.3.32
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/package.json +3 -3
- package/dist/feishu/index.js +0 -146
- package/dist/feishu/index.js.map +0 -1
- package/dist/legacy.js +0 -26
- package/dist/legacy.js.map +0 -1
- package/dist/sources/feishu-bot-admin.js +0 -119
- package/dist/sources/feishu-bot-admin.js.map +0 -1
- package/dist/sources/feishu-directory.js +0 -77
- package/dist/sources/feishu-directory.js.map +0 -1
- package/dist/sources/feishu-identity.js +0 -646
- package/dist/sources/feishu-identity.js.map +0 -1
- package/dist/sources/feishu-image.js +0 -338
- package/dist/sources/feishu-image.js.map +0 -1
- package/dist/sources/feishu.js +0 -645
- package/dist/sources/feishu.js.map +0 -1
- package/dist/sources/lark.js +0 -214
- package/dist/sources/lark.js.map +0 -1
|
@@ -1,646 +0,0 @@
|
|
|
1
|
-
import { errorMessage, noopLogger } from '@router-agent/infra/logger.js';
|
|
2
|
-
import { parseLarkMembers } from './lark.js';
|
|
3
|
-
/** open_id 跨应用:当前应用查不到这个 id。 */
|
|
4
|
-
const CODE_CROSS_APP = '99992361';
|
|
5
|
-
/** open_id 无效 / 不存在。 */
|
|
6
|
-
const CODE_NOT_FOUND = '99992351';
|
|
7
|
-
/** 通讯录调用超时;超时按「暂时查不了」处理,条目保留待重试。 */
|
|
8
|
-
const CONTACT_TIMEOUT_MS = 20_000;
|
|
9
|
-
/**
|
|
10
|
-
* `batch_get_id` 单次请求的邮箱数量上限。
|
|
11
|
-
*
|
|
12
|
-
* 飞书 `contact/v3/users/batch_get_id` 对 `emails` 数组有数量上限(本项目未实测,
|
|
13
|
-
* 这里按 50 取保守值)。超限时接口会直接报错,整批条目都会变成「待重试」,
|
|
14
|
-
* 下游按 open_ids 重建名单时等于被清空——和这次要消灭的故障是同一种表现,
|
|
15
|
-
* 所以必须分片请求,单片失败只影响该片。
|
|
16
|
-
*/
|
|
17
|
-
const BATCH_GET_ID_EMAIL_LIMIT = 50;
|
|
18
|
-
/** 错误详情最多保留这么多个字符,避免日志被长文本撑爆。 */
|
|
19
|
-
const DETAIL_MAX = 200;
|
|
20
|
-
/** 判定条目形态:邮箱 / open_id / 非法。 */
|
|
21
|
-
export function classifyEntry(raw) {
|
|
22
|
-
const value = String(raw ?? '').trim();
|
|
23
|
-
if (!value)
|
|
24
|
-
return { kind: 'invalid', value };
|
|
25
|
-
if (value.includes('@'))
|
|
26
|
-
return { kind: 'email', value };
|
|
27
|
-
if (value.startsWith('ou_'))
|
|
28
|
-
return { kind: 'open_id', value };
|
|
29
|
-
return { kind: 'invalid', value };
|
|
30
|
-
}
|
|
31
|
-
/** 邮箱掩码:只留首字符,域名保留(`p***@dewu.com`)。 */
|
|
32
|
-
export function maskEmail(email) {
|
|
33
|
-
const at = email.indexOf('@');
|
|
34
|
-
if (at <= 0)
|
|
35
|
-
return email;
|
|
36
|
-
const local = email.slice(0, at);
|
|
37
|
-
return `${local.slice(0, 1)}***${email.slice(at)}`;
|
|
38
|
-
}
|
|
39
|
-
/** 解析 `batch_get_id` 的返回:email(小写)→ open_id。查无此人的条目不会出现在映射里。 */
|
|
40
|
-
export function parseBatchGetId(stdout) {
|
|
41
|
-
return parseBatchPayload(stdout).map;
|
|
42
|
-
}
|
|
43
|
-
/** 解析 `contact +get-user` 的返回;拿不到用户时返回 null。 */
|
|
44
|
-
export function parseContactUser(stdout) {
|
|
45
|
-
const payload = parseContactPayload(stdout);
|
|
46
|
-
return payload.user;
|
|
47
|
-
}
|
|
48
|
-
export class IdentityResolver {
|
|
49
|
-
lark;
|
|
50
|
-
appId;
|
|
51
|
-
cache;
|
|
52
|
-
logger;
|
|
53
|
-
constructor(opts) {
|
|
54
|
-
this.lark = opts.lark;
|
|
55
|
-
this.appId = opts.appId ?? '';
|
|
56
|
-
this.cache = opts.cache;
|
|
57
|
-
this.logger = opts.logger ?? noopLogger;
|
|
58
|
-
}
|
|
59
|
-
async resolveEntries(entries) {
|
|
60
|
-
const raw = entries.map(entry => String(entry ?? ''));
|
|
61
|
-
const kinds = raw.map(entry => classifyEntry(entry).kind);
|
|
62
|
-
const result = raw.map((entry, index) => ({
|
|
63
|
-
entry,
|
|
64
|
-
kind: kinds[index] ?? 'invalid',
|
|
65
|
-
openId: '',
|
|
66
|
-
name: '',
|
|
67
|
-
status: 'invalid',
|
|
68
|
-
detail: '',
|
|
69
|
-
}));
|
|
70
|
-
await this.resolveInvalid(result, kinds);
|
|
71
|
-
await this.resolveOpenIds(result, kinds);
|
|
72
|
-
await this.resolveEmails(result, kinds);
|
|
73
|
-
const openIds = [];
|
|
74
|
-
const names = new Map();
|
|
75
|
-
const invalid = [];
|
|
76
|
-
const pending = [];
|
|
77
|
-
for (const entry of result) {
|
|
78
|
-
// 只要拿到了 open_id 就进名单:用户手填的 ou_xxx 即使这次没验证成,
|
|
79
|
-
// 也不会匹配到别人(open_id 是应用内唯一的),先保住名单再等下次重试。
|
|
80
|
-
if (entry.openId && !openIds.includes(entry.openId))
|
|
81
|
-
openIds.push(entry.openId);
|
|
82
|
-
if (entry.openId && entry.name)
|
|
83
|
-
names.set(entry.openId, entry.name);
|
|
84
|
-
if (entry.status === 'invalid')
|
|
85
|
-
invalid.push(entry);
|
|
86
|
-
else if (entry.status === 'pending')
|
|
87
|
-
pending.push(entry);
|
|
88
|
-
}
|
|
89
|
-
return { entries: result, openIds, names, invalid, pending };
|
|
90
|
-
}
|
|
91
|
-
/** 非法条目:不是邮箱也不是 open_id,直接判 invalid。 */
|
|
92
|
-
async resolveInvalid(result, kinds) {
|
|
93
|
-
for (let index = 0; index < result.length; index += 1) {
|
|
94
|
-
if (kinds[index] !== 'invalid')
|
|
95
|
-
continue;
|
|
96
|
-
const entry = result[index];
|
|
97
|
-
entry.status = 'invalid';
|
|
98
|
-
entry.detail = '不是邮箱,也不是 ou_ 开头的 open_id';
|
|
99
|
-
this.logger.warn('identity:resolve-failed', { entry: entry.entry, detail: entry.detail });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
/** open_id 条目:查缓存 → 查通讯录拿名字。 */
|
|
103
|
-
async resolveOpenIds(result, kinds) {
|
|
104
|
-
for (let index = 0; index < result.length; index += 1) {
|
|
105
|
-
if (kinds[index] !== 'open_id')
|
|
106
|
-
continue;
|
|
107
|
-
const entry = result[index];
|
|
108
|
-
const openId = entry.entry.trim();
|
|
109
|
-
const cached = this.readCache('open_id', openId);
|
|
110
|
-
if (cached) {
|
|
111
|
-
entry.status = 'resolved';
|
|
112
|
-
entry.openId = cached.openId || openId;
|
|
113
|
-
entry.name = cached.name;
|
|
114
|
-
this.logResolved(entry);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
const payload = await this.fetchContactUser(openId);
|
|
118
|
-
if (payload.status === 'ok' && payload.user) {
|
|
119
|
-
entry.status = 'resolved';
|
|
120
|
-
entry.openId = payload.user.openId || openId;
|
|
121
|
-
entry.name = payload.user.name;
|
|
122
|
-
// 只有名字也拿到了才写缓存:只缓存空名字会让下次解析直接命中一个没有名字的缓存。
|
|
123
|
-
if (entry.name)
|
|
124
|
-
this.writeCache('open_id', openId, entry.openId, entry.name);
|
|
125
|
-
this.logResolved(entry);
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (payload.status === 'invalid') {
|
|
129
|
-
entry.status = 'invalid';
|
|
130
|
-
entry.detail = payload.detail;
|
|
131
|
-
this.logger.warn('identity:resolve-failed', { entry: entry.entry, detail: entry.detail });
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
// 暂时查不了:条目保留,open_id 就是用户填的值,先按它继续匹配。
|
|
135
|
-
entry.status = 'pending';
|
|
136
|
-
entry.openId = openId;
|
|
137
|
-
entry.detail = payload.detail;
|
|
138
|
-
this.logger.warn('identity:resolve-deferred', { entry: entry.entry, detail: entry.detail });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
/** 邮箱条目:查缓存 → 分批 batch_get_id → 补通讯录显示名。 */
|
|
142
|
-
async resolveEmails(result, kinds) {
|
|
143
|
-
const needBatch = [];
|
|
144
|
-
for (let index = 0; index < result.length; index += 1) {
|
|
145
|
-
if (kinds[index] !== 'email')
|
|
146
|
-
continue;
|
|
147
|
-
const entry = result[index];
|
|
148
|
-
const email = entry.entry.trim();
|
|
149
|
-
const cached = this.readCache('email', email);
|
|
150
|
-
if (cached) {
|
|
151
|
-
entry.status = 'resolved';
|
|
152
|
-
entry.openId = cached.openId;
|
|
153
|
-
entry.name = cached.name;
|
|
154
|
-
this.logResolved(entry);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
needBatch.push(index);
|
|
158
|
-
}
|
|
159
|
-
if (needBatch.length === 0)
|
|
160
|
-
return;
|
|
161
|
-
// 同一个邮箱只查一次;分片时单片失败只影响该片的条目。
|
|
162
|
-
const wanted = new Map();
|
|
163
|
-
for (const index of needBatch) {
|
|
164
|
-
const original = result[index].entry.trim();
|
|
165
|
-
const key = original.toLowerCase();
|
|
166
|
-
const found = wanted.get(key);
|
|
167
|
-
if (found)
|
|
168
|
-
found.indexes.push(index);
|
|
169
|
-
else
|
|
170
|
-
wanted.set(key, { original, indexes: [index] });
|
|
171
|
-
}
|
|
172
|
-
for (const shard of chunk([...wanted.values()], BATCH_GET_ID_EMAIL_LIMIT)) {
|
|
173
|
-
const batch = await this.fetchBatch(shard.map(item => item.original), shard.length);
|
|
174
|
-
if (!batch.ok) {
|
|
175
|
-
// 这一片查不了:片内条目全部保留待重试,不能当成查无此人。
|
|
176
|
-
for (const item of shard) {
|
|
177
|
-
for (const index of item.indexes) {
|
|
178
|
-
const entry = result[index];
|
|
179
|
-
entry.status = 'pending';
|
|
180
|
-
entry.detail = batch.detail;
|
|
181
|
-
this.logger.warn('identity:resolve-deferred', { entry: maskEntry(entry.entry), detail: entry.detail });
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
continue;
|
|
185
|
-
}
|
|
186
|
-
for (const item of shard) {
|
|
187
|
-
const key = item.original.toLowerCase();
|
|
188
|
-
const openId = batch.map.get(key);
|
|
189
|
-
if (!openId) {
|
|
190
|
-
// 响应结构完好(条数对得上、每条都有邮箱),却查不到 user_id:
|
|
191
|
-
// 这才是「确认查无此人」,条目丢弃。
|
|
192
|
-
for (const index of item.indexes) {
|
|
193
|
-
const entry = result[index];
|
|
194
|
-
entry.status = 'invalid';
|
|
195
|
-
entry.detail = `查无此人:通讯录里没有 ${maskEmail(item.original)}`;
|
|
196
|
-
this.logger.warn('identity:resolve-failed', { entry: maskEmail(item.original), detail: entry.detail });
|
|
197
|
-
}
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
for (const index of item.indexes)
|
|
201
|
-
await this.resolveEmailEntry(result[index], openId);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
/** 邮箱已经换成 open_id,再补上通讯录显示名。 */
|
|
206
|
-
async resolveEmailEntry(entry, openId) {
|
|
207
|
-
const email = entry.entry.trim();
|
|
208
|
-
entry.status = 'resolved';
|
|
209
|
-
entry.openId = openId;
|
|
210
|
-
const nameHit = this.readCache('open_id', openId);
|
|
211
|
-
if (nameHit) {
|
|
212
|
-
entry.name = nameHit.name;
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
const contact = await this.fetchContactUser(openId);
|
|
216
|
-
if (contact.status === 'ok' && contact.user) {
|
|
217
|
-
entry.name = contact.user.name;
|
|
218
|
-
if (entry.name)
|
|
219
|
-
this.writeCache('open_id', openId, openId, entry.name);
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
// 身份已经确定,只是名字暂时没取到:不影响进名单,记录下来等下次补。
|
|
223
|
-
entry.name = '';
|
|
224
|
-
entry.detail = `名字暂未取到:${contact.detail}`;
|
|
225
|
-
this.logger.warn('identity:resolve-deferred', { entry: maskEmail(email), actor: openId, detail: entry.detail });
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// 名字没取到时不写缓存,下次解析会重试,把名字补上。
|
|
229
|
-
if (entry.name)
|
|
230
|
-
this.writeCache('email', email, openId, entry.name);
|
|
231
|
-
this.logResolved(entry);
|
|
232
|
-
}
|
|
233
|
-
/** 调 `contact +get-user` 查单个用户。 */
|
|
234
|
-
async fetchContactUser(openId) {
|
|
235
|
-
const argv = ['contact', '+get-user', '--user-id', openId, '--as', 'bot', '--format', 'json'];
|
|
236
|
-
try {
|
|
237
|
-
const result = await this.lark.run(argv, { timeoutMs: CONTACT_TIMEOUT_MS });
|
|
238
|
-
if (result.code !== 0) {
|
|
239
|
-
// lark-cli 会把飞书的错误信封写在 stderr 并以非零退出:其中「跨应用 open_id」「查无此人」
|
|
240
|
-
// 是确定结论,重试也不会成功,必须丢弃;其余(权限、网络、超时)保留待重试。
|
|
241
|
-
const definitive = classifyContactError(result.stderr) ?? classifyContactError(result.stdout);
|
|
242
|
-
if (definitive)
|
|
243
|
-
return { status: 'invalid', user: null, detail: definitive };
|
|
244
|
-
return { status: 'unknown', user: null, detail: `contact +get-user 调用失败(code=${result.code}):${detailText(result.stderr)}` };
|
|
245
|
-
}
|
|
246
|
-
return parseContactPayload(result.stdout);
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
return { status: 'unknown', user: null, detail: `contact +get-user 调用异常:${detailText(errorMessage(error))}` };
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
/** 调 `batch_get_id` 批量把邮箱换成 open_id;一批的邮箱数量不能超过接口上限。 */
|
|
253
|
-
async fetchBatch(emails, expectedCount) {
|
|
254
|
-
const argv = [
|
|
255
|
-
'api', 'POST', '/open-apis/contact/v3/users/batch_get_id',
|
|
256
|
-
'--params', JSON.stringify({ user_id_type: 'open_id' }),
|
|
257
|
-
'--data', JSON.stringify({ emails }),
|
|
258
|
-
'--as', 'bot', '--format', 'json',
|
|
259
|
-
];
|
|
260
|
-
try {
|
|
261
|
-
const result = await this.lark.run(argv, { timeoutMs: CONTACT_TIMEOUT_MS });
|
|
262
|
-
if (result.code !== 0) {
|
|
263
|
-
return { ok: false, map: new Map(), detail: `batch_get_id 调用失败(code=${result.code}):${detailText(result.stderr)}` };
|
|
264
|
-
}
|
|
265
|
-
return parseBatchPayload(result.stdout, expectedCount);
|
|
266
|
-
}
|
|
267
|
-
catch (error) {
|
|
268
|
-
return { ok: false, map: new Map(), detail: `batch_get_id 调用异常:${detailText(errorMessage(error))}` };
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
readCache(kind, value) {
|
|
272
|
-
// 没有 appId 就没有可用的缓存作用域,直接不查也不写,避免跨应用污染。
|
|
273
|
-
if (!this.cache || !this.appId)
|
|
274
|
-
return null;
|
|
275
|
-
try {
|
|
276
|
-
return this.cache.get(this.appId, kind, kind === 'email' ? value.toLowerCase() : value);
|
|
277
|
-
}
|
|
278
|
-
catch (error) {
|
|
279
|
-
this.logger.warn('identity:cache-failed', { kind, detail: `缓存读取失败:${detailText(errorMessage(error))}` });
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
writeCache(kind, value, openId, name) {
|
|
284
|
-
if (!this.cache || !this.appId)
|
|
285
|
-
return;
|
|
286
|
-
try {
|
|
287
|
-
this.cache.set(this.appId, kind, kind === 'email' ? value.toLowerCase() : value, openId, name);
|
|
288
|
-
}
|
|
289
|
-
catch (error) {
|
|
290
|
-
this.logger.warn('identity:cache-failed', { kind, detail: `缓存写入失败:${detailText(errorMessage(error))}` });
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
logResolved(entry) {
|
|
294
|
-
this.logger.info('identity:resolved', { entry: maskEntry(entry.entry), actor: entry.openId, name: entry.name });
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
/** 读取群成员列表的超时。 */
|
|
298
|
-
const MEMBER_TIMEOUT_MS = 20_000;
|
|
299
|
-
/**
|
|
300
|
-
* 自动识别机器人自身身份。
|
|
301
|
-
*
|
|
302
|
-
* 机器人没有邮箱,只能按 `app_id` 匹配群成员列表里的 bot 条目:
|
|
303
|
-
* 用户不再需要手填 `ou_xxx`(那是「应用 × 用户」维度的 id,用户既看不到也校验不了)。
|
|
304
|
-
*
|
|
305
|
-
* 扫描范围只给 `mode: mention` 的群——只有这些群靠 @ 机器人判定消息是否该收,
|
|
306
|
-
* 识别不到就会把整群消息丢掉(2026-11-11 事故的形态)。
|
|
307
|
-
*/
|
|
308
|
-
export async function resolveBotIdentity(opts) {
|
|
309
|
-
const logger = opts.logger ?? noopLogger;
|
|
310
|
-
const botOpenIds = [];
|
|
311
|
-
const botNames = [];
|
|
312
|
-
/** 机器人 open_id → 显示名;同一个机器人可能在多个群里出现。 */
|
|
313
|
-
const names = new Map();
|
|
314
|
-
/** 读不到成员的群:调用失败。 */
|
|
315
|
-
const failures = [];
|
|
316
|
-
/** 读到成员但没有匹配到机器人的群。 */
|
|
317
|
-
const missing = [];
|
|
318
|
-
// 没有 mention 群时不需要识别:不是故障,不写错误日志。
|
|
319
|
-
// resolved 的语义是「机器人身份可用或无需识别」,所以这里必须是 true,否则面板会误报成解析失败。
|
|
320
|
-
if (opts.chatIds.length === 0) {
|
|
321
|
-
return { botOpenIds: [], botNames: [], resolved: true, detail: '没有配置 mode=mention 的群,无需识别机器人身份' };
|
|
322
|
-
}
|
|
323
|
-
if (!opts.appId) {
|
|
324
|
-
const detail = '没有配置 feishu.appId,无法识别机器人身份';
|
|
325
|
-
logger.error('feishu:bot-identity-failed', { detail });
|
|
326
|
-
return { botOpenIds: [], botNames: [], resolved: false, detail };
|
|
327
|
-
}
|
|
328
|
-
for (const chatId of opts.chatIds) {
|
|
329
|
-
const argv = ['im', '+chat-members-list', '--chat-id', chatId, '--as', 'bot', '--format', 'json'];
|
|
330
|
-
let stdout = '';
|
|
331
|
-
try {
|
|
332
|
-
const result = await opts.lark.run(argv, { timeoutMs: MEMBER_TIMEOUT_MS });
|
|
333
|
-
if (result.code !== 0) {
|
|
334
|
-
failures.push(`群 ${chatId} 读取成员失败(code=${result.code}):${detailText(result.stderr)}`);
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
stdout = result.stdout;
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
failures.push(`群 ${chatId} 读取成员异常:${detailText(errorMessage(error))}`);
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
if (!isJson(stdout)) {
|
|
344
|
-
failures.push(`群 ${chatId} 读取成员接口异常:成员列表输出无法解析`);
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
// 退出码 0 不代表成功:lark-cli 可能返回 `{"ok":false,"error":{...}}`。
|
|
348
|
-
// 这种情况是接口异常,不能当成「群里没找到机器人」,否则唯一的诊断日志会被带偏。
|
|
349
|
-
const check = checkMemberPayload(stdout);
|
|
350
|
-
if (!check.ok) {
|
|
351
|
-
failures.push(`群 ${chatId} 读取成员${check.detail}`);
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
const matched = parseLarkMembers(stdout).filter(member => member.type === 'bot' && member.appId === opts.appId);
|
|
355
|
-
if (matched.length === 0) {
|
|
356
|
-
missing.push(chatId);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
for (const member of matched) {
|
|
360
|
-
if (!botOpenIds.includes(member.openId))
|
|
361
|
-
botOpenIds.push(member.openId);
|
|
362
|
-
if (member.name) {
|
|
363
|
-
names.set(member.openId, member.name);
|
|
364
|
-
if (!botNames.includes(member.name))
|
|
365
|
-
botNames.push(member.name);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
if (botOpenIds.length > 0) {
|
|
370
|
-
for (const openId of botOpenIds) {
|
|
371
|
-
logger.info('feishu:bot-identity-resolved', { bot: openId, name: names.get(openId) ?? '' });
|
|
372
|
-
}
|
|
373
|
-
// 部分成功时也要把失败的群带出来:否则面板显示「已识别」,排查的人看不到另一半群其实读不到。
|
|
374
|
-
const failureDetail = failures.length > 0 ? `;以下群读取成员失败:${failures.join(';')}` : '';
|
|
375
|
-
return {
|
|
376
|
-
botOpenIds,
|
|
377
|
-
botNames,
|
|
378
|
-
resolved: true,
|
|
379
|
-
detail: `已在群 ${opts.chatIds.join('、')} 里识别到机器人${failureDetail}`,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
// 接口异常优先于「没找到」:一个是查不了,一个是确实不在群里,排查方向完全不同。
|
|
383
|
-
const failureText = failures.length > 0 ? failures.join(';') : '';
|
|
384
|
-
const missingText = missing.length > 0
|
|
385
|
-
? `群 ${missing.join('、')} 里没找到 appId=${opts.appId} 的机器人`
|
|
386
|
-
: '';
|
|
387
|
-
const joined = [failureText, missingText].filter(Boolean).join(';');
|
|
388
|
-
const detail = joined || `未能在群 ${opts.chatIds.join('、')} 里识别到机器人`;
|
|
389
|
-
logger.error('feishu:bot-identity-failed', { detail });
|
|
390
|
-
return { botOpenIds: [], botNames: [], resolved: false, detail };
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* 启动装配用的运行时身份解析。
|
|
394
|
-
*
|
|
395
|
-
* 只在这里查通讯录:解析完的结果(open_id 名单 + 名字表)进内存,
|
|
396
|
-
* 消息入站链路只读内存,不再发任何外部请求。
|
|
397
|
-
*/
|
|
398
|
-
export async function resolveRuntimeIdentity(opts) {
|
|
399
|
-
const logger = opts.logger ?? noopLogger;
|
|
400
|
-
const feishu = opts.config.feishu;
|
|
401
|
-
// appId 必须传:没有它就查不了也写不了缓存,缓存命中才是「通讯录挂了名单仍然可用」的前提。
|
|
402
|
-
const resolver = new IdentityResolver({ lark: opts.lark, appId: feishu.appId, cache: opts.cache, logger });
|
|
403
|
-
const names = new Map();
|
|
404
|
-
const entries = [];
|
|
405
|
-
const invalid = [];
|
|
406
|
-
const pending = [];
|
|
407
|
-
const collect = (resolution) => {
|
|
408
|
-
entries.push(...resolution.entries);
|
|
409
|
-
invalid.push(...resolution.invalid);
|
|
410
|
-
pending.push(...resolution.pending);
|
|
411
|
-
for (const [openId, name] of resolution.names)
|
|
412
|
-
names.set(openId, name);
|
|
413
|
-
};
|
|
414
|
-
const global = await resolver.resolveEntries(feishu.allowed);
|
|
415
|
-
collect(global);
|
|
416
|
-
// 全局名单配了却一个都解析不出来:运行时等于谁都不许,必须显式报错而不是静默。
|
|
417
|
-
if (feishu.allowed.length > 0 && global.openIds.length === 0) {
|
|
418
|
-
logger.error('feishu:allowlist-empty', {
|
|
419
|
-
count: global.entries.length,
|
|
420
|
-
detail: `全局允许名单一个都解析不出来:${global.pending.length} 条待重试、${global.invalid.length} 条查无此人`,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
const allowedByChatOpenIds = {};
|
|
424
|
-
for (const chat of feishu.chats) {
|
|
425
|
-
if (chat.allowed.length === 0)
|
|
426
|
-
continue;
|
|
427
|
-
const resolution = await resolver.resolveEntries(chat.allowed);
|
|
428
|
-
collect(resolution);
|
|
429
|
-
allowedByChatOpenIds[chat.chat] = resolution.openIds;
|
|
430
|
-
// 配了名单却一个都没解析出来:这个群按「拒绝所有人」处理,绝不退回全局名单。
|
|
431
|
-
if (resolution.openIds.length === 0) {
|
|
432
|
-
logger.error('feishu:chat-allowlist-empty', {
|
|
433
|
-
chat: chat.chat,
|
|
434
|
-
name: opts.chatNames?.(chat.chat) ?? '',
|
|
435
|
-
count: resolution.entries.length,
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
// 需求分析助手的终审管理员:同样支持邮箱,启动时一次解析成 open_id。
|
|
440
|
-
// 终审是「谁来拍板」的硬边界,配了却解析不到人时不能退回全局名单。
|
|
441
|
-
const approverOpenIdsByChat = {};
|
|
442
|
-
for (const chat of feishu.chats) {
|
|
443
|
-
const approvers = chat.intake?.approvers ?? [];
|
|
444
|
-
if (approvers.length === 0)
|
|
445
|
-
continue;
|
|
446
|
-
const resolution = await resolver.resolveEntries(approvers);
|
|
447
|
-
collect(resolution);
|
|
448
|
-
approverOpenIdsByChat[chat.chat] = resolution.openIds;
|
|
449
|
-
if (resolution.openIds.length === 0) {
|
|
450
|
-
logger.error('feishu:intake-approver-empty', {
|
|
451
|
-
chat: chat.chat,
|
|
452
|
-
name: opts.chatNames?.(chat.chat) ?? '',
|
|
453
|
-
count: resolution.entries.length,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
const bot = await resolveBotIdentityOf(opts.config, opts.lark, logger);
|
|
458
|
-
if (invalid.length > 0 || pending.length > 0) {
|
|
459
|
-
const masked = [...invalid, ...pending].map(entry => maskEntry(entry.entry)).slice(0, 10);
|
|
460
|
-
logger.warn('config:identity-invalid', {
|
|
461
|
-
count: invalid.length + pending.length,
|
|
462
|
-
detail: `${invalid.length} 条解析不到人、${pending.length} 条暂时查不了待重试:${masked.join('、')}`,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
return {
|
|
466
|
-
allowedOpenIds: global.openIds,
|
|
467
|
-
allowedByChatOpenIds,
|
|
468
|
-
approverOpenIdsByChat,
|
|
469
|
-
names,
|
|
470
|
-
bot,
|
|
471
|
-
status: { entries, bot, invalidCount: invalid.length, pendingCount: pending.length },
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
/** 机器人身份:用户手动填了就用用户的,否则按 app_id 自动识别。 */
|
|
475
|
-
async function resolveBotIdentityOf(config, lark, logger) {
|
|
476
|
-
const feishu = config.feishu;
|
|
477
|
-
// 手动填了就是手动覆盖:不再自动识别,保留逃生口。
|
|
478
|
-
if (feishu.botOpenIds.length > 0 || feishu.botNames.length > 0) {
|
|
479
|
-
feishu.botOpenIds.forEach((openId, index) => {
|
|
480
|
-
logger.info('feishu:bot-identity-resolved', { bot: openId, name: feishu.botNames[index] ?? '' });
|
|
481
|
-
});
|
|
482
|
-
return {
|
|
483
|
-
botOpenIds: [...feishu.botOpenIds],
|
|
484
|
-
botNames: [...feishu.botNames],
|
|
485
|
-
resolved: true,
|
|
486
|
-
detail: '使用配置里手动填写的机器人身份',
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
const chatIds = feishu.chats.filter(chat => chat.mode === 'mention').map(chat => chat.chat);
|
|
490
|
-
return resolveBotIdentity({ appId: feishu.appId, chatIds, lark, logger });
|
|
491
|
-
}
|
|
492
|
-
/**
|
|
493
|
-
* 解析 `batch_get_id` 的返回。
|
|
494
|
-
*
|
|
495
|
-
* 只有响应结构完好(条数与请求一致、每条都能认出 email)时,缺 `user_id` 的邮箱才算是
|
|
496
|
-
* 「确认查无此人」;结构对不上(字段改名、条数对不上)一律按「暂时查不了」处理,
|
|
497
|
-
* 避免接口形状一变就把允许名单静默清空。
|
|
498
|
-
*/
|
|
499
|
-
function parseBatchPayload(stdout, expectedCount) {
|
|
500
|
-
let doc = null;
|
|
501
|
-
try {
|
|
502
|
-
doc = JSON.parse(stdout);
|
|
503
|
-
}
|
|
504
|
-
catch {
|
|
505
|
-
return { ok: false, map: new Map(), detail: 'batch_get_id 输出无法解析' };
|
|
506
|
-
}
|
|
507
|
-
if (doc?.ok === false) {
|
|
508
|
-
return { ok: false, map: new Map(), detail: `batch_get_id 调用失败:${describeError(doc)}` };
|
|
509
|
-
}
|
|
510
|
-
const list = doc?.data?.user_list;
|
|
511
|
-
if (!Array.isArray(list)) {
|
|
512
|
-
return { ok: false, map: new Map(), detail: 'batch_get_id 返回里没有 user_list' };
|
|
513
|
-
}
|
|
514
|
-
const items = list;
|
|
515
|
-
const map = new Map();
|
|
516
|
-
for (const item of items) {
|
|
517
|
-
const email = typeof item?.email === 'string' ? item.email.trim() : '';
|
|
518
|
-
// 条目认不出邮箱:响应结构与预期不符,不能据此判断谁查无此人。
|
|
519
|
-
if (!email.includes('@')) {
|
|
520
|
-
return { ok: false, map: new Map(), detail: 'batch_get_id 返回的条目里没有可识别的 email' };
|
|
521
|
-
}
|
|
522
|
-
const userId = typeof item?.user_id === 'string' ? item.user_id.trim() : '';
|
|
523
|
-
// 缺 user_id 表示这个邮箱查无此人,不进映射(调用方据此判定 invalid)。
|
|
524
|
-
if (userId)
|
|
525
|
-
map.set(email.toLowerCase(), userId);
|
|
526
|
-
}
|
|
527
|
-
if (expectedCount !== undefined && items.length !== expectedCount) {
|
|
528
|
-
return { ok: false, map: new Map(), detail: `batch_get_id 返回的条数与请求不一致(请求 ${expectedCount} 条,返回 ${items.length} 条)` };
|
|
529
|
-
}
|
|
530
|
-
return { ok: true, map, detail: '' };
|
|
531
|
-
}
|
|
532
|
-
/** 解析 `contact +get-user` 的返回,并按错误码区分「查无此人」与「暂时查不了」。 */
|
|
533
|
-
function parseContactPayload(stdout) {
|
|
534
|
-
let doc = null;
|
|
535
|
-
try {
|
|
536
|
-
doc = JSON.parse(stdout);
|
|
537
|
-
}
|
|
538
|
-
catch {
|
|
539
|
-
return { status: 'unknown', user: null, detail: 'contact +get-user 输出无法解析' };
|
|
540
|
-
}
|
|
541
|
-
if (doc?.ok === false) {
|
|
542
|
-
const code = String(doc?.error?.code ?? '');
|
|
543
|
-
if (code === CODE_CROSS_APP) {
|
|
544
|
-
return { status: 'invalid', user: null, detail: `open_id 跨应用,当前应用查不到这个 id(code=${code})` };
|
|
545
|
-
}
|
|
546
|
-
if (code === CODE_NOT_FOUND) {
|
|
547
|
-
return { status: 'invalid', user: null, detail: `查无此人:open_id 无效或不存在(code=${code})` };
|
|
548
|
-
}
|
|
549
|
-
return { status: 'unknown', user: null, detail: `通讯录查询失败:${describeError(doc)}` };
|
|
550
|
-
}
|
|
551
|
-
const user = doc?.data?.user;
|
|
552
|
-
if (!user || typeof user !== 'object') {
|
|
553
|
-
return { status: 'unknown', user: null, detail: 'contact +get-user 返回里没有 user' };
|
|
554
|
-
}
|
|
555
|
-
return {
|
|
556
|
-
status: 'ok',
|
|
557
|
-
user: {
|
|
558
|
-
openId: String(user.open_id ?? user.openId ?? ''),
|
|
559
|
-
userId: String(user.user_id ?? user.userId ?? ''),
|
|
560
|
-
name: String(user.name ?? ''),
|
|
561
|
-
email: String(user.email ?? ''),
|
|
562
|
-
},
|
|
563
|
-
detail: '',
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
/**
|
|
567
|
-
* 从非零退出码的错误输出里识别「确定查不到这个人」的结论。
|
|
568
|
-
*
|
|
569
|
-
* lark-cli 把飞书错误信封写在 stderr(也可能写 stdout)并以非零码退出。其中
|
|
570
|
-
* 「跨应用 open_id」「查无此人」重试也不会成功,必须丢弃条目;其余失败(权限、
|
|
571
|
-
* 网络、超时)返回 null,由调用方按「暂时查不了」保留,避免一次接口故障清空名单。
|
|
572
|
-
*/
|
|
573
|
-
function classifyContactError(text) {
|
|
574
|
-
if (!text.trim())
|
|
575
|
-
return null;
|
|
576
|
-
try {
|
|
577
|
-
const doc = JSON.parse(text);
|
|
578
|
-
const code = String(doc?.error?.code ?? doc?.code ?? '');
|
|
579
|
-
if (code === CODE_CROSS_APP)
|
|
580
|
-
return `open_id 跨应用,当前应用查不到这个 id(code=${code}),请改用邮箱或确认 feishu.appId`;
|
|
581
|
-
if (code === CODE_NOT_FOUND)
|
|
582
|
-
return `查无此人:open_id 无效或不存在(code=${code})`;
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
catch {
|
|
586
|
-
// 输出不是 JSON(例如普通 stderr 文本):按「暂时查不了」处理,交调用方保留条目。
|
|
587
|
-
return null;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
/** 从错误响应里取一句可读原因。 */
|
|
591
|
-
function describeError(doc) {
|
|
592
|
-
const text = doc?.error?.msg ?? doc?.error?.message ?? doc?.msg ?? doc?.message ?? '未知错误';
|
|
593
|
-
return detailText(String(text));
|
|
594
|
-
}
|
|
595
|
-
/** 错误详情截断,并顺手把里面出现的邮箱掩码掉。 */
|
|
596
|
-
export function detailText(text) {
|
|
597
|
-
const masked = text.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, match => maskEmail(match));
|
|
598
|
-
return masked.length > DETAIL_MAX ? `${masked.slice(0, DETAIL_MAX)}…` : masked;
|
|
599
|
-
}
|
|
600
|
-
/** 入日志 / 进 detail 的条目:邮箱要掩码,open_id 与非法条目原样保留。 */
|
|
601
|
-
function maskEntry(entry) {
|
|
602
|
-
return entry.includes('@') ? maskEmail(entry.trim()) : entry;
|
|
603
|
-
}
|
|
604
|
-
/**
|
|
605
|
-
* 校验群成员列表返回:先识别接口错误信封,再确认结构完好。
|
|
606
|
-
*
|
|
607
|
-
* lark-cli 存在「退出码 0 + `{"ok":false,"error":{...}}`」的返回形态。接口已经失败时
|
|
608
|
-
* 根本没有成员列表可查,必须归为接口异常;只有结构完好、确实没有匹配项才算「没找到机器人」。
|
|
609
|
-
*/
|
|
610
|
-
export function checkMemberPayload(stdout) {
|
|
611
|
-
const parsed = JSON.parse(stdout);
|
|
612
|
-
const root = parsed && typeof parsed === 'object' ? parsed : null;
|
|
613
|
-
// 顶层 ok === false,或存在 error 字段(即使 ok 没写 false),都是接口错误信封。
|
|
614
|
-
if (root && (root.ok === false || 'error' in root)) {
|
|
615
|
-
const code = String(root.error?.code ?? '');
|
|
616
|
-
return {
|
|
617
|
-
ok: false,
|
|
618
|
-
detail: `接口异常:接口返回错误${code ? `(code=${code})` : ''}:${describeError(root)}`,
|
|
619
|
-
};
|
|
620
|
-
}
|
|
621
|
-
const data = root?.data;
|
|
622
|
-
if (!data || typeof data !== 'object' || (!Array.isArray(data.users) && !Array.isArray(data.bots))) {
|
|
623
|
-
return { ok: false, detail: '接口异常:成员列表结构异常' };
|
|
624
|
-
}
|
|
625
|
-
return { ok: true };
|
|
626
|
-
}
|
|
627
|
-
/** 输出是不是 JSON:不是的话不能当「没找到机器人」,那会把接口异常说成业务结论。 */
|
|
628
|
-
function isJson(text) {
|
|
629
|
-
try {
|
|
630
|
-
JSON.parse(text);
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
// 输出不是 JSON 时按「接口异常」处理(由调用方写进 failures),不当作业务结论,
|
|
635
|
-
// 所以这里可以安全地忽略解析异常,不需要额外日志。
|
|
636
|
-
return false;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
/** 按固定大小切片。 */
|
|
640
|
-
function chunk(list, size) {
|
|
641
|
-
const out = [];
|
|
642
|
-
for (let index = 0; index < list.length; index += size)
|
|
643
|
-
out.push(list.slice(index, index + size));
|
|
644
|
-
return out;
|
|
645
|
-
}
|
|
646
|
-
//# sourceMappingURL=feishu-identity.js.map
|