@sidleo3/dsh-chat-feishu 0.0.4
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/client/index.js +1190 -0
- package/cordis.patch.yml +5 -0
- package/host/bridge.mjs +1605 -0
- package/host/config-store.mjs +240 -0
- package/host/controller.mjs +1514 -0
- package/host/index.mjs +227 -0
- package/host/lark-cli.mjs +543 -0
- package/host/lark-gateway.mjs +1362 -0
- package/host/lark-guard.mjs +200 -0
- package/host/panel-card.mjs +681 -0
- package/host/provision.mjs +247 -0
- package/host/state-store.mjs +160 -0
- package/host/turn-presenter.mjs +778 -0
- package/lib/client.js +1132 -0
- package/lib/index.js +132126 -0
- package/package.json +54 -0
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书 SDK 网关:把 `@larksuiteoapi/node-sdk` 收窄成本插件需要的那几个动作。
|
|
3
|
+
*
|
|
4
|
+
* 两条硬性约束:
|
|
5
|
+
* 1. **不使用 SDK 自带的握手超时**(`handshakeTimeoutMs: 0`)。该超时路径会先摘掉
|
|
6
|
+
* socket 的全部 error 监听再 terminate,一旦随后还有 error 事件就会变成未捕获异常。
|
|
7
|
+
* 我们改为自己用 Promise.race 计时,并把整条连接的生命周期(含重连)握在自己手里:
|
|
8
|
+
* 失败就丢弃旧 WSClient、另起一个新的。
|
|
9
|
+
* 2. 所有 `im.v1.*` 调用统一做 `code !== 0` 判定,否则飞书会用 HTTP 200 返回业务失败。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-chat-feishu/lark-gateway
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createReadStream } from 'node:fs';
|
|
15
|
+
import { stat } from 'node:fs/promises';
|
|
16
|
+
|
|
17
|
+
import { askRow } from './turn-presenter.mjs';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
20
|
+
|
|
21
|
+
/** 按扩展名判断是不是图片(图片走图片气泡/正文内嵌,其余走附件区)。 */
|
|
22
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
23
|
+
|
|
24
|
+
function isImagePath(path) {
|
|
25
|
+
const name = String(path).toLowerCase();
|
|
26
|
+
const dot = name.lastIndexOf('.');
|
|
27
|
+
return dot >= 0 && IMAGE_EXTENSIONS.has(name.slice(dot));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 按扩展名给出飞书要的 `file_type`(它决定文件在客户端的图标与打开方式;
|
|
32
|
+
* 不在表里的一律 `stream`,飞书会当普通附件处理)。
|
|
33
|
+
*/
|
|
34
|
+
const FILE_TYPES = new Map(Object.entries({
|
|
35
|
+
opus: 'opus',
|
|
36
|
+
mp4: 'mp4',
|
|
37
|
+
pdf: 'pdf',
|
|
38
|
+
doc: 'doc',
|
|
39
|
+
docx: 'doc',
|
|
40
|
+
xls: 'xls',
|
|
41
|
+
xlsx: 'xls',
|
|
42
|
+
ppt: 'ppt',
|
|
43
|
+
pptx: 'ppt',
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 归一化下拉/多选控件的取值。
|
|
48
|
+
*
|
|
49
|
+
* 飞书这套字段在不同 SDK 版本与不同控件上形态都不一样,真机上全见过:
|
|
50
|
+
* 单选下拉给字符串(`action.option`)、多选给数组或**逗号串**(`action.options`)、
|
|
51
|
+
* 有的版本把它塞进 `form_value[组件名]`,还有的给 `{ value }` 对象。
|
|
52
|
+
* 少认一种,用户点了下拉就"没反应"——而这类失败在真机上是静默的,所以这里全部认。
|
|
53
|
+
*
|
|
54
|
+
* @param value - 任意形态。
|
|
55
|
+
* @param options - { splitCommas }:多选的逗号串要拆;**单选(`action.option`)不能拆**
|
|
56
|
+
* ——工作区路径里就可能有逗号,拆了会静默换成另一个目录。默认拆(多选语义)。
|
|
57
|
+
* @returns 字符串数组(已去空、去重)。
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* 单选类组件:取值是**原子值**,不能按逗号拆(工作区路径里就可能带逗号)。
|
|
61
|
+
* 表单提交(那时 `action.tag` 是 `button`)不在此列——那种形态下的多选是逗号串,要拆。
|
|
62
|
+
*/
|
|
63
|
+
const SINGLE_SELECT_TAGS = new Set(['select_static', 'select', 'single_select']);
|
|
64
|
+
|
|
65
|
+
export function normalizeOptionValues(value, { splitCommas = true } = {}) {
|
|
66
|
+
const flat = [];
|
|
67
|
+
const push = (item) => {
|
|
68
|
+
if (typeof item === 'string') {
|
|
69
|
+
// 逗号串(Card 2.0 的多选就是这个形状)。
|
|
70
|
+
if (splitCommas) {
|
|
71
|
+
for (const part of item.split(',')) {
|
|
72
|
+
const text = part.trim();
|
|
73
|
+
if (text) flat.push(text);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
const text = item.trim();
|
|
77
|
+
if (text) flat.push(text);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(item)) {
|
|
82
|
+
for (const entry of item) push(entry);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (item !== null && typeof item === 'object') {
|
|
86
|
+
// `{ value }` / `{ text, value }` 这类包装。
|
|
87
|
+
if (item.value !== undefined) push(item.value);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
push(value);
|
|
91
|
+
return [...new Set(flat)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 把飞书卡片回调归一化成一种形状。
|
|
96
|
+
*
|
|
97
|
+
* 为什么必须做:我们注册在**裸 EventDispatcher** 上,拿到的是原始回调体
|
|
98
|
+
* (`operator.open_id` / `context.open_chat_id` / `action.value`),
|
|
99
|
+
* 而不是 SDK 高层封装里那份 camelCase 版本(`operator.openId` / `chatId`)。
|
|
100
|
+
* 真机上就是因为按后者读字段,回调进来后被"缺 operator/chatId"静默丢掉。
|
|
101
|
+
*
|
|
102
|
+
* 两种形状都认:缺字段就返回 null,由调用方记一条可检索的日志。
|
|
103
|
+
*
|
|
104
|
+
* @param raw - 原始回调体。
|
|
105
|
+
* @returns 归一化事件,或 null。
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeCardAction(raw) {
|
|
108
|
+
if (raw === null || typeof raw !== 'object') return null;
|
|
109
|
+
const context = raw.context ?? {};
|
|
110
|
+
const operator = raw.operator ?? {};
|
|
111
|
+
const action = raw.action ?? {};
|
|
112
|
+
const messageId = context.open_message_id ?? raw.open_message_id ?? raw.messageId;
|
|
113
|
+
const chatId = context.open_chat_id ?? raw.open_chat_id ?? raw.chatId;
|
|
114
|
+
const openId = operator.open_id ?? operator.openId;
|
|
115
|
+
// 延迟更新 token 在事件顶层(SDK 的 RawCardActionEvent 也是这个位置)。
|
|
116
|
+
const token = raw.token ?? context.token ?? raw.event?.token;
|
|
117
|
+
if (typeof chatId !== 'string' || !chatId || typeof openId !== 'string' || !openId) return null;
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
messageId: typeof messageId === 'string' ? messageId : undefined,
|
|
120
|
+
chatId,
|
|
121
|
+
/**
|
|
122
|
+
* 延迟更新 token(`card.action.trigger` 自带,30 分钟内最多用 2 次)。
|
|
123
|
+
*
|
|
124
|
+
* **它是更新这张卡片的唯一正路**:交互之后必须用它调 `updateCard`,
|
|
125
|
+
* 否则(用 message.patch)客户端会把卡片还原成原样。
|
|
126
|
+
*/
|
|
127
|
+
token: typeof token === 'string' && token ? token : undefined,
|
|
128
|
+
operator: Object.freeze({ openId }),
|
|
129
|
+
action: Object.freeze({
|
|
130
|
+
tag: action.tag ?? 'unknown',
|
|
131
|
+
value: action.value ?? {},
|
|
132
|
+
// 表单(form)内组件的值在这里:action.form_value[组件name]。
|
|
133
|
+
formValue: action.form_value ?? action.formValue ?? {},
|
|
134
|
+
/**
|
|
135
|
+
* 下拉(`select_static`)选中的值:单选在 `action.option`,多选在 `action.options`。
|
|
136
|
+
* 卡片上的下拉靠 `behaviors.callback` 直接回调,选中值就落在这两个字段里——
|
|
137
|
+
* 漏了它们,用户点下拉就是"没反应"(而这在真机上是静默的)。
|
|
138
|
+
*/
|
|
139
|
+
options: Object.freeze([
|
|
140
|
+
// 单选是原子值(路径里可能有逗号):不拆。
|
|
141
|
+
...normalizeOptionValues(action.option, { splitCommas: false }),
|
|
142
|
+
// 多选是逗号串(官方 Card 2.0 就是这个形状),要拆。
|
|
143
|
+
...normalizeOptionValues(action.options),
|
|
144
|
+
// 某些版本把选中值只塞进 form_value[组件名]:**按组件类型决定要不要拆**——
|
|
145
|
+
// 单选的取值是原子的(工作区路径里可能有逗号),一律按逗号拆会把路径切成两段。
|
|
146
|
+
...normalizeOptionValues(
|
|
147
|
+
(action.form_value ?? action.formValue ?? {})[action.name],
|
|
148
|
+
{ splitCommas: !SINGLE_SELECT_TAGS.has(action.tag) },
|
|
149
|
+
),
|
|
150
|
+
].filter((item, index, list) => item !== '' && list.indexOf(item) === index)),
|
|
151
|
+
...(action.name === undefined ? {} : { name: action.name }),
|
|
152
|
+
}),
|
|
153
|
+
raw,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fileTypeFor(name) {
|
|
158
|
+
const ext = String(name ?? '').split('.').pop()?.toLowerCase() ?? '';
|
|
159
|
+
return FILE_TYPES.get(ext) ?? 'stream';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 入站资源(图片/文件)大小上限:超过就报错,不把内存撑爆。 */
|
|
163
|
+
const DEFAULT_MAX_RESOURCE_BYTES = 10 * 1024 * 1024;
|
|
164
|
+
|
|
165
|
+
/** SDK 的 LoggerLevel 映射;缺省静默,避免刷屏。 */
|
|
166
|
+
function loggerLevelFor(sdk, level) {
|
|
167
|
+
const table = sdk?.LoggerLevel ?? {};
|
|
168
|
+
return table[level] ?? table.info;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function apiError(operation, response) {
|
|
172
|
+
const error = new Error(`${operation} 失败:${response?.msg || response?.code}`);
|
|
173
|
+
error.code = 'feishu/api-failed';
|
|
174
|
+
error.providerCode = response?.code;
|
|
175
|
+
return error;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function assertSuccess(operation, response) {
|
|
179
|
+
if (response?.code && response.code !== 0) throw apiError(operation, response);
|
|
180
|
+
return response;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 一次性探针:**只验证一组 App 凭据、顺手读一下机器人名字**。
|
|
185
|
+
*
|
|
186
|
+
* 设置页的「新建机器人接入」要在落盘/建长连接**之前**知道这组凭据对不对——否则用户拿到的是
|
|
187
|
+
* "加进去了但一直 failed",原因还埋在长连接的报错里。与 `createLarkGateway` 的区别:
|
|
188
|
+
* 这里不连长连接、不发消息,用完即弃,所以不塞进网关(网关的每条路径都假设自己连着)。
|
|
189
|
+
*
|
|
190
|
+
* @param options - { appId, appSecret, domain, sdk, logger, loggerLevel }。
|
|
191
|
+
* @returns `{ verify() }`;`verify()` → `{ botName, botOpenId }`(名字读不到就是 null)。
|
|
192
|
+
*/
|
|
193
|
+
export function createLarkProbe({
|
|
194
|
+
appId,
|
|
195
|
+
appSecret,
|
|
196
|
+
domain = 'feishu',
|
|
197
|
+
sdk,
|
|
198
|
+
logger = console,
|
|
199
|
+
loggerLevel = process.env.DSH_CHAT_FEISHU_SDK_LOG || 'info',
|
|
200
|
+
} = {}) {
|
|
201
|
+
if (!sdk?.Client) throw new TypeError('飞书探针需要 @larksuiteoapi/node-sdk。');
|
|
202
|
+
if (!appId || !appSecret) throw new TypeError('飞书探针需要 appId 与 appSecret。');
|
|
203
|
+
|
|
204
|
+
const client = new sdk.Client({
|
|
205
|
+
appId,
|
|
206
|
+
appSecret,
|
|
207
|
+
...(domain === 'lark' ? { domain: sdk.Domain?.Lark } : {}),
|
|
208
|
+
logger,
|
|
209
|
+
loggerLevel: loggerLevelFor(sdk, loggerLevel),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return Object.freeze({
|
|
213
|
+
async verify() {
|
|
214
|
+
let auth;
|
|
215
|
+
try {
|
|
216
|
+
auth = await client.request({
|
|
217
|
+
method: 'POST',
|
|
218
|
+
url: `${client.domain}/open-apis/auth/v3/tenant_access_token/internal`,
|
|
219
|
+
data: { app_id: appId, app_secret: appSecret },
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// SDK 抛的 axios 错误里 code/msg 埋在 response.data:压成一句能给人看的话。
|
|
223
|
+
const invalid = new Error(`App ID 或 App Secret 校验失败:${readableApiError(error)}`);
|
|
224
|
+
invalid.code = 'feishu/credential-invalid';
|
|
225
|
+
throw invalid;
|
|
226
|
+
}
|
|
227
|
+
const body = auth?.data ?? auth;
|
|
228
|
+
if (body?.code) {
|
|
229
|
+
const invalid = new Error(`App ID 或 App Secret 不对:${body.msg ?? `code ${body.code}`}`);
|
|
230
|
+
invalid.code = 'feishu/credential-invalid';
|
|
231
|
+
invalid.providerCode = body.code;
|
|
232
|
+
throw invalid;
|
|
233
|
+
}
|
|
234
|
+
if (!body?.tenant_access_token) {
|
|
235
|
+
const missing = new Error('飞书没有返回 tenant_access_token,无法确认这组凭据可用。');
|
|
236
|
+
missing.code = 'feishu/credential-invalid';
|
|
237
|
+
throw missing;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* 名字是**尽力而为**:应用没开机器人能力等情况下读不到,但不该因此拒绝接入——
|
|
241
|
+
* 列表里先显示 id,用户在后台补齐能力后点「重新连接」即可。
|
|
242
|
+
*/
|
|
243
|
+
try {
|
|
244
|
+
const info = await client.request({
|
|
245
|
+
method: 'GET',
|
|
246
|
+
url: `${client.domain}/open-apis/bot/v3/info`,
|
|
247
|
+
});
|
|
248
|
+
const bot = info?.bot ?? info?.data?.bot ?? null;
|
|
249
|
+
return {
|
|
250
|
+
botName: typeof bot?.app_name === 'string' && bot.app_name ? bot.app_name : null,
|
|
251
|
+
botOpenId: typeof bot?.open_id === 'string' && bot.open_id ? bot.open_id : null,
|
|
252
|
+
};
|
|
253
|
+
} catch (error) {
|
|
254
|
+
logger.warn?.('[dsh-chat-feishu] 读机器人信息失败(不影响接入,列表里先显示 id):'
|
|
255
|
+
+ `${readableApiError(error)}`);
|
|
256
|
+
return { botName: null, botOpenId: null };
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 把 SDK 抛出的错误压成一句可读的话。
|
|
264
|
+
*
|
|
265
|
+
* SDK 在业务失败时抛的是 axios 错误,真正的 code/msg 埋在 `response.data` 里(或塞在
|
|
266
|
+
* message 的一长串 JSON 里)。名字解析失败只该降级、不该刷屏,所以这里只取关键信息。
|
|
267
|
+
*/
|
|
268
|
+
function readableApiError(error) {
|
|
269
|
+
const detail = error?.response?.data;
|
|
270
|
+
if (detail?.msg) return `${detail.msg}${detail.code ? `(code ${detail.code})` : ''}`;
|
|
271
|
+
const raw = typeof error?.message === 'string' ? error.message : String(error);
|
|
272
|
+
const embedded = /\{[\s\S]*\}/.exec(raw);
|
|
273
|
+
if (embedded) {
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(embedded[0]);
|
|
276
|
+
if (parsed?.msg) return `${parsed.msg}${parsed.code ? `(code ${parsed.code})` : ''}`;
|
|
277
|
+
} catch {
|
|
278
|
+
// 不是 JSON 就退回原文
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return raw.slice(0, 300);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 创建 SDK 网关。
|
|
286
|
+
*
|
|
287
|
+
* @param options - {
|
|
288
|
+
* appId, appSecret, domain, sdk, logger,
|
|
289
|
+
* connectTimeoutMs?, loggerLevel?,
|
|
290
|
+
* }。
|
|
291
|
+
* `sdk` 可注入(测试用假 SDK)。
|
|
292
|
+
* @returns 网关 API。
|
|
293
|
+
*/
|
|
294
|
+
export function createLarkGateway({
|
|
295
|
+
appId,
|
|
296
|
+
appSecret,
|
|
297
|
+
domain = 'feishu',
|
|
298
|
+
sdk,
|
|
299
|
+
logger = console,
|
|
300
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
301
|
+
loggerLevel = process.env.DSH_CHAT_FEISHU_SDK_LOG || 'info',
|
|
302
|
+
maxResourceBytes = DEFAULT_MAX_RESOURCE_BYTES,
|
|
303
|
+
} = {}) {
|
|
304
|
+
if (!sdk?.Client || !sdk?.WSClient) throw new TypeError('飞书网关需要 @larksuiteoapi/node-sdk。');
|
|
305
|
+
if (!appId || !appSecret) throw new TypeError('飞书网关需要 appId 与 appSecret。');
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 我们发出去的卡片是什么 schema(messageId → '2.0' | '1.0')。
|
|
309
|
+
*
|
|
310
|
+
* 为什么记:卡片回写时要与原来那张**同一 schema**(2.0 的卡用 1.0 内容回写会被飞书拒:
|
|
311
|
+
* `230099 schemaV2 card can not change schemaV1`)。只保留最近若干条,避免长期占用。
|
|
312
|
+
*/
|
|
313
|
+
const cardSchemas = new Map();
|
|
314
|
+
const CARD_SCHEMA_MEMORY = 200;
|
|
315
|
+
function rememberCardSchema(messageId, card) {
|
|
316
|
+
if (typeof messageId !== 'string' || !messageId || card === null || typeof card !== 'object') return;
|
|
317
|
+
cardSchemas.set(messageId, card.schema === '2.0' ? '2.0' : '1.0');
|
|
318
|
+
if (cardSchemas.size > CARD_SCHEMA_MEMORY) {
|
|
319
|
+
const oldest = cardSchemas.keys().next().value;
|
|
320
|
+
cardSchemas.delete(oldest);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const clientOptions = {
|
|
325
|
+
appId,
|
|
326
|
+
appSecret,
|
|
327
|
+
...(domain === 'lark' ? { domain: sdk.Domain?.Lark } : {}),
|
|
328
|
+
// 让 SDK 自己的日志也进我们的渠道日志文件:排查"事件到底有没有到"
|
|
329
|
+
// (如卡片回调)时,SDK 的帧日志与 `no xxx handle` 警告是唯一线索。
|
|
330
|
+
logger,
|
|
331
|
+
loggerLevel: loggerLevelFor(sdk, loggerLevel),
|
|
332
|
+
};
|
|
333
|
+
const client = new sdk.Client(clientOptions);
|
|
334
|
+
const wsOptions = {
|
|
335
|
+
...clientOptions,
|
|
336
|
+
// 关键:关掉 SDK 自己的握手超时,改由我们计时与重连。
|
|
337
|
+
handshakeTimeoutMs: 0,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
let wsClient = null;
|
|
341
|
+
let connected = false;
|
|
342
|
+
let lastError = null;
|
|
343
|
+
|
|
344
|
+
function closeQuietly(instance) {
|
|
345
|
+
try {
|
|
346
|
+
instance?.close?.({ force: true });
|
|
347
|
+
} catch (error) {
|
|
348
|
+
logger.warn?.(`[dsh-chat-feishu] 关闭长连接时报错:${error?.message ?? error}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 单选/多选页共用的"自定义文字答案"表单:输入框宽度拉满 + 提交。
|
|
354
|
+
*
|
|
355
|
+
* 为什么要它(真机反馈):卡片上只有按钮时,用户想写"以上都不是,我想要…"这种
|
|
356
|
+
* 自定义答案就没有地方写,只能切回聊天框。多给一个输入框,选项与自由文本就都能用。
|
|
357
|
+
*
|
|
358
|
+
* @param options - { questionId, placeholder }。
|
|
359
|
+
* @returns 表单元素数组。
|
|
360
|
+
*/
|
|
361
|
+
function customInputElements({
|
|
362
|
+
questionId,
|
|
363
|
+
placeholder = '也可以直接输入你的答案,点「提交」',
|
|
364
|
+
formName = 'dsh_custom',
|
|
365
|
+
submitType = 'default',
|
|
366
|
+
}) {
|
|
367
|
+
return [{
|
|
368
|
+
tag: 'form',
|
|
369
|
+
name: `${formName}_${questionId}`,
|
|
370
|
+
elements: [
|
|
371
|
+
{
|
|
372
|
+
tag: 'input',
|
|
373
|
+
name: `text_${questionId}`,
|
|
374
|
+
placeholder: { tag: 'plain_text', content: placeholder },
|
|
375
|
+
input_type: 'multiline_text',
|
|
376
|
+
rows: 1,
|
|
377
|
+
auto_resize: true,
|
|
378
|
+
max_rows: 6,
|
|
379
|
+
width: 'fill',
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
tag: 'button',
|
|
383
|
+
name: 'submit',
|
|
384
|
+
form_action_type: 'submit',
|
|
385
|
+
type: submitType,
|
|
386
|
+
width: 'fill',
|
|
387
|
+
text: { tag: 'plain_text', content: '提交' },
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
}];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 渲染提问:已答的给出"面板行",未答的给出"交互元素"(纯函数,不发请求)。
|
|
395
|
+
*
|
|
396
|
+
* 两种用法:
|
|
397
|
+
* ① 内嵌进"正在处理"的进度卡——已答的行并入那张卡的工具面板(真机反馈:提问回答
|
|
398
|
+
* 也要跟工具、思考放在一起),未答的控件留在面板外(Card 2.0 的面板里放不了 form);
|
|
399
|
+
* ② 独立提问卡片——已答的行自己组成一个 `❓ N/M 已回答` 折叠面板,控件在下面。
|
|
400
|
+
*
|
|
401
|
+
* 组件依据(`lark-im` skill 的卡片组件文档,均为 Card 2.0):单选=按钮+输入框;
|
|
402
|
+
* 多选=form 内每个选项一个 checker 平铺 + 提交;自由文本=form 内 input + 提交。
|
|
403
|
+
* 表单值回调在 `action.form_value[组件name]`。
|
|
404
|
+
*
|
|
405
|
+
* @param options - { questions, answered, final }。
|
|
406
|
+
* @returns { rows, elements, current }:已答的行、当前题的交互元素、当前题(答完为 null)。
|
|
407
|
+
*/
|
|
408
|
+
function renderQuestionElements({ questions = [], answered = {}, final = false } = {}) {
|
|
409
|
+
const elements = [];
|
|
410
|
+
const answeredList = questions.filter((question) => answered[question?.id] !== undefined);
|
|
411
|
+
const current = final
|
|
412
|
+
? null
|
|
413
|
+
: (questions.find((question) => answered[question?.id] === undefined) ?? null);
|
|
414
|
+
|
|
415
|
+
const answerText = (question) => {
|
|
416
|
+
const answer = answered[question?.id] ?? {};
|
|
417
|
+
const chosen = [...(answer.selected ?? [])];
|
|
418
|
+
if (answer.custom) chosen.push(answer.custom);
|
|
419
|
+
return chosen.join('、') || '(空)';
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// 已答的题:渲染成与工具/思考同样的一行(`提问 · 口径 → 答案`),由调用方决定
|
|
423
|
+
// 放进工具面板还是自己组一个面板。
|
|
424
|
+
const rows = answeredList.map((question) => ({
|
|
425
|
+
id: String(question?.id ?? questions.indexOf(question)),
|
|
426
|
+
text: askRow({
|
|
427
|
+
header: question?.header || question?.question || '问题',
|
|
428
|
+
answer: answerText(question),
|
|
429
|
+
}),
|
|
430
|
+
}));
|
|
431
|
+
|
|
432
|
+
if (!current) return { rows, elements, current: null };
|
|
433
|
+
|
|
434
|
+
const index = questions.indexOf(current) + 1;
|
|
435
|
+
const body = [`**${index}. ${current?.header || '需要确认'}**`, '', String(current?.question ?? '')];
|
|
436
|
+
if (current?.detail) body.push('', String(current.detail));
|
|
437
|
+
const options = Array.isArray(current?.options) ? current.options : [];
|
|
438
|
+
const questionId = String(current?.id ?? '');
|
|
439
|
+
elements.push({ tag: 'hr' });
|
|
440
|
+
|
|
441
|
+
if (options.length > 0 && current?.multiSelect !== true) {
|
|
442
|
+
elements.push({ tag: 'markdown', content: body.join('\n') });
|
|
443
|
+
options.slice(0, 8).forEach((option, optionIndex) => {
|
|
444
|
+
const label = String(option.label).slice(0, 60);
|
|
445
|
+
elements.push({
|
|
446
|
+
tag: 'button',
|
|
447
|
+
text: { tag: 'plain_text', content: option.description ? `${label} —— ${option.description}`.slice(0, 100) : label },
|
|
448
|
+
type: optionIndex === 0 ? 'primary_filled' : 'default',
|
|
449
|
+
width: 'fill',
|
|
450
|
+
behaviors: [{
|
|
451
|
+
type: 'callback',
|
|
452
|
+
value: { dsh: 'answer', questionId, label, index: String(optionIndex + 1) },
|
|
453
|
+
}],
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
elements.push(...customInputElements({ questionId, formName: 'dsh_custom' }));
|
|
457
|
+
} else if (options.length > 0) {
|
|
458
|
+
body.push('', '可多选:勾选后点「提交」。');
|
|
459
|
+
elements.push({ tag: 'markdown', content: body.join('\n') });
|
|
460
|
+
elements.push({
|
|
461
|
+
tag: 'form',
|
|
462
|
+
name: `dsh_form_${questionId}`,
|
|
463
|
+
elements: [
|
|
464
|
+
...options.slice(0, 20).map((option, optionIndex) => ({
|
|
465
|
+
tag: 'checker',
|
|
466
|
+
name: `chk_${optionIndex}_${questionId}`,
|
|
467
|
+
checked: false,
|
|
468
|
+
text: { tag: 'plain_text', content: String(option.label).slice(0, 80) },
|
|
469
|
+
})),
|
|
470
|
+
{
|
|
471
|
+
tag: 'input',
|
|
472
|
+
name: `text_${questionId}`,
|
|
473
|
+
placeholder: { tag: 'plain_text', content: '也可以在补充框里写别的答案' },
|
|
474
|
+
input_type: 'multiline_text',
|
|
475
|
+
rows: 1,
|
|
476
|
+
width: 'fill',
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
tag: 'button',
|
|
480
|
+
name: 'submit',
|
|
481
|
+
form_action_type: 'submit',
|
|
482
|
+
type: 'primary_filled',
|
|
483
|
+
width: 'fill',
|
|
484
|
+
text: { tag: 'plain_text', content: '提交' },
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
});
|
|
488
|
+
} else {
|
|
489
|
+
body.push('', '在下面输入后点「提交」(也可以直接在聊天里回复)。');
|
|
490
|
+
elements.push({ tag: 'markdown', content: body.join('\n') });
|
|
491
|
+
elements.push({
|
|
492
|
+
tag: 'form',
|
|
493
|
+
name: `dsh_form_${questionId}`,
|
|
494
|
+
elements: [
|
|
495
|
+
{
|
|
496
|
+
tag: 'input',
|
|
497
|
+
name: `text_${questionId}`,
|
|
498
|
+
placeholder: { tag: 'plain_text', content: '在这里输入' },
|
|
499
|
+
label: { tag: 'plain_text', content: '你的回答' },
|
|
500
|
+
input_type: 'multiline_text',
|
|
501
|
+
rows: 3,
|
|
502
|
+
auto_resize: true,
|
|
503
|
+
max_rows: 8,
|
|
504
|
+
width: 'fill',
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
tag: 'button',
|
|
508
|
+
name: 'submit',
|
|
509
|
+
form_action_type: 'submit',
|
|
510
|
+
type: 'primary_filled',
|
|
511
|
+
width: 'fill',
|
|
512
|
+
text: { tag: 'plain_text', content: '提交' },
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
elements.push({
|
|
518
|
+
tag: 'div',
|
|
519
|
+
text: {
|
|
520
|
+
tag: 'plain_text',
|
|
521
|
+
content: '回答后会自动翻到下一题;也可以直接回复文字。',
|
|
522
|
+
text_size: 'notation',
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
return { rows, elements, current };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 上传一个文件,返回 `file_key`。
|
|
530
|
+
*
|
|
531
|
+
* 注意:`im.v1.file.create` / `image.create` 直接返回 data(`{ file_key }`),
|
|
532
|
+
* 不像 `message.create` 那样包一层 `{ code, msg, data }`;两种都认,免得跟着 SDK 版本翻车。
|
|
533
|
+
*/
|
|
534
|
+
async function uploadFileKey(path, fileName) {
|
|
535
|
+
const uploaded = await client.im.v1.file.create({
|
|
536
|
+
data: { file_type: fileTypeFor(fileName), file_name: fileName, file: createReadStream(path) },
|
|
537
|
+
});
|
|
538
|
+
const fileKey = uploaded?.file_key ?? uploaded?.data?.file_key;
|
|
539
|
+
if (!fileKey) {
|
|
540
|
+
const error = new Error('飞书上传文件失败:没有返回 file_key。');
|
|
541
|
+
error.code = 'feishu/upload-failed';
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
return fileKey;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 上传一张图片,返回 `image_key`。 */
|
|
548
|
+
async function uploadImageKey(path) {
|
|
549
|
+
const uploaded = await client.im.v1.image.create({
|
|
550
|
+
data: { image_type: 'message', image: createReadStream(path) },
|
|
551
|
+
});
|
|
552
|
+
const imageKey = uploaded?.image_key ?? uploaded?.data?.image_key;
|
|
553
|
+
if (!imageKey) {
|
|
554
|
+
const error = new Error('飞书上传图片失败:没有返回 image_key。');
|
|
555
|
+
error.code = 'feishu/upload-failed';
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
return imageKey;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return Object.freeze({
|
|
562
|
+
appId,
|
|
563
|
+
|
|
564
|
+
/** @returns 长连接是否就绪。 */
|
|
565
|
+
isConnected: () => connected,
|
|
566
|
+
|
|
567
|
+
/** @returns 最近一次连接错误(供状态展示)。 */
|
|
568
|
+
lastError: () => lastError,
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 建立长连接并订阅消息事件。超时或失败会抛出,并把半开的连接丢掉。
|
|
572
|
+
*
|
|
573
|
+
* @param options - { onMessage, onCardAction, signal }。
|
|
574
|
+
*/
|
|
575
|
+
async connect({ onMessage, onCardAction, signal } = {}) {
|
|
576
|
+
const dispatcher = new sdk.EventDispatcher({}).register({
|
|
577
|
+
'im.message.receive_v1': (event) => {
|
|
578
|
+
void Promise.resolve()
|
|
579
|
+
.then(() => onMessage?.(event))
|
|
580
|
+
.catch((error) => logger.error?.(`[dsh-chat-feishu] 处理入站消息失败:${error?.message ?? error}`));
|
|
581
|
+
return undefined;
|
|
582
|
+
},
|
|
583
|
+
// 注意:卡片回调的返回值就是飞书客户端的应答(toast / 替换卡片),
|
|
584
|
+
// 必须把处理结果返回给 SDK,否则用户点了按钮只会看到一个失败提示。
|
|
585
|
+
'card.action.trigger': (event) => {
|
|
586
|
+
const normalized = normalizeCardAction(event);
|
|
587
|
+
if (!normalized) {
|
|
588
|
+
// 到了但认不出:把原始键名记下来,别让"点了没反应"再次无从查起。
|
|
589
|
+
logger.warn?.('[dsh-chat-feishu] 收到卡片回调但字段认不出:'
|
|
590
|
+
+ `${JSON.stringify(event ?? null).slice(0, 300)}`);
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
logger.info?.('[dsh-chat-feishu] 收到卡片回调:'
|
|
594
|
+
+ `会话=${normalized.chatId} 操作者=${normalized.operator.openId}`
|
|
595
|
+
+ ` 值=${JSON.stringify(normalized.action.value)}`
|
|
596
|
+
// 表单类控件的值不一定在 value 里,原始 action 一并记下(截断,避免刷屏)。
|
|
597
|
+
+ ` 原始=${JSON.stringify(event?.action ?? {}).slice(0, 400)}`);
|
|
598
|
+
return Promise.resolve()
|
|
599
|
+
.then(() => onCardAction?.(normalized))
|
|
600
|
+
.catch((error) => {
|
|
601
|
+
logger.error?.(`[dsh-chat-feishu] 处理卡片回调失败:${error?.message ?? error}`);
|
|
602
|
+
return undefined;
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
let settleReady;
|
|
608
|
+
let settleFail;
|
|
609
|
+
const ready = new Promise((resolve, reject) => {
|
|
610
|
+
settleReady = resolve;
|
|
611
|
+
settleFail = reject;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const instance = new sdk.WSClient({
|
|
615
|
+
...wsOptions,
|
|
616
|
+
onReady: () => {
|
|
617
|
+
connected = true;
|
|
618
|
+
lastError = null;
|
|
619
|
+
settleReady();
|
|
620
|
+
},
|
|
621
|
+
onError: (error) => {
|
|
622
|
+
connected = false;
|
|
623
|
+
lastError = error?.message ?? String(error);
|
|
624
|
+
settleFail(new Error(`飞书长连接失败:${lastError}`));
|
|
625
|
+
},
|
|
626
|
+
onReconnecting: () => {
|
|
627
|
+
connected = false;
|
|
628
|
+
},
|
|
629
|
+
onReconnected: () => {
|
|
630
|
+
connected = true;
|
|
631
|
+
lastError = null;
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
wsClient = instance;
|
|
635
|
+
|
|
636
|
+
const aborted = () => {
|
|
637
|
+
const error = new Error('飞书长连接已取消。');
|
|
638
|
+
error.code = 'feishu/aborted';
|
|
639
|
+
return error;
|
|
640
|
+
};
|
|
641
|
+
const timer = setTimeout(() => {
|
|
642
|
+
closeQuietly(instance);
|
|
643
|
+
const error = new Error(`飞书长连接在 ${connectTimeoutMs}ms 内未就绪。`);
|
|
644
|
+
error.code = 'feishu/connect-timeout';
|
|
645
|
+
settleFail(error);
|
|
646
|
+
}, connectTimeoutMs);
|
|
647
|
+
const onAbort = () => {
|
|
648
|
+
closeQuietly(instance);
|
|
649
|
+
settleFail(aborted());
|
|
650
|
+
};
|
|
651
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
const started = Promise.resolve().then(() => instance.start({ eventDispatcher: dispatcher }));
|
|
655
|
+
// 启动失败也要让 ready 有机会结束,避免悬挂。
|
|
656
|
+
void started.catch((error) => settleFail(error));
|
|
657
|
+
await Promise.race([ready, started.then(() => ready)]);
|
|
658
|
+
if (signal?.aborted) throw aborted();
|
|
659
|
+
} catch (error) {
|
|
660
|
+
connected = false;
|
|
661
|
+
if (wsClient === instance) wsClient = null;
|
|
662
|
+
closeQuietly(instance);
|
|
663
|
+
throw error;
|
|
664
|
+
} finally {
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
/** 断开长连接(幂等)。 */
|
|
671
|
+
async disconnect() {
|
|
672
|
+
const instance = wsClient;
|
|
673
|
+
wsClient = null;
|
|
674
|
+
connected = false;
|
|
675
|
+
closeQuietly(instance);
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 回复一条文本消息。
|
|
680
|
+
*
|
|
681
|
+
* @param options - { messageId, text, replyInThread }。
|
|
682
|
+
* @returns { messageId, threadId }。
|
|
683
|
+
*/
|
|
684
|
+
async replyText({ messageId, text, replyInThread = false }) {
|
|
685
|
+
const response = await client.im.v1.message.reply({
|
|
686
|
+
path: { message_id: messageId },
|
|
687
|
+
data: {
|
|
688
|
+
msg_type: 'text',
|
|
689
|
+
content: JSON.stringify({ text }),
|
|
690
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
assertSuccess('飞书回复消息', response);
|
|
694
|
+
return {
|
|
695
|
+
messageId: response?.data?.message_id,
|
|
696
|
+
threadId: response?.data?.thread_id,
|
|
697
|
+
};
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 主动发文本。
|
|
702
|
+
*
|
|
703
|
+
* @param options - { chatId }(群/会话)或 { openId }(私聊用户,二选一)、{ text }。
|
|
704
|
+
*/
|
|
705
|
+
async sendText({ chatId, openId, text }) {
|
|
706
|
+
const receiveId = chatId ?? openId;
|
|
707
|
+
if (!receiveId) throw new TypeError('sendText 需要 chatId 或 openId。');
|
|
708
|
+
const response = await client.im.v1.message.create({
|
|
709
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
710
|
+
data: { receive_id: receiveId, msg_type: 'text', content: JSON.stringify({ text }) },
|
|
711
|
+
});
|
|
712
|
+
assertSuccess('飞书发送消息', response);
|
|
713
|
+
return { messageId: response?.data?.message_id };
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* 发一个文件(先上传拿 file_key,再作为 file 消息发出去)。
|
|
718
|
+
*
|
|
719
|
+
* @param options - { chatId } 或 { openId }、{ path, name }。
|
|
720
|
+
* @returns { messageId, fileKey, name, size }。
|
|
721
|
+
*/
|
|
722
|
+
async sendFile({ chatId, openId, path, name }) {
|
|
723
|
+
const receiveId = chatId ?? openId;
|
|
724
|
+
if (!receiveId) throw new TypeError('sendFile 需要 chatId 或 openId。');
|
|
725
|
+
if (!path) throw new TypeError('sendFile 需要 path。');
|
|
726
|
+
const fileName = name || path.split('/').pop();
|
|
727
|
+
const info = await stat(path);
|
|
728
|
+
const fileKey = await uploadFileKey(path, fileName);
|
|
729
|
+
const response = await client.im.v1.message.create({
|
|
730
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
731
|
+
data: { receive_id: receiveId, msg_type: 'file', content: JSON.stringify({ file_key: fileKey }) },
|
|
732
|
+
});
|
|
733
|
+
assertSuccess('飞书发送文件', response);
|
|
734
|
+
return {
|
|
735
|
+
messageId: response?.data?.message_id,
|
|
736
|
+
fileKey,
|
|
737
|
+
name: fileName,
|
|
738
|
+
size: info.size,
|
|
739
|
+
};
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* 发一张图片(走 im/v1/images 上传,再作为 image 消息发出)。
|
|
744
|
+
*
|
|
745
|
+
* @param options - { chatId } 或 { openId }、{ path }。
|
|
746
|
+
*/
|
|
747
|
+
async sendImage({ chatId, openId, path }) {
|
|
748
|
+
const receiveId = chatId ?? openId;
|
|
749
|
+
if (!receiveId) throw new TypeError('sendImage 需要 chatId 或 openId。');
|
|
750
|
+
const imageKey = await uploadImageKey(path);
|
|
751
|
+
const response = await client.im.v1.message.create({
|
|
752
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
753
|
+
data: { receive_id: receiveId, msg_type: 'image', content: JSON.stringify({ image_key: imageKey }) },
|
|
754
|
+
});
|
|
755
|
+
assertSuccess('飞书发送图片', response);
|
|
756
|
+
return { messageId: response?.data?.message_id, imageKey };
|
|
757
|
+
},
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 独立提问卡片(Card 2.0,一页一题)。
|
|
761
|
+
*
|
|
762
|
+
* 进度卡可用时提问会内嵌进那张卡(见 turn-presenter),这个独立卡片只是兜底。
|
|
763
|
+
*
|
|
764
|
+
* @param options - { chatId } 或 { openId }、{ questions, answered, final, messageId? }。
|
|
765
|
+
* @returns { messageId }。
|
|
766
|
+
*/
|
|
767
|
+
async sendQuestionsCard({ chatId, openId, questions = [], answered = {}, final = false, messageId = null }) {
|
|
768
|
+
const receiveId = chatId ?? openId;
|
|
769
|
+
if (!messageId && !receiveId) throw new TypeError('sendQuestionsCard 需要 chatId/openId 或 messageId。');
|
|
770
|
+
const { rows, elements, current } = renderQuestionElements({ questions, answered, final });
|
|
771
|
+
if (rows.length > 0) {
|
|
772
|
+
// 独立卡片没有"工具面板",已答的行自己组成一个折叠面板:
|
|
773
|
+
// 收起而不是消失,点标题还能展开回看(真机要求)。
|
|
774
|
+
elements.unshift({
|
|
775
|
+
tag: 'collapsible_panel',
|
|
776
|
+
expanded: Boolean(current),
|
|
777
|
+
border: { color: 'grey', corner_radius: '4px' },
|
|
778
|
+
header: {
|
|
779
|
+
title: {
|
|
780
|
+
tag: 'plain_text',
|
|
781
|
+
content: `❓ ${rows.length}/${questions.length} 已回答`,
|
|
782
|
+
},
|
|
783
|
+
width: 'fill',
|
|
784
|
+
icon_position: 'right',
|
|
785
|
+
icon_expanded_angle: -180,
|
|
786
|
+
},
|
|
787
|
+
elements: [{
|
|
788
|
+
tag: 'markdown',
|
|
789
|
+
content: rows.map((row) => `· ${row.text}`).join('\n'),
|
|
790
|
+
}],
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (elements.length === 0) {
|
|
794
|
+
elements.push({ tag: 'markdown', content: '全部问题都已回答,正在继续处理…' });
|
|
795
|
+
}
|
|
796
|
+
const card = {
|
|
797
|
+
schema: '2.0',
|
|
798
|
+
config: { update_multi: true, width_mode: 'default' },
|
|
799
|
+
header: {
|
|
800
|
+
template: final || !current ? 'green' : 'blue',
|
|
801
|
+
title: {
|
|
802
|
+
tag: 'plain_text',
|
|
803
|
+
content: final || !current
|
|
804
|
+
? '✅ 已全部回答'
|
|
805
|
+
: `❓ 需要你确认(第 ${questions.indexOf(current) + 1}/${questions.length} 题)`,
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
body: { direction: 'vertical', elements },
|
|
809
|
+
};
|
|
810
|
+
if (messageId) {
|
|
811
|
+
const patched = await client.im.v1.message.patch({
|
|
812
|
+
path: { message_id: messageId },
|
|
813
|
+
data: { content: JSON.stringify(card) },
|
|
814
|
+
});
|
|
815
|
+
assertSuccess('飞书更新提问卡片', patched);
|
|
816
|
+
return { messageId };
|
|
817
|
+
}
|
|
818
|
+
const response = await client.im.v1.message.create({
|
|
819
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
820
|
+
data: { receive_id: receiveId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
821
|
+
});
|
|
822
|
+
assertSuccess('飞书发送提问卡片', response);
|
|
823
|
+
return { messageId: response?.data?.message_id };
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 一条消息发多个交付文件。
|
|
828
|
+
*
|
|
829
|
+
* 飞书原生支持:`post`(富文本)消息正文可以内嵌图片(`img`),顶层 `files` 附件区可以放
|
|
830
|
+
* **多个** `file_key`(文件名/大小由服务端按文件元数据回填,客户端传 name 无效),
|
|
831
|
+
* 附件区永远渲染在正文下面。因此**一条消息**就能做到真机要求的排版:
|
|
832
|
+
*
|
|
833
|
+
* ```
|
|
834
|
+
* 图片
|
|
835
|
+
* 图片
|
|
836
|
+
* 文件 文件 ← 附件区
|
|
837
|
+
* ```
|
|
838
|
+
*
|
|
839
|
+
* 正文里**不写任何文字**(交付物不需要描述),只有图片行。
|
|
840
|
+
*
|
|
841
|
+
* @param options - { chatId, openId, items },`items` = `[{ path, name? }]`。
|
|
842
|
+
* @returns { messageId, files, images, failed }。
|
|
843
|
+
*/
|
|
844
|
+
async sendDeliverables({ chatId, openId, items = [] }) {
|
|
845
|
+
const receiveId = chatId ?? openId;
|
|
846
|
+
if (!receiveId) throw new TypeError('sendDeliverables 需要 chatId 或 openId。');
|
|
847
|
+
const list = items.filter((item) => typeof item?.path === 'string' && item.path);
|
|
848
|
+
// 图片在前、文件在后(附件区固定在最下面,所以只上传一次就能得到目标顺序)。
|
|
849
|
+
const ordered = [
|
|
850
|
+
...list.filter((item) => isImagePath(item.path)),
|
|
851
|
+
...list.filter((item) => !isImagePath(item.path)),
|
|
852
|
+
];
|
|
853
|
+
const paragraphs = [];
|
|
854
|
+
const attachments = [];
|
|
855
|
+
const sent = [];
|
|
856
|
+
const failed = [];
|
|
857
|
+
for (const item of ordered) {
|
|
858
|
+
const path = item.path;
|
|
859
|
+
const name = item.name || path.split('/').pop() || '文件';
|
|
860
|
+
try {
|
|
861
|
+
if (isImagePath(path)) {
|
|
862
|
+
// 图片按图片发:正文里的 img 行会直接显示成图片,而不是可下载的附件卡片。
|
|
863
|
+
paragraphs.push([{ tag: 'img', image_key: await uploadImageKey(path) }]);
|
|
864
|
+
sent.push({ name, kind: 'image' });
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
attachments.push({ key: await uploadFileKey(path, name) });
|
|
868
|
+
sent.push({ name, kind: 'file' });
|
|
869
|
+
} catch (error) {
|
|
870
|
+
failed.push({ name, reason: error?.message ?? String(error) });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (sent.length === 0) return { files: [], images: [], failed, messageId: null };
|
|
874
|
+
const content = {
|
|
875
|
+
zh_cn: { content: paragraphs },
|
|
876
|
+
...(attachments.length > 0 ? { files: attachments } : {}),
|
|
877
|
+
};
|
|
878
|
+
const response = await client.im.v1.message.create({
|
|
879
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
880
|
+
data: { receive_id: receiveId, msg_type: 'post', content: JSON.stringify(content) },
|
|
881
|
+
});
|
|
882
|
+
assertSuccess('飞书发送交付文件', response);
|
|
883
|
+
return {
|
|
884
|
+
messageId: response?.data?.message_id,
|
|
885
|
+
files: sent.filter((entry) => entry.kind === 'file').map((entry) => entry.name),
|
|
886
|
+
images: sent.filter((entry) => entry.kind === 'image').map((entry) => entry.name),
|
|
887
|
+
failed,
|
|
888
|
+
};
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
/** 供进度卡内嵌提问区使用(纯渲染)。 */
|
|
892
|
+
renderQuestionElements,
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* 把一个提问渲染成带按钮的卡片发出去。
|
|
896
|
+
*
|
|
897
|
+
* 按钮 `value` 里带的是**答案原文**(选项 label),点击后由桥交给 hub 的交互服务
|
|
898
|
+
* 认领——与"用户手打选项文字"走完全相同的解析路径,因此两条路不会出现行为差异。
|
|
899
|
+
*
|
|
900
|
+
* @param options - { chatId } 或 { openId }、{ question, position, total, note? }。
|
|
901
|
+
* @returns { messageId }。
|
|
902
|
+
*/
|
|
903
|
+
async sendQuestionCard({ chatId, openId, question, position = 1, total = 1, note = '' }) {
|
|
904
|
+
const receiveId = chatId ?? openId;
|
|
905
|
+
if (!receiveId) throw new TypeError('sendQuestionCard 需要 chatId 或 openId。');
|
|
906
|
+
const options = Array.isArray(question?.options) ? question.options : [];
|
|
907
|
+
const elements = [];
|
|
908
|
+
const body = [String(question?.question ?? '')];
|
|
909
|
+
if (question?.detail) body.push('', String(question.detail));
|
|
910
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: body.join('\n') } });
|
|
911
|
+
if (options.length > 0) {
|
|
912
|
+
elements.push({
|
|
913
|
+
tag: 'action',
|
|
914
|
+
actions: options.slice(0, 8).map((option, index) => ({
|
|
915
|
+
tag: 'button',
|
|
916
|
+
type: 'default',
|
|
917
|
+
text: { tag: 'plain_text', content: String(option.label).slice(0, 60) },
|
|
918
|
+
value: {
|
|
919
|
+
dsh: 'answer',
|
|
920
|
+
questionId: String(question?.id ?? ''),
|
|
921
|
+
label: String(option.label),
|
|
922
|
+
index: String(index + 1),
|
|
923
|
+
},
|
|
924
|
+
})),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
elements.push({
|
|
928
|
+
tag: 'note',
|
|
929
|
+
elements: [{ tag: 'plain_text', content: '点按钮即可;也可以直接回复文字。' }],
|
|
930
|
+
});
|
|
931
|
+
const response = await client.im.v1.message.create({
|
|
932
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
933
|
+
data: {
|
|
934
|
+
receive_id: receiveId,
|
|
935
|
+
msg_type: 'interactive',
|
|
936
|
+
content: JSON.stringify({
|
|
937
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
938
|
+
header: {
|
|
939
|
+
template: 'blue',
|
|
940
|
+
title: {
|
|
941
|
+
tag: 'plain_text',
|
|
942
|
+
content: total > 1 ? `❓ 需要你确认(${position}/${total})` : '❓ 需要你确认',
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
elements,
|
|
946
|
+
}),
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
assertSuccess('飞书发送提问卡片', response);
|
|
950
|
+
return { messageId: response?.data?.message_id };
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* 把一次审批渲染成「允许 / 拒绝」按钮卡片。
|
|
955
|
+
*
|
|
956
|
+
* @param options - { chatId } 或 { openId }、{ request }。
|
|
957
|
+
* @returns { messageId }。
|
|
958
|
+
*/
|
|
959
|
+
async sendApprovalCard({ chatId, openId, request }) {
|
|
960
|
+
const receiveId = chatId ?? openId;
|
|
961
|
+
if (!receiveId) throw new TypeError('sendApprovalCard 需要 chatId 或 openId。');
|
|
962
|
+
const lines = ['需要授权', '', `工具:${request?.toolName ?? '未知'}`];
|
|
963
|
+
if (request?.reason) lines.push(`原因:${request.reason}`);
|
|
964
|
+
// 审批卡是 Card 1.0(`elements` 顶格):登记 schema,交互后回写才不会被
|
|
965
|
+
// "2.0/1.0 混用"拒掉,也才知道要不要带 open_ids。
|
|
966
|
+
const card = {
|
|
967
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
968
|
+
header: { template: 'orange', title: { tag: 'plain_text', content: '⚠️ 需要授权' } },
|
|
969
|
+
elements: [
|
|
970
|
+
{ tag: 'div', text: { tag: 'lark_md', content: lines.join('\n') } },
|
|
971
|
+
{
|
|
972
|
+
tag: 'action',
|
|
973
|
+
actions: [
|
|
974
|
+
{
|
|
975
|
+
tag: 'button',
|
|
976
|
+
type: 'primary',
|
|
977
|
+
text: { tag: 'plain_text', content: '允许一次' },
|
|
978
|
+
value: { dsh: 'approval', decision: 'allowed-once' },
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
tag: 'button',
|
|
982
|
+
type: 'danger',
|
|
983
|
+
text: { tag: 'plain_text', content: '拒绝' },
|
|
984
|
+
value: { dsh: 'approval', decision: 'rejected' },
|
|
985
|
+
},
|
|
986
|
+
],
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
};
|
|
990
|
+
const response = await client.im.v1.message.create({
|
|
991
|
+
params: { receive_id_type: chatId ? 'chat_id' : 'open_id' },
|
|
992
|
+
data: { receive_id: receiveId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
993
|
+
});
|
|
994
|
+
assertSuccess('飞书发送审批卡片', response);
|
|
995
|
+
const messageId = response?.data?.message_id;
|
|
996
|
+
rememberCardSchema(messageId, card);
|
|
997
|
+
return { messageId };
|
|
998
|
+
},
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 给一条消息加表情回复(默认「在做了」),返回可撤销的 reaction_id。
|
|
1002
|
+
*
|
|
1003
|
+
* 用途:用户发来消息时立刻打个表情表示"收到了、正在处理",处理完再撤掉——
|
|
1004
|
+
* 比等着卡片刷新更即时,也不会污染聊天记录。
|
|
1005
|
+
*
|
|
1006
|
+
* @param options - { messageId, emojiType = 'OnIt' }。
|
|
1007
|
+
* @returns { reactionId }。
|
|
1008
|
+
*/
|
|
1009
|
+
async addReaction({ messageId, emojiType = 'OnIt' }) {
|
|
1010
|
+
if (!messageId) throw new TypeError('addReaction 需要 messageId。');
|
|
1011
|
+
const response = await client.im.v1.messageReaction.create({
|
|
1012
|
+
path: { message_id: messageId },
|
|
1013
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
1014
|
+
});
|
|
1015
|
+
assertSuccess('飞书添加表情回复', response);
|
|
1016
|
+
return { reactionId: response?.data?.reaction_id ?? null };
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* 撤销一条表情回复。
|
|
1021
|
+
*
|
|
1022
|
+
* @param options - { messageId, reactionId }。
|
|
1023
|
+
*/
|
|
1024
|
+
async removeReaction({ messageId, reactionId }) {
|
|
1025
|
+
if (!messageId || !reactionId) return { removed: false };
|
|
1026
|
+
const response = await client.im.v1.messageReaction.delete({
|
|
1027
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
1028
|
+
});
|
|
1029
|
+
assertSuccess('飞书撤销表情回复', response);
|
|
1030
|
+
return { removed: true };
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
/** 把卡片替换成"已处理"的静态卡片(点击后再也点不动,避免重复回答)。 */
|
|
1034
|
+
/**
|
|
1035
|
+
* 把一张提问/审批卡标成"已回答"。
|
|
1036
|
+
*
|
|
1037
|
+
* 两个坑都在真机上踩过:
|
|
1038
|
+
* - **schema 必须与原来那张一致**:2.0 的卡用 1.0 内容回写会被拒
|
|
1039
|
+
* (`230099 schemaV2 card can not change schemaV1`),所以发出时记下 schema;
|
|
1040
|
+
* - **交互驱动的更新要走延迟更新 token**:用 `message.patch` 会被客户端还原
|
|
1041
|
+
* ("点了又变回去")。有 token 就先走 token 路径,没有才退回 patch。
|
|
1042
|
+
*
|
|
1043
|
+
* @param options - { messageId, token?, title, content }。
|
|
1044
|
+
*/
|
|
1045
|
+
async markCardAnswered({ messageId, token = null, openIds = null, title, content }) {
|
|
1046
|
+
const schema = cardSchemas.get(messageId) ?? '1.0';
|
|
1047
|
+
const header = { template: 'green', title: { tag: 'plain_text', content: String(title).slice(0, 100) } };
|
|
1048
|
+
const text = String(content);
|
|
1049
|
+
const card = schema === '2.0'
|
|
1050
|
+
? {
|
|
1051
|
+
schema: '2.0',
|
|
1052
|
+
config: { update_multi: true, width_mode: 'default' },
|
|
1053
|
+
header,
|
|
1054
|
+
body: { direction: 'vertical', elements: [{ tag: 'markdown', content: text }] },
|
|
1055
|
+
}
|
|
1056
|
+
: {
|
|
1057
|
+
config: { wide_screen_mode: true, update_multi: true },
|
|
1058
|
+
header,
|
|
1059
|
+
elements: [{ tag: 'div', text: { tag: 'lark_md', content: text } }],
|
|
1060
|
+
};
|
|
1061
|
+
if (token) {
|
|
1062
|
+
const updated = await this.updateCard({ token, card, openIds }).then(() => true).catch((error) => {
|
|
1063
|
+
logger.warn?.(`[dsh-chat-feishu] 提问卡片延迟更新失败,退回 patch:${error?.message ?? error}`);
|
|
1064
|
+
return false;
|
|
1065
|
+
});
|
|
1066
|
+
if (updated) return { messageId, via: 'token' };
|
|
1067
|
+
}
|
|
1068
|
+
const response = await client.im.v1.message.patch({
|
|
1069
|
+
path: { message_id: messageId },
|
|
1070
|
+
data: { content: JSON.stringify(card) },
|
|
1071
|
+
});
|
|
1072
|
+
assertSuccess('飞书更新提问卡片', response);
|
|
1073
|
+
return { messageId, via: 'patch' };
|
|
1074
|
+
},
|
|
1075
|
+
|
|
1076
|
+
/** 发一张交互卡片。 */
|
|
1077
|
+
async sendCard({ chatId, card }) {
|
|
1078
|
+
const response = await client.im.v1.message.create({
|
|
1079
|
+
params: { receive_id_type: 'chat_id' },
|
|
1080
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
1081
|
+
});
|
|
1082
|
+
assertSuccess('飞书发送卡片', response);
|
|
1083
|
+
const messageId = response?.data?.message_id;
|
|
1084
|
+
rememberCardSchema(messageId, card);
|
|
1085
|
+
return { messageId };
|
|
1086
|
+
},
|
|
1087
|
+
|
|
1088
|
+
/** 回复一张交互卡片。 */
|
|
1089
|
+
async replyCard({ messageId, card, replyInThread = false }) {
|
|
1090
|
+
const response = await client.im.v1.message.reply({
|
|
1091
|
+
path: { message_id: messageId },
|
|
1092
|
+
data: {
|
|
1093
|
+
msg_type: 'interactive',
|
|
1094
|
+
content: JSON.stringify(card),
|
|
1095
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
1098
|
+
assertSuccess('飞书回复卡片', response);
|
|
1099
|
+
const sentId = response?.data?.message_id;
|
|
1100
|
+
rememberCardSchema(sentId, card);
|
|
1101
|
+
return { messageId: sentId, threadId: response?.data?.thread_id };
|
|
1102
|
+
},
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* 原地更新一张卡片(过程卡的实时刷新靠它)。
|
|
1106
|
+
*
|
|
1107
|
+
* **只用于非交互驱动的更新**(agent 跑任务时刷新过程卡)。用户点了卡片之后要更新它,
|
|
1108
|
+
* 必须走 `updateCard`(延迟更新 token)——用 `message.patch` 会被客户端回滚成原样,
|
|
1109
|
+
* 真机现象就是"卡片变了一下又变回去"。
|
|
1110
|
+
*/
|
|
1111
|
+
async patchCard({ messageId, card }) {
|
|
1112
|
+
const response = await client.im.v1.message.patch({
|
|
1113
|
+
path: { message_id: messageId },
|
|
1114
|
+
data: { content: JSON.stringify(card) },
|
|
1115
|
+
});
|
|
1116
|
+
assertSuccess('飞书更新卡片', response);
|
|
1117
|
+
return { messageId };
|
|
1118
|
+
},
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* 更新"用户刚交互的那张卡片"(飞书**延迟更新**接口)。
|
|
1122
|
+
*
|
|
1123
|
+
* 为什么不能用 `message.patch`:卡片回传(`card.action.trigger`)会带一个
|
|
1124
|
+
* **延迟更新 token**,飞书要求在一次交互里对卡片的更新走
|
|
1125
|
+
* `POST /open-apis/interactive/v1/card/update { token, card }`;
|
|
1126
|
+
* 用 `message.patch` 改会被客户端还原(真机上反复出现"变了又变回去")。
|
|
1127
|
+
*
|
|
1128
|
+
* 约束(飞书官方):token 有效期 30 分钟、**最多用 2 次**;`card` 必须是**完整**卡片 JSON,
|
|
1129
|
+
* 不支持增量更新;**Card 1.0 还必须在 card 里带 `open_ids`**(至少一个 open_id,
|
|
1130
|
+
* 省略或传空会报 300090 "openid empty")。
|
|
1131
|
+
*
|
|
1132
|
+
* @param options - { token, card, openIds? }。`openIds` 仅 1.0 卡片需要。
|
|
1133
|
+
* @returns `{ updated: true }`。
|
|
1134
|
+
*/
|
|
1135
|
+
async updateCard({ token, card, openIds = null }) {
|
|
1136
|
+
if (typeof token !== 'string' || !token) {
|
|
1137
|
+
const error = new Error('交互卡片更新需要回调里的 token(延迟更新凭证)。');
|
|
1138
|
+
error.code = 'feishu/no-card-token';
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
const payload = card?.schema === '2.0'
|
|
1142
|
+
? card
|
|
1143
|
+
: { ...card, ...(Array.isArray(openIds) && openIds.length > 0 ? { open_ids: openIds } : {}) };
|
|
1144
|
+
if (payload.schema !== '2.0' && !(Array.isArray(payload.open_ids) && payload.open_ids.length > 0)) {
|
|
1145
|
+
const error = new Error('Card 1.0 的延迟更新必须在 card 里带 open_ids(否则飞书报 300090)。');
|
|
1146
|
+
error.code = 'feishu/missing-open-ids';
|
|
1147
|
+
throw error;
|
|
1148
|
+
}
|
|
1149
|
+
const response = await client.request({
|
|
1150
|
+
method: 'POST',
|
|
1151
|
+
url: `${client.domain}/open-apis/interactive/v1/card/update`,
|
|
1152
|
+
data: { token, card: payload },
|
|
1153
|
+
});
|
|
1154
|
+
assertSuccess('飞书更新交互卡片', response);
|
|
1155
|
+
return { updated: true };
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* 读一条消息的**可读内容**(引用回复要用:飞书的事件里只有 `parent_id`,正文得再查一次)。
|
|
1160
|
+
*
|
|
1161
|
+
* 只做"映射成 reply 快照"这一件事,拼提示词是 hub 的活(`enhanceReplyReference`):
|
|
1162
|
+
* - 文字:`body.content` 是 JSON,`text` 字段;
|
|
1163
|
+
* - 富文本(post):把 `content` 里各段的 text 拼起来;
|
|
1164
|
+
* - 其它类型(图片/文件/语音/视频…):给类型与文件名,**不下载**被引用的历史媒体;
|
|
1165
|
+
* - 读不到(已删除/无权限/超时)抛错,由调用方转成"引用内容不可用"的标记。
|
|
1166
|
+
*
|
|
1167
|
+
* @param options - { messageId }。
|
|
1168
|
+
* @returns `{ messageId, senderId, kind, text, fileName }`。
|
|
1169
|
+
*/
|
|
1170
|
+
async getMessageText({ messageId }) {
|
|
1171
|
+
if (typeof messageId !== 'string' || !messageId) {
|
|
1172
|
+
throw new TypeError('getMessageText 需要 messageId。');
|
|
1173
|
+
}
|
|
1174
|
+
const response = await client.im.v1.message.get({ path: { message_id: messageId } });
|
|
1175
|
+
assertSuccess('飞书读取被引用的消息', response);
|
|
1176
|
+
const item = (response?.data?.items ?? [])[0];
|
|
1177
|
+
if (!item) {
|
|
1178
|
+
const error = new Error(`飞书没有返回消息 ${messageId} 的内容。`);
|
|
1179
|
+
error.code = 'feishu/message-not-found';
|
|
1180
|
+
throw error;
|
|
1181
|
+
}
|
|
1182
|
+
let body = {};
|
|
1183
|
+
try {
|
|
1184
|
+
body = JSON.parse(item.body?.content ?? '{}');
|
|
1185
|
+
} catch {
|
|
1186
|
+
body = {};
|
|
1187
|
+
}
|
|
1188
|
+
const msgType = typeof item.msg_type === 'string' ? item.msg_type : 'unknown';
|
|
1189
|
+
const plain = (value) => (typeof value === 'string' ? value.trim() : '');
|
|
1190
|
+
let text = '';
|
|
1191
|
+
if (msgType === 'text') {
|
|
1192
|
+
text = plain(body.text);
|
|
1193
|
+
} else if (msgType === 'post') {
|
|
1194
|
+
// 富文本:`content` 是 [[{tag,text|href|...}]],只取文字部分。
|
|
1195
|
+
const rows = Array.isArray(body.content) ? body.content : [];
|
|
1196
|
+
text = rows.flat().map((node) => plain(node?.text ?? node?.href)).filter(Boolean).join(' ');
|
|
1197
|
+
} else if (msgType === 'audio') {
|
|
1198
|
+
text = plain(body.text); // 飞书的语音消息可能带 ASR 文本。
|
|
1199
|
+
}
|
|
1200
|
+
const fileName = plain(body.file_name) || plain(body.fileName) || null;
|
|
1201
|
+
return {
|
|
1202
|
+
messageId: item.message_id ?? messageId,
|
|
1203
|
+
senderId: item.sender?.id ?? item.sender?.sender_id?.open_id ?? null,
|
|
1204
|
+
kind: msgType,
|
|
1205
|
+
text,
|
|
1206
|
+
fileName,
|
|
1207
|
+
};
|
|
1208
|
+
},
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* 下载消息里的资源(图片/文件)。
|
|
1212
|
+
*
|
|
1213
|
+
* 飞书这个接口用**二进制流**返回成功结果,业务失败则回一段 JSON;因此这里
|
|
1214
|
+
* 同时看 content-type 与体积,两种失败都给出可读原因,绝不当成图片交出去。
|
|
1215
|
+
*
|
|
1216
|
+
* @param options - { messageId, fileKey, type = 'image' }。
|
|
1217
|
+
* @returns { bytes: Buffer, contentType: string|null }。
|
|
1218
|
+
*/
|
|
1219
|
+
async downloadResource({ messageId, fileKey, type = 'image' }) {
|
|
1220
|
+
const response = await client.im.v1.messageResource.get({
|
|
1221
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
1222
|
+
params: { type },
|
|
1223
|
+
});
|
|
1224
|
+
const contentType = String(response?.headers?.['content-type'] ?? '').toLowerCase();
|
|
1225
|
+
const chunks = [];
|
|
1226
|
+
let size = 0;
|
|
1227
|
+
for await (const chunk of response.getReadableStream()) {
|
|
1228
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1229
|
+
size += buffer.length;
|
|
1230
|
+
if (size > maxResourceBytes) {
|
|
1231
|
+
const error = new Error(
|
|
1232
|
+
`飞书资源超过 ${Math.round(maxResourceBytes / 1024 / 1024)}MB 上限,已忽略。`,
|
|
1233
|
+
);
|
|
1234
|
+
error.code = 'feishu/resource-too-large';
|
|
1235
|
+
throw error;
|
|
1236
|
+
}
|
|
1237
|
+
chunks.push(buffer);
|
|
1238
|
+
}
|
|
1239
|
+
const bytes = Buffer.concat(chunks);
|
|
1240
|
+
if (contentType.includes('application/json') || contentType.includes('text/')) {
|
|
1241
|
+
// 业务失败被包在流里。
|
|
1242
|
+
let detail = '';
|
|
1243
|
+
try {
|
|
1244
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
1245
|
+
detail = parsed?.msg ? `${parsed.msg}(code ${parsed.code})` : bytes.toString('utf8').slice(0, 200);
|
|
1246
|
+
} catch {
|
|
1247
|
+
detail = bytes.toString('utf8').slice(0, 200);
|
|
1248
|
+
}
|
|
1249
|
+
const error = new Error(`下载飞书资源失败:${detail || contentType}`);
|
|
1250
|
+
error.code = 'feishu/resource-failed';
|
|
1251
|
+
throw error;
|
|
1252
|
+
}
|
|
1253
|
+
if (bytes.length === 0) {
|
|
1254
|
+
const error = new Error('下载飞书资源失败:返回内容为空。');
|
|
1255
|
+
error.code = 'feishu/resource-failed';
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
return { bytes, contentType: contentType || null };
|
|
1259
|
+
},
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* 机器人所在的群(含群名)。
|
|
1263
|
+
*
|
|
1264
|
+
* 需要 `im:chat:readonly`(或 `im:chat` / `im:chat.group_info:readonly`)权限;
|
|
1265
|
+
* 没开通就抛出可读错误,由调用方降级——**名字只影响好不好认,不该让设置页出错**。
|
|
1266
|
+
*
|
|
1267
|
+
* @param options - { pageSize?, maxPages? }。
|
|
1268
|
+
* @returns `[{ chatId, name }]`(`name` 可能为空串)。
|
|
1269
|
+
*/
|
|
1270
|
+
async listChats({ pageSize = 100, maxPages = 20 } = {}) {
|
|
1271
|
+
const chats = [];
|
|
1272
|
+
let pageToken = null;
|
|
1273
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
1274
|
+
let response;
|
|
1275
|
+
try {
|
|
1276
|
+
response = await client.im.v1.chat.list({
|
|
1277
|
+
params: { page_size: pageSize, ...(pageToken ? { page_token: pageToken } : {}) },
|
|
1278
|
+
});
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
throw new Error(`读取群列表失败:${readableApiError(error)}`);
|
|
1281
|
+
}
|
|
1282
|
+
const data = assertSuccess('读取群列表', response)?.data ?? {};
|
|
1283
|
+
for (const item of data.items ?? []) {
|
|
1284
|
+
if (typeof item?.chat_id !== 'string' || !item.chat_id) continue;
|
|
1285
|
+
chats.push({ chatId: item.chat_id, name: typeof item.name === 'string' ? item.name : '' });
|
|
1286
|
+
}
|
|
1287
|
+
if (!data.has_more || !data.page_token) break;
|
|
1288
|
+
pageToken = data.page_token;
|
|
1289
|
+
}
|
|
1290
|
+
return chats;
|
|
1291
|
+
},
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* 群成员(`open_id` → 名字)。
|
|
1295
|
+
*
|
|
1296
|
+
* 为什么需要它:`getUserName` 要**通讯录权限**(还要人在这台应用的可见范围里),
|
|
1297
|
+
* 真机上常见的失败是 `no user authority error (code 41050)`——那排白名单就只剩 id。
|
|
1298
|
+
* 而"读群成员"用的是 `im:chat:readonly` 系权限(读群列表本来就要它),
|
|
1299
|
+
* 于是只要这个人在机器人所在的任一群里,名字就还能换出来。
|
|
1300
|
+
* 拿不到(应用没进群 / 缺权限)抛可读错误,由调用方决定要不要留空。
|
|
1301
|
+
*
|
|
1302
|
+
* @param options - { chatId, pageSize, maxPages }。
|
|
1303
|
+
* @returns `[{ openId, name }]`。
|
|
1304
|
+
*/
|
|
1305
|
+
async listChatMembers({ chatId, pageSize = 100, maxPages = 10 } = {}) {
|
|
1306
|
+
if (typeof chatId !== 'string' || !chatId) return [];
|
|
1307
|
+
const members = [];
|
|
1308
|
+
let pageToken = null;
|
|
1309
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
1310
|
+
let response;
|
|
1311
|
+
try {
|
|
1312
|
+
response = await client.im.v1.chatMembers.get({
|
|
1313
|
+
path: { chat_id: chatId },
|
|
1314
|
+
params: {
|
|
1315
|
+
member_id_type: 'open_id',
|
|
1316
|
+
page_size: pageSize,
|
|
1317
|
+
...(pageToken ? { page_token: pageToken } : {}),
|
|
1318
|
+
},
|
|
1319
|
+
});
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
throw new Error(`读取群成员失败:${readableApiError(error)}`);
|
|
1322
|
+
}
|
|
1323
|
+
const data = assertSuccess('读取群成员', response)?.data ?? {};
|
|
1324
|
+
for (const item of data.items ?? []) {
|
|
1325
|
+
if (typeof item?.member_id !== 'string' || !item.member_id) continue;
|
|
1326
|
+
members.push({
|
|
1327
|
+
openId: item.member_id,
|
|
1328
|
+
name: typeof item.name === 'string' ? item.name : '',
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
if (!data.has_more || !data.page_token) break;
|
|
1332
|
+
pageToken = data.page_token;
|
|
1333
|
+
}
|
|
1334
|
+
return members;
|
|
1335
|
+
},
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* 用 open_id 反查人名。
|
|
1339
|
+
*
|
|
1340
|
+
* 需要通讯录权限(`contact:user.base:readonly` 等)**且这个人在应用的可见范围里**;
|
|
1341
|
+
* 没开通就抛出可读错误(调用方会退回"从共同群里查名字")。
|
|
1342
|
+
*
|
|
1343
|
+
* @param openId - 用户 open_id。
|
|
1344
|
+
* @returns 名字(查不到返回空串)。
|
|
1345
|
+
*/
|
|
1346
|
+
async getUserName(openId) {
|
|
1347
|
+
if (typeof openId !== 'string' || !openId) return '';
|
|
1348
|
+
let response;
|
|
1349
|
+
try {
|
|
1350
|
+
response = await client.contact.v3.user.get({
|
|
1351
|
+
path: { user_id: openId },
|
|
1352
|
+
params: { user_id_type: 'open_id' },
|
|
1353
|
+
});
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
throw new Error(`读取用户信息失败:${readableApiError(error)}`);
|
|
1356
|
+
}
|
|
1357
|
+
const data = assertSuccess('读取用户信息', response)?.data ?? {};
|
|
1358
|
+
const name = data?.user?.name;
|
|
1359
|
+
return typeof name === 'string' ? name : '';
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
}
|