@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,1514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书渠道控制器:多机器人长连接的生命周期、状态与渠道 RPC 端点。
|
|
3
|
+
*
|
|
4
|
+
* 每个机器人独立启动:一个机器人凭据坏了只影响它自己,其他机器人照常在线。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-chat-feishu/controller
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { createFeishuBridge } from './bridge.mjs';
|
|
13
|
+
import { createFeishuConfigStore } from './config-store.mjs';
|
|
14
|
+
import { createLarkCli, normalizeLarkUserIdentity } from './lark-cli.mjs';
|
|
15
|
+
import { createLarkCliGuard } from './lark-guard.mjs';
|
|
16
|
+
import { createLarkGateway, createLarkProbe } from './lark-gateway.mjs';
|
|
17
|
+
import { createProvisionManager } from './provision.mjs';
|
|
18
|
+
import { createFeishuStateStore } from './state-store.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 由 appId 推导 botId 与凭据引用名(与微信渠道同一条做法:sha256 前 24 位十六进制)。
|
|
22
|
+
*
|
|
23
|
+
* 取的是 appId 而不是"接入时间":**同一个应用重复接入不会造出第二只机器人**,
|
|
24
|
+
* 凭据引用名也稳定(页面把 Secret 写进 DSH 凭据服务时用的就是它)。
|
|
25
|
+
*
|
|
26
|
+
* @param appId - 飞书应用的 App ID(`cli_…`)。
|
|
27
|
+
* @returns { botId, secretRef }。
|
|
28
|
+
*/
|
|
29
|
+
export function deriveFeishuIdentity(appId) {
|
|
30
|
+
const raw = typeof appId === 'string' ? appId.trim() : '';
|
|
31
|
+
if (!raw) throw new TypeError('deriveFeishuIdentity 需要 appId。');
|
|
32
|
+
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
33
|
+
return { botId: `fs_${digest}`, secretRef: `DSH_FEISHU_APP_SECRET_${digest.toUpperCase()}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** App ID 的合法形状(飞书自建应用都是 `cli_` 开头;后面的字符宽松一点,真正的门是探针)。 */
|
|
37
|
+
const APP_ID_PATTERN = /^cli_[A-Za-z0-9_-]{4,64}$/;
|
|
38
|
+
/** 域名取值:飞书(国内)与 Lark(海外)。 */
|
|
39
|
+
const APP_DOMAINS = Object.freeze(['feishu', 'lark']);
|
|
40
|
+
|
|
41
|
+
/** App Secret 只经 DSH 凭据服务读取;展示时永不回传。 */
|
|
42
|
+
async function resolveSecret(credentials, ref) {
|
|
43
|
+
if (typeof credentials?.resolve !== 'function') {
|
|
44
|
+
throw new Error('当前 Host 未提供凭据服务,无法读取飞书 App Secret。');
|
|
45
|
+
}
|
|
46
|
+
let resolved;
|
|
47
|
+
try {
|
|
48
|
+
resolved = await credentials.resolve(ref);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const wrapped = new Error(`读取飞书凭据 ${ref} 失败:${error?.message ?? error}`);
|
|
51
|
+
wrapped.code = 'feishu/credential-unreadable';
|
|
52
|
+
throw wrapped;
|
|
53
|
+
}
|
|
54
|
+
if (!resolved?.value) {
|
|
55
|
+
const error = new Error(`飞书凭据 ${ref} 未配置,请在设置页重新接入。`);
|
|
56
|
+
error.code = 'feishu/credential-missing';
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return resolved.value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function maskAppId(appId) {
|
|
63
|
+
if (typeof appId !== 'string' || appId.length <= 8) return '****';
|
|
64
|
+
return `${appId.slice(0, 8)}****`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 创建飞书控制器。
|
|
69
|
+
*
|
|
70
|
+
* @param options - { deps, logger, config, internals }。
|
|
71
|
+
* `internals` 可注入 sdk / createGateway / createBridge(测试用)。
|
|
72
|
+
* @returns 控制器。
|
|
73
|
+
*/
|
|
74
|
+
export function createFeishuController({ deps, logger = console, config = {}, internals = {} }) {
|
|
75
|
+
const dataDir = deps.dataDir;
|
|
76
|
+
if (typeof dataDir !== 'string' || !dataDir) throw new TypeError('飞书控制器需要 deps.dataDir。');
|
|
77
|
+
// hub 的会话桥与上下文增强引擎是本渠道的硬依赖:缺了就直接失败,
|
|
78
|
+
// 而不是让每个机器人各报一次同样的错。
|
|
79
|
+
if (typeof deps.sessions?.ask !== 'function' || typeof deps.contextEnhancement?.enhanceContent !== 'function') {
|
|
80
|
+
throw new TypeError('飞书控制器需要 hub 的 sessions.ask 与 contextEnhancement(请确认 dsh-chat 已加载)。');
|
|
81
|
+
}
|
|
82
|
+
const configStore = createFeishuConfigStore({ path: join(dataDir, 'config.json'), logger });
|
|
83
|
+
const gatewayFactory = internals.createGateway ?? createLarkGateway;
|
|
84
|
+
/** @type {Map<string, object>} botId → 运行时记录 */
|
|
85
|
+
const runtimes = new Map();
|
|
86
|
+
|
|
87
|
+
const sdkLoader = internals.sdk ?? (() => import('@larksuiteoapi/node-sdk'));
|
|
88
|
+
/** 建桥:测试可注入一份替身,用来断言"桥拿到的是哪份 bot 对象"。 */
|
|
89
|
+
const bridgeFactory = internals.createBridge ?? createFeishuBridge;
|
|
90
|
+
/** 凭据探针:测试注入替身,避免真去打飞书接口。 */
|
|
91
|
+
const probeFactory = internals.createProbe ?? createLarkProbe;
|
|
92
|
+
/** lark-cli 调用器工厂:测试注入替身(**测试绝不真跑 lark-cli**)。 */
|
|
93
|
+
const larkCliFactory = internals.createLarkCli ?? createLarkCli;
|
|
94
|
+
/** @type {Map<string, object>} botId → lark-cli 调用器(懒建,一个进程一份) */
|
|
95
|
+
const larkCliInstances = new Map();
|
|
96
|
+
/**
|
|
97
|
+
* @type {Map<string, string>} botId → 该应用在 lark-cli 里的 profile 名。
|
|
98
|
+
*
|
|
99
|
+
* **profile 名不能猜**:lark-cli 里一个 appId 只有一份 profile,名字由用户当初起
|
|
100
|
+
* (多数就是 appId,但也可能是别的)。门禁与提示词都要报出**真实**的那个名字,
|
|
101
|
+
* 报错了模型写什么都不对——所以这里存的是从 `lark-cli profile list` 读回来的值。
|
|
102
|
+
*/
|
|
103
|
+
const profileNames = new Map();
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 取某台机器人的 lark-cli 调用器。
|
|
107
|
+
*
|
|
108
|
+
* 身份策略是**函数**:每次调用现读运行期那份 bot 对象——设置页改完立刻生效,
|
|
109
|
+
* 不用重启、不用重连(与过程展示那条同一个道理,见 `patchRuntime`)。
|
|
110
|
+
*
|
|
111
|
+
* @param botId - 机器人 id。
|
|
112
|
+
* @returns 调用器,或 null(机器人不存在)。
|
|
113
|
+
*/
|
|
114
|
+
function larkCliFor(botId) {
|
|
115
|
+
const existing = larkCliInstances.get(botId);
|
|
116
|
+
if (existing) return existing;
|
|
117
|
+
const bot = runtimes.get(botId)?.bot ?? configStore.get(botId);
|
|
118
|
+
if (!bot) return null;
|
|
119
|
+
const instance = larkCliFactory({
|
|
120
|
+
appId: bot.appId,
|
|
121
|
+
brand: bot.domain === 'lark' ? 'lark' : 'feishu',
|
|
122
|
+
secretRef: bot.secretRef,
|
|
123
|
+
resolveSecret: (ref) => resolveSecret(deps.credentials, ref),
|
|
124
|
+
identityPolicy: () => {
|
|
125
|
+
const live = runtimes.get(botId)?.bot ?? configStore.get(botId) ?? bot;
|
|
126
|
+
return { mode: live.larkUserIdentity, userOpenId: live.larkUserOpenId };
|
|
127
|
+
},
|
|
128
|
+
logger,
|
|
129
|
+
});
|
|
130
|
+
larkCliInstances.set(botId, instance);
|
|
131
|
+
return instance;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 扫码接入用的 SDK 入口(`registerApp`)。
|
|
135
|
+
*
|
|
136
|
+
* 测试注入替身;真机走 SDK。**先确认它存在**:SDK 换版本后少这个方法时,
|
|
137
|
+
* 用户该看到"当前 SDK 不支持扫码创建",而不是一个 undefined is not a function。
|
|
138
|
+
*/
|
|
139
|
+
const registerApp = internals.registerApp ?? (async (options) => {
|
|
140
|
+
const sdk = await sdkLoader();
|
|
141
|
+
if (typeof sdk?.registerApp !== 'function') {
|
|
142
|
+
const error = new Error('当前 @larksuiteoapi/node-sdk 不支持扫码创建应用(缺 registerApp);'
|
|
143
|
+
+ '请改用「手动接入已有机器人」填 App ID 与 App Secret。');
|
|
144
|
+
error.code = 'feishu/register-unsupported';
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
return sdk.registerApp(options);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function startBot(bot) {
|
|
151
|
+
const existing = runtimes.get(bot.id);
|
|
152
|
+
if (existing?.phase === 'running' || existing?.phase === 'starting') return existing;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* **可变的一份运行期配置**:桥、状态、补丁都读它。
|
|
156
|
+
*
|
|
157
|
+
* 为什么不是直接用 `bot` 那个冻结对象:设置页/控制面板改完过程展示后会调
|
|
158
|
+
* `patchRuntime()`,早先那里是 `record.bot = { ...record.bot, ...patch }`——换了引用,
|
|
159
|
+
* 而桥在创建时已经把旧对象**闭包**进去了,于是"改了设置、群里的卡照旧"
|
|
160
|
+
* (真机现象:群聊已设「不显示过程」,回复仍带着工具与思考面板)。
|
|
161
|
+
* 现在桥拿到的就是这份可变的副本,`patchRuntime` 就地改它,改完立刻生效。
|
|
162
|
+
*/
|
|
163
|
+
const liveBot = { ...bot };
|
|
164
|
+
const record = {
|
|
165
|
+
bot: liveBot,
|
|
166
|
+
phase: 'starting',
|
|
167
|
+
error: null,
|
|
168
|
+
gateway: null,
|
|
169
|
+
bridge: null,
|
|
170
|
+
controller: new AbortController(),
|
|
171
|
+
};
|
|
172
|
+
runtimes.set(bot.id, record);
|
|
173
|
+
try {
|
|
174
|
+
const sdk = await sdkLoader();
|
|
175
|
+
const secret = await resolveSecret(deps.credentials, bot.secretRef);
|
|
176
|
+
const gateway = gatewayFactory({
|
|
177
|
+
appId: bot.appId,
|
|
178
|
+
appSecret: secret,
|
|
179
|
+
domain: bot.domain,
|
|
180
|
+
sdk,
|
|
181
|
+
logger,
|
|
182
|
+
connectTimeoutMs: config.connectTimeoutMs,
|
|
183
|
+
});
|
|
184
|
+
const state = createFeishuStateStore({
|
|
185
|
+
path: join(dataDir, 'bots', bot.id, 'state.json'),
|
|
186
|
+
logger,
|
|
187
|
+
});
|
|
188
|
+
await state.load();
|
|
189
|
+
record.state = state;
|
|
190
|
+
// 接管旧实现的会话绑定(不覆盖已有绑定),会话不丢。
|
|
191
|
+
if (deps.sessions?.bindings?.adopt) {
|
|
192
|
+
await deps.sessions.bindings.adopt(deps.channelId, bot.id, state.sessions());
|
|
193
|
+
}
|
|
194
|
+
const bridge = bridgeFactory({
|
|
195
|
+
bot: liveBot,
|
|
196
|
+
deps,
|
|
197
|
+
gateway,
|
|
198
|
+
state,
|
|
199
|
+
logger,
|
|
200
|
+
/**
|
|
201
|
+
* 会话标题里的"哪个群/哪个人":**复用这边的名字缓存**(含 10 分钟 TTL 与
|
|
202
|
+
* "缺权限"退避),桥自己不另开一套查询——否则设置页与标题会把同一批接口打两遍。
|
|
203
|
+
*/
|
|
204
|
+
resolveChatLabel: async ({ conversationType, senderId, chatId }) => {
|
|
205
|
+
if (conversationType === 'group') {
|
|
206
|
+
const cached = cacheFor(bot.id).chats.get(chatId);
|
|
207
|
+
if (cached) return cached;
|
|
208
|
+
await allChats(bot.id, { minIntervalMs: 60_000 });
|
|
209
|
+
return cacheFor(bot.id).chats.get(chatId) ?? null;
|
|
210
|
+
}
|
|
211
|
+
return (await userName(bot.id, senderId)) || null;
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
record.gateway = gateway;
|
|
215
|
+
record.bridge = bridge;
|
|
216
|
+
await gateway.connect({
|
|
217
|
+
onMessage: (event) => bridge.accept(event),
|
|
218
|
+
// 卡片按钮点击走这里:与文本回答共用同一条"认领"路径。
|
|
219
|
+
onCardAction: (event) => bridge.handleCardAction?.(event),
|
|
220
|
+
signal: record.controller.signal,
|
|
221
|
+
});
|
|
222
|
+
record.phase = 'running';
|
|
223
|
+
record.error = null;
|
|
224
|
+
logger.info?.(`[dsh-chat-feishu] ${bot.botName ?? bot.id} 长连接已就绪`);
|
|
225
|
+
/**
|
|
226
|
+
* 顺手把"这台机器人在 lark-cli 里那份 profile"解析并记住(懒建一次、幂等)。
|
|
227
|
+
*
|
|
228
|
+
* 为什么在这儿做:聊天会话里的 lark-cli 调用被门禁要求**必须**带这个 profile;
|
|
229
|
+
* profile 不存在时 lark-cli 会直接报 profile not found(模型会卡在"按要求写了参数
|
|
230
|
+
* 却跑不起来"),名字报错了模型写什么都不对——所以机器人一起来就把它备好、记准。
|
|
231
|
+
* 拿不到 secret / 没装 lark-cli 都只记一条日志,**绝不影响机器人启动**。
|
|
232
|
+
*/
|
|
233
|
+
await resolveProfileName(bot.id).catch((error) => {
|
|
234
|
+
const level = error?.code === 'feishu/lark-cli-missing' ? 'info' : 'warn';
|
|
235
|
+
logger[level]?.(`[dsh-chat-feishu] ${bot.id} 解析 lark-cli profile 失败:${error?.message ?? error}`);
|
|
236
|
+
return null;
|
|
237
|
+
});
|
|
238
|
+
} catch (error) {
|
|
239
|
+
record.phase = 'failed';
|
|
240
|
+
record.error = typeof error?.code === 'string' ? error.code : 'feishu/connect-failed';
|
|
241
|
+
record.errorMessage = error?.message ?? String(error);
|
|
242
|
+
logger.error?.(`[dsh-chat-feishu] ${bot.id} 启动失败:${record.errorMessage}`);
|
|
243
|
+
await stopBot(bot.id).catch(() => undefined);
|
|
244
|
+
record.phase = 'failed';
|
|
245
|
+
}
|
|
246
|
+
return record;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function stopBot(botId) {
|
|
250
|
+
const record = runtimes.get(botId);
|
|
251
|
+
if (!record) return;
|
|
252
|
+
record.controller?.abort?.();
|
|
253
|
+
try {
|
|
254
|
+
await record.gateway?.disconnect?.();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
logger.warn?.(`[dsh-chat-feishu] ${botId} 断开时报错:${error?.message ?? error}`);
|
|
257
|
+
}
|
|
258
|
+
// 去重集合是异步落盘的:停机前等它写完,避免重启后重复处理刚收过的消息。
|
|
259
|
+
try {
|
|
260
|
+
await record.state?.flush?.();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
logger.warn?.(`[dsh-chat-feishu] ${botId} 状态落盘失败:${error?.message ?? error}`);
|
|
263
|
+
}
|
|
264
|
+
record.bridge?.dispose?.();
|
|
265
|
+
record.gateway = null;
|
|
266
|
+
record.bridge = null;
|
|
267
|
+
if (record.phase !== 'failed') record.phase = 'stopped';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function botStatus(record) {
|
|
271
|
+
const { bot } = record;
|
|
272
|
+
const bridgeStatus = record.bridge?.status?.() ?? { handled: 0, lastError: null };
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
// 规范化字段(契约要求):hub 的机器人列表按这几个键渲染,渠道无关。
|
|
275
|
+
botId: bot.id,
|
|
276
|
+
name: bot.botName ?? null,
|
|
277
|
+
// 以下三个是飞书自己的补充信息。
|
|
278
|
+
id: bot.id,
|
|
279
|
+
appIdMasked: maskAppId(bot.appId),
|
|
280
|
+
domain: bot.domain,
|
|
281
|
+
state: record.phase,
|
|
282
|
+
error: record.error ?? null,
|
|
283
|
+
errorMessage: record.errorMessage ?? null,
|
|
284
|
+
connected: record.gateway?.isConnected?.() === true,
|
|
285
|
+
// 通配符(`*`)是"没有记录属主",不该算成一个属主——否则设置页显示"属主 1 人",
|
|
286
|
+
// 而实际上没有人能绕过访问策略。
|
|
287
|
+
ownerCount: bot.ownerOpenIds.filter((id) => id !== '*').length,
|
|
288
|
+
ownersWildcard: bot.ownerOpenIds.includes('*'),
|
|
289
|
+
// 设置页要显示"当前属主是谁",也要支持从会话里选人替换。
|
|
290
|
+
ownerOpenIds: Object.freeze([...bot.ownerOpenIds]),
|
|
291
|
+
groupResponseMode: bot.groupResponseMode,
|
|
292
|
+
groupTopicReply: bot.groupTopicReply,
|
|
293
|
+
stepPush: Object.freeze({ direct: bot.stepPushDirect, group: bot.stepPushGroup }),
|
|
294
|
+
// lark-cli 的身份策略(默认 bot-only);细节(profile / whoami)走 bot.lark-identity.get。
|
|
295
|
+
larkIdentity: Object.freeze({ mode: bot.larkUserIdentity, userOpenId: bot.larkUserOpenId }),
|
|
296
|
+
handled: bridgeStatus.handled,
|
|
297
|
+
lastHandledAt: bridgeStatus.lastHandledAt ?? null,
|
|
298
|
+
// 处理消息的失败必须能被设置页看到:终端日志之外,这是唯一的现场。
|
|
299
|
+
lastError: bridgeStatus.lastError ?? null,
|
|
300
|
+
// 名字解析失败(多为缺权限)也要能在界面上看到原因,而不是只显示一串 id。
|
|
301
|
+
nameHint: nameCache.get(bot.id)?.nameHint ?? null,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 解析(必要时创建)该机器人在 lark-cli 里的 profile,并记住它的**真实名字**。
|
|
307
|
+
*
|
|
308
|
+
* @param botId - 机器人 id。
|
|
309
|
+
* @returns profile 名;拿不到时抛错(调用方决定是记日志还是拒绝)。
|
|
310
|
+
*/
|
|
311
|
+
async function resolveProfileName(botId) {
|
|
312
|
+
const cached = profileNames.get(botId);
|
|
313
|
+
if (cached) return cached;
|
|
314
|
+
const cli = larkCliFor(botId);
|
|
315
|
+
if (!cli) throw new Error(`未找到机器人 ${botId},无法解析 lark-cli profile。`);
|
|
316
|
+
const profile = await cli.ensureProfile();
|
|
317
|
+
const name = typeof profile?.name === 'string' && profile.name ? profile.name : cli.profileName;
|
|
318
|
+
profileNames.set(botId, name);
|
|
319
|
+
return name;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 这个会话是不是本渠道的**聊天会话**,以及它属于哪台机器人。
|
|
324
|
+
*
|
|
325
|
+
* 门禁、会话环境事实、身份策略提示词都靠它——三处必须用同一份判据,
|
|
326
|
+
* 否则会出现"提示词说属主是 A、门禁按 B 判"这种自相矛盾。
|
|
327
|
+
*
|
|
328
|
+
* @param sessionId - DSH 会话 id。
|
|
329
|
+
* @returns { botId, botName, chatKey, mode, profileName } 或 null(不是聊天会话)。
|
|
330
|
+
*/
|
|
331
|
+
function chatOwnership(sessionId) {
|
|
332
|
+
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
333
|
+
const locate = deps.sessions?.bindings?.locate;
|
|
334
|
+
if (typeof locate !== 'function') return null;
|
|
335
|
+
const located = locate(sessionId);
|
|
336
|
+
if (!located || located.channelId !== deps.channelId) return null;
|
|
337
|
+
// 同步读:系统提示词段的 text 只能是同步函数(`dsh-system-prompt` 的类型就是同步)。
|
|
338
|
+
// 配置还没落盘就绪时返回 null——这一轮不渲染,下一轮自然就有了。
|
|
339
|
+
const bot = runtimes.get(located.botId)?.bot ?? configStore.get(located.botId);
|
|
340
|
+
if (!bot) return null;
|
|
341
|
+
return Object.freeze({
|
|
342
|
+
botId: bot.id,
|
|
343
|
+
botName: bot.botName ?? null,
|
|
344
|
+
chatKey: located.key,
|
|
345
|
+
mode: normalizeLarkUserIdentity(bot.larkUserIdentity),
|
|
346
|
+
// 还没解析出来时为 null:提示词段会退化成"用 profile list 查 appId 对应的那份",
|
|
347
|
+
// 而不是编一个不存在的名字(编错了模型写什么都不对)。
|
|
348
|
+
profileName: profileNames.get(bot.id) ?? null,
|
|
349
|
+
appId: bot.appId,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 某台机器人的 lark-cli 身份策略(门禁用;读运行期那份,改设置立刻生效)。 */
|
|
354
|
+
async function larkPolicyFor(botId) {
|
|
355
|
+
await configStore.load();
|
|
356
|
+
const bot = runtimes.get(botId)?.bot ?? configStore.get(botId);
|
|
357
|
+
if (!bot) return null;
|
|
358
|
+
let profileName = profileNames.get(bot.id) ?? null;
|
|
359
|
+
if (!profileName) {
|
|
360
|
+
// 门禁要报出真实 profile 名才有意义:这里现解析一次(拿不到就让门禁放行 + 留日志)。
|
|
361
|
+
profileName = await resolveProfileName(bot.id).catch((error) => {
|
|
362
|
+
logger.warn?.(`[dsh-chat-feishu] 解析 ${bot.id} 的 lark-cli profile 失败,门禁暂时不生效:`
|
|
363
|
+
+ `${error?.message ?? error}`);
|
|
364
|
+
return null;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return { mode: normalizeLarkUserIdentity(bot.larkUserIdentity), profileName };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 聊天会话里的 lark-cli 门禁:让「身份策略」对**模型自己跑的** lark-cli 也有约束力。
|
|
372
|
+
*
|
|
373
|
+
* 真机踩过:模型用 lark-cli 的 skill + bash 直接发消息,绕过本插件的 `host/lark-cli.mjs`,
|
|
374
|
+
* 于是"只用应用身份"这个开关对它毫无作用(第二条消息照样以用户身份发出去了)。
|
|
375
|
+
*/
|
|
376
|
+
const larkGuard = createLarkCliGuard({
|
|
377
|
+
locate: (sessionId) => deps.sessions?.bindings?.locate?.(sessionId),
|
|
378
|
+
policyFor: larkPolicyFor,
|
|
379
|
+
channelId: deps.channelId,
|
|
380
|
+
logger,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
async function status() {
|
|
384
|
+
await configStore.load();
|
|
385
|
+
const bots = configStore.list();
|
|
386
|
+
const known = new Set(bots.map((bot) => bot.id));
|
|
387
|
+
for (const botId of [...runtimes.keys()]) if (!known.has(botId)) await stopBot(botId);
|
|
388
|
+
return Object.freeze({
|
|
389
|
+
channel: deps.channelId,
|
|
390
|
+
dataDir,
|
|
391
|
+
bots: Object.freeze(bots.map((bot) => botStatus(
|
|
392
|
+
runtimes.get(bot.id) ?? { bot, phase: 'stopped', error: null, bridge: null },
|
|
393
|
+
))),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 更新运行中机器人的本地配置(保存后立即生效,不需要重连)。 */
|
|
398
|
+
/**
|
|
399
|
+
* 任务过程展示的三态(与设置页同一份文案)。
|
|
400
|
+
*
|
|
401
|
+
* 渠道把它作为「面板字段」交给 hub:hub 不认识"过程展示",只负责画一行下拉、
|
|
402
|
+
* 把选择透传回这里的 `panel.apply`。
|
|
403
|
+
*/
|
|
404
|
+
const STEP_PUSH_FIELD_OPTIONS = Object.freeze([
|
|
405
|
+
{ value: 'off', label: '不显示过程(只回最终答案)' },
|
|
406
|
+
{ value: 'streaming_card', label: '实时过程卡(一张卡动态更新)' },
|
|
407
|
+
{ value: 'post', label: '逐步直播(每步一条消息)' },
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 过程展示是"按会话类型"存的两份设置(私聊 / 群聊),卡片上**两份都列出来**:
|
|
412
|
+
* 只看当前会话类型那一份时,用户在群里想改私聊的展示方式就得先回私聊发一次 `/menu`。
|
|
413
|
+
*/
|
|
414
|
+
const STEP_PUSH_FIELDS = Object.freeze([
|
|
415
|
+
{ field: 'stepPushDirect', scope: 'direct', label: '任务过程展示(私聊)' },
|
|
416
|
+
{ field: 'stepPushGroup', scope: 'group', label: '任务过程展示(群聊)' },
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
/** 卡片字段名 → 该改哪一份;旧的会话类型字段名(stepPush)也认,保持兼容。 */
|
|
420
|
+
function stepPushTarget(fieldName, conversationType) {
|
|
421
|
+
const hit = STEP_PUSH_FIELDS.find((item) => item.field === fieldName);
|
|
422
|
+
if (hit) return hit;
|
|
423
|
+
if (fieldName !== 'stepPush') return null;
|
|
424
|
+
return conversationType === 'group'
|
|
425
|
+
? STEP_PUSH_FIELDS[1]
|
|
426
|
+
: STEP_PUSH_FIELDS[0];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 过程展示的两份当前值(面板字段用)。 */
|
|
430
|
+
function stepPushValues(bot) {
|
|
431
|
+
return { direct: bot.stepPushDirect, group: bot.stepPushGroup };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 就地改运行期那份 bot 配置。
|
|
436
|
+
*
|
|
437
|
+
* **必须就地改**:桥、状态这些读者都持有同一个对象(见 `startBot` 里的 `liveBot`),
|
|
438
|
+
* 换成新引用等于它们全都看不到——过程展示这类"改完要立刻生效"的设置就会静默失效。
|
|
439
|
+
*/
|
|
440
|
+
function patchRuntime(botId, patch) {
|
|
441
|
+
const record = runtimes.get(botId);
|
|
442
|
+
if (record) Object.assign(record.bot, patch);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function startAll() {
|
|
446
|
+
await configStore.load();
|
|
447
|
+
const bots = configStore.list();
|
|
448
|
+
logger.info?.(`[dsh-chat-feishu] 发现 ${bots.length} 个已配置机器人`);
|
|
449
|
+
await Promise.all(bots.map((bot) => startBot(bot)));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 属主 id 的合法形状:该应用下的 open_id,或通配符(= 没有属主)。 */
|
|
453
|
+
const OWNER_ID_PATTERN = /^(\*|ou_[A-Za-z0-9_-]{1,64})$/;
|
|
454
|
+
/** 一台机器人的属主上限(属主绕过所有策略,不该是个长名单)。 */
|
|
455
|
+
const MAX_OWNERS = 10;
|
|
456
|
+
/** 一次最多换多少个 id 的名字(每个都要打一次通讯录/群接口)。 */
|
|
457
|
+
const MAX_RESOLVE_IDS = 50;
|
|
458
|
+
/** 走"共同群成员"这条退路时最多翻几个群(成员接口是一群一次)。 */
|
|
459
|
+
const MAX_MEMBER_CHATS = 20;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 会话键 → 可投递目标(`p2p:ou_x` → 私聊,`group:oc_y` → 群聊)。
|
|
463
|
+
*
|
|
464
|
+
* 同一份翻译两处用:`discover`(运行时状态 / 群列表)与 `targetFromKey`
|
|
465
|
+
* (hub 的**持久**会话绑定表)。只做前者的话,重启后运行时是空的,
|
|
466
|
+
* 设置页就一个可添加的候选都没有。
|
|
467
|
+
*
|
|
468
|
+
* 这里的名字只是**兜底**(掩码后的 id);能拿到真名的场合由 `decorateTargets` 覆盖。
|
|
469
|
+
*/
|
|
470
|
+
const ids = (value) => value.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
|
|
471
|
+
const targetFor = (kind, rawId, name) => ({
|
|
472
|
+
id: ids(`${kind}:${rawId}`),
|
|
473
|
+
name,
|
|
474
|
+
kind: kind === 'group' ? 'group' : 'direct',
|
|
475
|
+
route: kind === 'group' ? { chatId: rawId } : { openId: rawId },
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
function targetFromKey(key) {
|
|
479
|
+
const [kind, rawId] = String(key ?? '').split(':', 2);
|
|
480
|
+
if (!rawId) return null;
|
|
481
|
+
if (kind === 'p2p') return targetFor('p2p', rawId, `私聊 · ${maskAppId(rawId)}`);
|
|
482
|
+
if (kind === 'group') return targetFor('group', rawId, `群聊 · ${maskAppId(rawId)}`);
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** 运行时状态里出现过的会话 → 候选目标。 */
|
|
487
|
+
function targetsFromState(state) {
|
|
488
|
+
return Object.keys(state?.sessions?.() ?? {})
|
|
489
|
+
.map((key) => targetFromKey(key))
|
|
490
|
+
.filter(Boolean);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 名字缓存(群名 / 人名)。
|
|
495
|
+
*
|
|
496
|
+
* 这些名字变得很慢,而每次打开设置页都会走一遍 `delivery.list`;不缓存就会把
|
|
497
|
+
* 飞书接口打成筛子(`chat.list` 还有每秒 5 次的频率上限)。失败也记时间戳,
|
|
498
|
+
* 免得没权限的机器人在每次刷新时反复重试。
|
|
499
|
+
*/
|
|
500
|
+
const NAME_TTL_MS = 10 * 60_000;
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* "缺权限"这类失败的退避间隔。
|
|
504
|
+
*
|
|
505
|
+
* 权限要人去开放平台点,重试再密也不会自己好;而每重试一次,SDK 就往日志里写一次
|
|
506
|
+
* 整个 axios 对象(一次几 KB),把真正的现场淹掉——真机上刷屏的就是这里。
|
|
507
|
+
* 开通权限后点一下「重新连接」即可立即重取,不必等这个窗口。
|
|
508
|
+
*/
|
|
509
|
+
const SCOPE_MISSING_TTL_MS = 30 * 60_000;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 把"名字拿不到"的原因压成一句能显示的话 + 开通链接。
|
|
513
|
+
*
|
|
514
|
+
* 权限没开通时飞书会把开通地址写在错误里(`https://open.feishu.cn/app/<appId>/auth?q=…`),
|
|
515
|
+
* 直接把它带给用户比"你自己去开放平台找"有用得多——否则界面上只能看到一串 oc_xxx,
|
|
516
|
+
* 原因却只在日志里(违反"失败必须可见")。
|
|
517
|
+
*/
|
|
518
|
+
function nameHintFrom(error, fallback) {
|
|
519
|
+
const message = String(error?.message ?? error ?? '');
|
|
520
|
+
const url = /https:\/\/open\.feishu\.cn\/app\/[^\s,]+/u.exec(message)?.[0] ?? null;
|
|
521
|
+
const scopeMissing = isScopeMissing(error);
|
|
522
|
+
return Object.freeze({
|
|
523
|
+
code: scopeMissing ? 'feishu/scope-missing' : 'feishu/name-failed',
|
|
524
|
+
message: scopeMissing
|
|
525
|
+
? `${fallback}:飞书应用还没开通对应权限,所以只能显示 id。开通后点「重新连接」立刻生效。`
|
|
526
|
+
: `${fallback}:${message.slice(0, 160)}`,
|
|
527
|
+
url,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function isScopeMissing(error) {
|
|
532
|
+
return /Access denied|99991672/u.test(String(error?.message ?? error ?? ''));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** 失败后这个方向多久不再重试:缺权限长退避,其它错误照旧(下次刷新就重试)。 */
|
|
536
|
+
function backoffMs(error) {
|
|
537
|
+
return isScopeMissing(error) ? SCOPE_MISSING_TTL_MS : 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const nameCache = new Map(); // botId → { chats, chatsAt, chatsBlockMs, users, usersAt, usersBlockMs, members, membersAt, membersBlockMs, nameHint }
|
|
541
|
+
|
|
542
|
+
function cacheFor(botId) {
|
|
543
|
+
let entry = nameCache.get(botId);
|
|
544
|
+
if (!entry) {
|
|
545
|
+
entry = {
|
|
546
|
+
chats: new Map(), chatsAt: 0, chatsBlockMs: 0, chatsPromise: null,
|
|
547
|
+
users: new Map(), usersAt: 0, usersBlockMs: 0, userPromises: new Map(), userError: null,
|
|
548
|
+
members: new Map(), membersAt: 0, membersBlockMs: 0, membersPromise: null,
|
|
549
|
+
nameHint: null,
|
|
550
|
+
};
|
|
551
|
+
nameCache.set(botId, entry);
|
|
552
|
+
}
|
|
553
|
+
return entry;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** 手动重连 = 用户可能刚去开放平台开了权限:名字缓存连同"缺权限"的退避一起清掉。 */
|
|
557
|
+
function resetNameCache(botId) {
|
|
558
|
+
nameCache.delete(botId);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function chatEntries(cache) {
|
|
562
|
+
return [...cache.chats.entries()].map(([chatId, name]) => ({ chatId, name }));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 群列表(带群名);拿不到就返回空表并记日志,不抛。
|
|
567
|
+
*
|
|
568
|
+
* `minIntervalMs` 是最短重取间隔:正常走 `NAME_TTL_MS`,遇到"缓存里没有的群"时
|
|
569
|
+
* 传一个更短的值(至少间隔一分钟),免得一个查不到的群 id 把接口打成筛子
|
|
570
|
+
* (`chat.list` 有每秒 5 次的频率上限)。失败也会更新时间戳,避免反复重试;
|
|
571
|
+
* 同一瞬间的并发调用(打开设置页时 `delivery.list` 会并发问几次)也只发一次请求。
|
|
572
|
+
*/
|
|
573
|
+
async function allChats(botId, { minIntervalMs = NAME_TTL_MS } = {}) {
|
|
574
|
+
const record = runtimes.get(botId);
|
|
575
|
+
if (!record?.gateway) return [];
|
|
576
|
+
const cache = cacheFor(botId);
|
|
577
|
+
if (Date.now() - cache.chatsAt < Math.max(minIntervalMs, cache.chatsBlockMs)) {
|
|
578
|
+
return chatEntries(cache);
|
|
579
|
+
}
|
|
580
|
+
if (cache.chatsPromise) return cache.chatsPromise;
|
|
581
|
+
cache.chatsPromise = (async () => {
|
|
582
|
+
try {
|
|
583
|
+
const chats = await record.gateway.listChats();
|
|
584
|
+
cache.chats = new Map(chats.map((chat) => [chat.chatId, chat.name]));
|
|
585
|
+
cache.chatsBlockMs = 0;
|
|
586
|
+
return chats;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
cache.nameHint = nameHintFrom(error, '读不到群名');
|
|
589
|
+
cache.chatsBlockMs = backoffMs(error);
|
|
590
|
+
logger.warn?.(`[dsh-chat-feishu] 读取群列表失败,群名将退回 id:${error?.message ?? error}`);
|
|
591
|
+
return [];
|
|
592
|
+
} finally {
|
|
593
|
+
cache.chatsAt = Date.now();
|
|
594
|
+
cache.chatsPromise = null;
|
|
595
|
+
}
|
|
596
|
+
})();
|
|
597
|
+
return cache.chatsPromise;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 补一个人名;拿不到就留空。同一个人的并发查询合成一次。 */
|
|
601
|
+
async function userName(botId, openId) {
|
|
602
|
+
const record = runtimes.get(botId);
|
|
603
|
+
if (!record?.gateway || !openId) return '';
|
|
604
|
+
const cache = cacheFor(botId);
|
|
605
|
+
// 缺权限期间不再逐人重试:换个人也照样拿不到,只会把日志刷满。
|
|
606
|
+
if (cache.usersBlockMs > 0 && Date.now() - cache.usersAt < cache.usersBlockMs) {
|
|
607
|
+
return cache.users.get(openId) ?? '';
|
|
608
|
+
}
|
|
609
|
+
const hit = cache.users.get(openId);
|
|
610
|
+
if (hit !== undefined && Date.now() - cache.usersAt < NAME_TTL_MS) return hit;
|
|
611
|
+
const pending = cache.userPromises.get(openId);
|
|
612
|
+
if (pending) return pending;
|
|
613
|
+
const task = (async () => {
|
|
614
|
+
try {
|
|
615
|
+
const name = await record.gateway.getUserName(openId);
|
|
616
|
+
cache.users.set(openId, name);
|
|
617
|
+
cache.usersBlockMs = 0;
|
|
618
|
+
return name;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
cache.nameHint = nameHintFrom(error, '读不到人名');
|
|
621
|
+
cache.userError = error;
|
|
622
|
+
cache.usersBlockMs = backoffMs(error);
|
|
623
|
+
logger.warn?.(`[dsh-chat-feishu] 读取用户信息失败,人名将退回 id:${error?.message ?? error}`);
|
|
624
|
+
cache.users.set(openId, '');
|
|
625
|
+
return '';
|
|
626
|
+
} finally {
|
|
627
|
+
cache.usersAt = Date.now();
|
|
628
|
+
cache.userPromises.delete(openId);
|
|
629
|
+
}
|
|
630
|
+
})();
|
|
631
|
+
cache.userPromises.set(openId, task);
|
|
632
|
+
return task;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 从"机器人所在的群"里查成员名字——**没有通讯录权限时的退路**。
|
|
637
|
+
*
|
|
638
|
+
* 真机上常见的是 `no user authority error (code 41050)`:应用没有通讯录权限,
|
|
639
|
+
* 或者这个人不在应用的可见范围里。但"读群成员"要的是 `im:chat:readonly` 系权限
|
|
640
|
+
* (读群列表本来就要它),所以只要这个人和机器人同群,名字就还能换出来。
|
|
641
|
+
* 整张表按 `NAME_TTL_MS` 缓存:群成员变得很慢,而设置页每次刷新都会问一遍。
|
|
642
|
+
*/
|
|
643
|
+
async function memberNames(botId) {
|
|
644
|
+
const record = runtimes.get(botId);
|
|
645
|
+
if (!record?.gateway) return new Map();
|
|
646
|
+
const cache = cacheFor(botId);
|
|
647
|
+
if (Date.now() - cache.membersAt < Math.max(NAME_TTL_MS, cache.membersBlockMs)) {
|
|
648
|
+
return cache.members;
|
|
649
|
+
}
|
|
650
|
+
if (cache.membersPromise) return cache.membersPromise;
|
|
651
|
+
cache.membersPromise = (async () => {
|
|
652
|
+
const map = new Map();
|
|
653
|
+
try {
|
|
654
|
+
const chats = await allChats(botId);
|
|
655
|
+
for (const chat of chats.slice(0, MAX_MEMBER_CHATS)) {
|
|
656
|
+
const members = await record.gateway.listChatMembers({ chatId: chat.chatId });
|
|
657
|
+
for (const member of members) {
|
|
658
|
+
if (member.name && !map.has(member.openId)) map.set(member.openId, member.name);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
cache.members = map;
|
|
662
|
+
cache.membersBlockMs = 0;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
// 失败也退避一个 TTL:换个群照样会失败,不该每次刷新都逐群重试。
|
|
665
|
+
cache.membersBlockMs = backoffMs(error) || NAME_TTL_MS;
|
|
666
|
+
logger.warn?.(`[dsh-chat-feishu] 读取群成员失败,人名将退回 id:${error?.message ?? error}`);
|
|
667
|
+
} finally {
|
|
668
|
+
cache.membersAt = Date.now();
|
|
669
|
+
cache.membersPromise = null;
|
|
670
|
+
}
|
|
671
|
+
return cache.members;
|
|
672
|
+
})();
|
|
673
|
+
return cache.membersPromise;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 人名换不到时的说明:**必须能指导用户去做一件事**。
|
|
678
|
+
*
|
|
679
|
+
* `no user authority error (code 41050)` 这种原始报错本身完全无法行动,
|
|
680
|
+
* 所以补上要开通哪个权限、以及这台应用的开通链接。
|
|
681
|
+
*/
|
|
682
|
+
function personNameHint(botId, error) {
|
|
683
|
+
const bot = configStore.get(botId);
|
|
684
|
+
const fromError = /https:\/\/open\.feishu\.cn\/app\/[^\s,]+/u.exec(String(error?.message ?? ''))?.[0] ?? null;
|
|
685
|
+
return Object.freeze({
|
|
686
|
+
code: 'feishu/name-scope-missing',
|
|
687
|
+
message: '读不到人名:这个飞书应用需要「通讯录 · 获取用户基本信息」权限,并且该用户要在应用的'
|
|
688
|
+
+ '通讯录可见范围里(或者让他和机器人待在同一个群,群成员名单也能换出名字)。'
|
|
689
|
+
+ '到开放平台开通后点「重新连接」立刻重取。',
|
|
690
|
+
url: fromError ?? (bot?.appId ? `https://open.feishu.cn/app/${bot.appId}/auth?q=contact:user.base:readonly` : null),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* 平台 id → 名字(`oc_` 群查群名,`ou_` 人查人名);换不到就返回 null。
|
|
696
|
+
*
|
|
697
|
+
* 为什么要它:访问策略的白名单里存的只有平台 id,设置页上就是一排
|
|
698
|
+
* `ou_4f6a8c0e2b1d9753…`——认不出是谁、也看不出加错了人(真机反馈
|
|
699
|
+
* "群了白名单 只显示id不显示名称,不方便管理")。
|
|
700
|
+
* 人名的顺序是**通讯录 → 共同群成员**:前者更准但常缺权限,后者要的权限读群列表时本来就有。
|
|
701
|
+
*/
|
|
702
|
+
async function resolveName(botId, id) {
|
|
703
|
+
if (id.startsWith('oc_')) {
|
|
704
|
+
const cached = cacheFor(botId).chats.get(id);
|
|
705
|
+
if (cached) return cached;
|
|
706
|
+
// 缓存里没有这个群(刚被拉进去 / 还没列过):以更短的间隔重取一次群列表。
|
|
707
|
+
await allChats(botId, { minIntervalMs: 60_000 });
|
|
708
|
+
return cacheFor(botId).chats.get(id) ?? null;
|
|
709
|
+
}
|
|
710
|
+
const direct = await userName(botId, id);
|
|
711
|
+
if (direct) return direct;
|
|
712
|
+
const fromGroup = (await memberNames(botId)).get(id);
|
|
713
|
+
if (fromGroup) return fromGroup;
|
|
714
|
+
// 两条路都没换到:留一条能行动的说明(缺权限 / 不在可见范围 / 没和机器人同群)。
|
|
715
|
+
const cache = cacheFor(botId);
|
|
716
|
+
cache.nameHint = personNameHint(botId, cache.userError);
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const delivery = Object.freeze({
|
|
721
|
+
/** 主动发文本:群用 chat_id,私聊用用户的 open_id。 */
|
|
722
|
+
async send({ botId, target, text }) {
|
|
723
|
+
const record = runtimes.get(botId);
|
|
724
|
+
if (!record?.gateway || record.phase !== 'running') {
|
|
725
|
+
const error = new Error(`机器人 ${botId} 当前不在线,无法投递。`);
|
|
726
|
+
error.code = 'feishu/bot-offline';
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
const { chatId, openId } = target.route ?? {};
|
|
730
|
+
if (!chatId && !openId) {
|
|
731
|
+
const error = new Error('投递目标的 route 既没有 chatId 也没有 openId。');
|
|
732
|
+
error.code = 'chat/bad-target';
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
return record.gateway.sendText({ chatId, openId, text });
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 主动发文件/图片:图片走 image 消息(有预览),其余走 file 消息。
|
|
740
|
+
*
|
|
741
|
+
* @param options - { botId, target, file: { path, name, size, kind } }。
|
|
742
|
+
*/
|
|
743
|
+
async sendFile({ botId, target, file }) {
|
|
744
|
+
const record = runtimes.get(botId);
|
|
745
|
+
if (!record?.gateway || record.phase !== 'running') {
|
|
746
|
+
const error = new Error(`机器人 ${botId} 当前不在线,无法投递。`);
|
|
747
|
+
error.code = 'feishu/bot-offline';
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
const { chatId, openId } = target.route ?? {};
|
|
751
|
+
if (!chatId && !openId) {
|
|
752
|
+
const error = new Error('投递目标的 route 既没有 chatId 也没有 openId。');
|
|
753
|
+
error.code = 'chat/bad-target';
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
if (file?.kind === 'image') {
|
|
757
|
+
const sent = await record.gateway.sendImage({ chatId, openId, path: file.path });
|
|
758
|
+
return { ...sent, name: file.name, size: file.size, kind: 'image' };
|
|
759
|
+
}
|
|
760
|
+
const sent = await record.gateway.sendFile({
|
|
761
|
+
chatId, openId, path: file.path, name: file.name,
|
|
762
|
+
});
|
|
763
|
+
return { ...sent, kind: 'file' };
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* 候选目标:机器人**所在的全部群** + 运行时聊过的会话。
|
|
768
|
+
*
|
|
769
|
+
* 群列表来自飞书接口,因此"刚被拉进群、还没说过话"的群也能作为候选被添加;
|
|
770
|
+
* 拿不到权限时退回运行时状态(跟以前一样)。
|
|
771
|
+
*/
|
|
772
|
+
async discover({ botId }) {
|
|
773
|
+
const record = runtimes.get(botId);
|
|
774
|
+
const chats = await allChats(botId);
|
|
775
|
+
const groupTargets = chats.map((chat) => targetFor(
|
|
776
|
+
'group',
|
|
777
|
+
chat.chatId,
|
|
778
|
+
chat.name || `群聊 · ${maskAppId(chat.chatId)}`,
|
|
779
|
+
));
|
|
780
|
+
const sessionTargets = record?.state ? targetsFromState(record.state) : [];
|
|
781
|
+
return [...groupTargets, ...sessionTargets];
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
/** 把 hub 持久会话绑定表里的会话键翻成目标(重启后仍有候选)。 */
|
|
785
|
+
targetFromKey,
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 给目标补上**人能认出的名字**(群名 / 人名)。
|
|
789
|
+
*
|
|
790
|
+
* 只在渠道里做:hub 不认平台概念。补不到就保持原样(掩码 id)——
|
|
791
|
+
* 权限没开通的机器人不该因为"名字拿不到"就看不到目标。
|
|
792
|
+
*/
|
|
793
|
+
async decorateTargets({ botId, targets }) {
|
|
794
|
+
const list = Array.isArray(targets) ? targets : [];
|
|
795
|
+
if (list.length === 0) return list;
|
|
796
|
+
// 群名查不到的(例如刚从会话绑定表来的群)先重取一次群列表,最短间隔一分钟。
|
|
797
|
+
if (list.some((target) => target?.kind === 'group'
|
|
798
|
+
&& !cacheFor(botId).chats.has(target.route?.chatId))) {
|
|
799
|
+
await allChats(botId, { minIntervalMs: 60_000 });
|
|
800
|
+
}
|
|
801
|
+
const chats = cacheFor(botId).chats;
|
|
802
|
+
const decorated = [];
|
|
803
|
+
for (const target of list) {
|
|
804
|
+
if (target?.kind === 'group') {
|
|
805
|
+
const name = chats.get(target.route?.chatId);
|
|
806
|
+
decorated.push(name ? { ...target, name } : target);
|
|
807
|
+
} else if (target?.kind === 'direct') {
|
|
808
|
+
const name = await userName(botId, target.route?.openId);
|
|
809
|
+
decorated.push(name ? { ...target, name } : target);
|
|
810
|
+
} else {
|
|
811
|
+
decorated.push(target);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return decorated;
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* 把一组**已验证**的凭据落成一只机器人并起长连接(`bot.add` 与扫码接入共用)。
|
|
820
|
+
*
|
|
821
|
+
* 顺序:写 DSH 凭据服务 → 写渠道 `config.json` → 起长连接。
|
|
822
|
+
* 落盘失败要把刚写的凭据删掉(不留一个谁也读不到的引用);
|
|
823
|
+
* 长连接起不来**不算没加上**(配置已在,状态里会显示 failed + 原因)。
|
|
824
|
+
*
|
|
825
|
+
* @returns `{ saved, record }`。
|
|
826
|
+
*/
|
|
827
|
+
async function storeAndStart({ appId, appSecret, domain, ownerOpenIds, botName, botOpenId }) {
|
|
828
|
+
const { botId, secretRef } = deriveFeishuIdentity(appId);
|
|
829
|
+
try {
|
|
830
|
+
await deps.credentials.set(secretRef, appSecret);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
const wrapped = new Error(`保存 App Secret 失败:${error?.message ?? error}`);
|
|
833
|
+
wrapped.code = 'feishu/credential-write-failed';
|
|
834
|
+
throw wrapped;
|
|
835
|
+
}
|
|
836
|
+
let saved;
|
|
837
|
+
try {
|
|
838
|
+
saved = await configStore.saveBot({
|
|
839
|
+
id: botId,
|
|
840
|
+
appId,
|
|
841
|
+
secretRef,
|
|
842
|
+
domain,
|
|
843
|
+
ownerOpenIds,
|
|
844
|
+
botName: botName ?? null,
|
|
845
|
+
botOpenId: botOpenId ?? null,
|
|
846
|
+
createdAt: new Date().toISOString(),
|
|
847
|
+
});
|
|
848
|
+
} catch (error) {
|
|
849
|
+
try {
|
|
850
|
+
await deps.credentials.unset?.(secretRef);
|
|
851
|
+
} catch (cleanupError) {
|
|
852
|
+
logger.warn?.(`[dsh-chat-feishu] 回滚凭据 ${secretRef} 失败:${cleanupError?.message ?? cleanupError}`);
|
|
853
|
+
}
|
|
854
|
+
const wrapped = new Error(`写入机器人配置失败:${error?.message ?? error}`);
|
|
855
|
+
wrapped.code = 'feishu/bot-save-failed';
|
|
856
|
+
throw wrapped;
|
|
857
|
+
}
|
|
858
|
+
logger.info?.(`[dsh-chat-feishu] 接入机器人:${saved.botName ?? saved.id}`
|
|
859
|
+
+ `(appId=${maskAppId(saved.appId)} 属主=${saved.ownerOpenIds.join('、')})`);
|
|
860
|
+
const record = await startBot(saved);
|
|
861
|
+
return { saved, record };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* 二维码缓存:同一个授权链接只编码一次(`status` 会被前端每 2 秒轮一次)。
|
|
866
|
+
*/
|
|
867
|
+
const qrCache = new Map();
|
|
868
|
+
let qrModule;
|
|
869
|
+
/**
|
|
870
|
+
* 把授权链接转成 data URL。
|
|
871
|
+
*
|
|
872
|
+
* `qrcode` 是构建期外置依赖(由 profile 的 node_modules 提供)。**拿不到就返回 null**:
|
|
873
|
+
* 前端会退回"打开链接/复制链接",绝不因为缺一个可选依赖就让这条路走不通。
|
|
874
|
+
*/
|
|
875
|
+
async function encodeQrDataUrl(url) {
|
|
876
|
+
if (qrCache.has(url)) return qrCache.get(url);
|
|
877
|
+
if (qrModule === undefined) {
|
|
878
|
+
try {
|
|
879
|
+
const loaded = await import('qrcode');
|
|
880
|
+
qrModule = loaded?.default ?? loaded;
|
|
881
|
+
} catch (error) {
|
|
882
|
+
qrModule = null;
|
|
883
|
+
logger.warn?.('[dsh-chat-feishu] 没能加载 qrcode,二维码将只给链接:'
|
|
884
|
+
+ `${error?.message ?? error}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
let dataUrl = null;
|
|
888
|
+
if (typeof qrModule?.toDataURL === 'function') {
|
|
889
|
+
try {
|
|
890
|
+
dataUrl = await qrModule.toDataURL(url, { errorCorrectionLevel: 'M', margin: 1, width: 320 });
|
|
891
|
+
} catch (error) {
|
|
892
|
+
logger.warn?.(`[dsh-chat-feishu] 生成二维码失败,将只给链接:${error?.message ?? error}`);
|
|
893
|
+
dataUrl = null;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (qrCache.size >= 8) qrCache.delete(qrCache.keys().next().value);
|
|
897
|
+
qrCache.set(url, dataUrl);
|
|
898
|
+
return dataUrl;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** 扫码接入的对外状态:二维码 data URL + 链接 + 剩余秒数 + 成功后的机器人。 */
|
|
902
|
+
async function provisionStatus(raw) {
|
|
903
|
+
const url = typeof raw?.qrCodeUrl === 'string' && /^https?:\/\//u.test(raw.qrCodeUrl) ? raw.qrCodeUrl : null;
|
|
904
|
+
const bot = raw?.bot?.botId ? provisionBotStatus(raw.bot.botId) : null;
|
|
905
|
+
return Object.freeze({
|
|
906
|
+
state: raw?.state ?? 'idle',
|
|
907
|
+
attempt: raw?.attempt ?? 0,
|
|
908
|
+
verificationUrl: url,
|
|
909
|
+
qrCodeDataUrl: url
|
|
910
|
+
? await (internals.encodeQr ?? encodeQrDataUrl)(url)
|
|
911
|
+
: null,
|
|
912
|
+
remainingSeconds: raw?.remainingSeconds ?? null,
|
|
913
|
+
error: raw?.error ? Object.freeze({ ...raw.error }) : null,
|
|
914
|
+
bot,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** 扫码接入成功后的机器人状态(读配置 + 运行时,与 `connection.status` 同一份形状)。 */
|
|
919
|
+
function provisionBotStatus(botId) {
|
|
920
|
+
const bot = configStore.get(botId);
|
|
921
|
+
if (!bot) return null;
|
|
922
|
+
return botStatus(runtimes.get(botId) ?? { bot, phase: 'stopped', error: null, bridge: null });
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const provision = createProvisionManager({
|
|
926
|
+
registerApp,
|
|
927
|
+
logger,
|
|
928
|
+
onCredentials: async ({ appId, appSecret, userInfo }) => {
|
|
929
|
+
// 扫码的人就是属主(飞书在这个回调里把人一起给回来);域名跟着租户品牌走。
|
|
930
|
+
const domain = userInfo?.tenant_brand === 'lark' ? 'lark' : 'feishu';
|
|
931
|
+
const ownerOpenIds = typeof userInfo?.open_id === 'string' && userInfo.open_id
|
|
932
|
+
? [userInfo.open_id]
|
|
933
|
+
: ['*'];
|
|
934
|
+
const info = await probeFactory({ appId, appSecret, domain, sdk: await sdkLoader(), logger }).verify();
|
|
935
|
+
const { saved, record } = await storeAndStart({
|
|
936
|
+
appId,
|
|
937
|
+
appSecret,
|
|
938
|
+
domain,
|
|
939
|
+
ownerOpenIds,
|
|
940
|
+
botName: info?.botName ?? null,
|
|
941
|
+
botOpenId: info?.botOpenId ?? null,
|
|
942
|
+
});
|
|
943
|
+
return { botId: saved.id, name: record?.bot?.botName ?? saved.botName ?? null };
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
return Object.freeze({
|
|
948
|
+
start: startAll,
|
|
949
|
+
delivery,
|
|
950
|
+
async stop() {
|
|
951
|
+
provision.dispose();
|
|
952
|
+
await Promise.all([...runtimes.keys()].map((botId) => stopBot(botId)));
|
|
953
|
+
},
|
|
954
|
+
status,
|
|
955
|
+
configStore,
|
|
956
|
+
/** 会话归属(不含则不是聊天会话):门禁、会话环境事实、提示词段共用。 */
|
|
957
|
+
chatOwnership,
|
|
958
|
+
/** lark-cli 门禁:接在 `tools/pre-execute` 上。 */
|
|
959
|
+
larkGuard,
|
|
960
|
+
|
|
961
|
+
endpoints: Object.freeze({
|
|
962
|
+
'connection.status': async () => ({ ok: true, value: await status() }),
|
|
963
|
+
|
|
964
|
+
'bot.reconnect': async (payload) => {
|
|
965
|
+
if (typeof payload?.botId !== 'string' || !payload.botId) {
|
|
966
|
+
return { ok: false, error: { code: 'chat/bad-request', message: '需要 botId。', details: {} } };
|
|
967
|
+
}
|
|
968
|
+
await configStore.load();
|
|
969
|
+
const bot = configStore.get(payload.botId);
|
|
970
|
+
if (!bot) {
|
|
971
|
+
return {
|
|
972
|
+
ok: false,
|
|
973
|
+
error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} },
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
await stopBot(bot.id);
|
|
977
|
+
// 手动重连是用户"我刚去开了权限"的信号:清掉名字缓存与缺权限的退避。
|
|
978
|
+
resetNameCache(bot.id);
|
|
979
|
+
const record = await startBot(bot);
|
|
980
|
+
return { ok: true, value: botStatus(record) };
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 设这台机器人的属主。
|
|
985
|
+
*
|
|
986
|
+
* `ownerOpenIds: ['*']` = **没有属主**(公开机器人:没有人绕过访问策略)。
|
|
987
|
+
* 改完必须重连:桥在创建时捕获了 `bot` 对象,属主判定用的就是它,重连才会重建。
|
|
988
|
+
*/
|
|
989
|
+
'bot.owner.set': async (payload) => {
|
|
990
|
+
const owners = payload?.ownerOpenIds;
|
|
991
|
+
const valid = typeof payload?.botId === 'string' && payload.botId
|
|
992
|
+
&& Array.isArray(owners) && owners.length > 0 && owners.length <= MAX_OWNERS
|
|
993
|
+
&& owners.every((id) => typeof id === 'string' && OWNER_ID_PATTERN.test(id));
|
|
994
|
+
if (!valid) {
|
|
995
|
+
// 把不合法的值带出来:属主必须是平台 open_id,传成投递目标 id(`p2p_ou_…`)
|
|
996
|
+
// 时只看这句话是查不出来的(真机上踩过)。
|
|
997
|
+
const offending = Array.isArray(owners)
|
|
998
|
+
? owners.filter((id) => typeof id !== 'string' || !OWNER_ID_PATTERN.test(id))
|
|
999
|
+
: [];
|
|
1000
|
+
const detail = offending.length > 0
|
|
1001
|
+
? `不合法的值:${offending.map((id) => JSON.stringify(String(id).slice(0, 48))).join('、')}`
|
|
1002
|
+
: 'ownerOpenIds 必须是非空数组。';
|
|
1003
|
+
return {
|
|
1004
|
+
ok: false,
|
|
1005
|
+
error: {
|
|
1006
|
+
code: 'chat/bad-request',
|
|
1007
|
+
message: `bot.owner.set 需要 { botId, ownerOpenIds }:1–${MAX_OWNERS} 个该应用的 open_id`
|
|
1008
|
+
+ `(形如 ou_…),或用 ['*'] 表示没有属主。${detail}`,
|
|
1009
|
+
details: { offending: offending.map((id) => String(id).slice(0, 48)) },
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
await configStore.load();
|
|
1014
|
+
const bot = configStore.get(payload.botId);
|
|
1015
|
+
if (!bot) {
|
|
1016
|
+
return {
|
|
1017
|
+
ok: false,
|
|
1018
|
+
error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} },
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
const saved = await configStore.saveBot({
|
|
1022
|
+
id: bot.id,
|
|
1023
|
+
ownerOpenIds: [...new Set(owners.map((id) => id.trim()))],
|
|
1024
|
+
});
|
|
1025
|
+
// 重连一次:新属主要立刻对"谁能绕过策略"生效,不能等下次重启。
|
|
1026
|
+
await stopBot(saved.id);
|
|
1027
|
+
const record = await startBot(saved);
|
|
1028
|
+
return { ok: true, value: botStatus(record) };
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* 新建机器人接入:填 App ID + App Secret(自定义自建应用)就能加一只机器人。
|
|
1033
|
+
*
|
|
1034
|
+
* 顺序是有讲究的——**先验凭据,再写任何东西**:
|
|
1035
|
+
* ① 校验形状 → ② 探针换一次 tenant_access_token(凭据不对就到此为止,磁盘与凭据服务一个字节没动)
|
|
1036
|
+
* → ③ 写 DSH 凭据服务 → ④ 写渠道自己的 config.json → ⑤ 起长连接。
|
|
1037
|
+
* ④ 失败时把 ③ 写的凭据删掉(不留孤儿引用);⑤ 失败不算"没加上"
|
|
1038
|
+
* (配置已在,状态里会如实显示 failed + 原因,用户改完权限点重连即可)。
|
|
1039
|
+
*
|
|
1040
|
+
* 属主可以留空:此时按 `['*']`(**没有属主**,不是"人人都是属主")落盘,
|
|
1041
|
+
* 由调用方把私聊访问策略放宽到「任何人可用」,让属主先能跟机器人说上话。
|
|
1042
|
+
*/
|
|
1043
|
+
'bot.add': async (payload) => {
|
|
1044
|
+
const appId = typeof payload?.appId === 'string' ? payload.appId.trim() : '';
|
|
1045
|
+
const appSecret = typeof payload?.appSecret === 'string' ? payload.appSecret.trim() : '';
|
|
1046
|
+
const domain = APP_DOMAINS.includes(payload?.domain) ? payload.domain : 'feishu';
|
|
1047
|
+
const rawOwners = payload?.ownerOpenIds === undefined ? ['*'] : payload.ownerOpenIds;
|
|
1048
|
+
const ownersValid = Array.isArray(rawOwners) && rawOwners.length > 0
|
|
1049
|
+
&& rawOwners.length <= MAX_OWNERS
|
|
1050
|
+
&& rawOwners.every((id) => typeof id === 'string' && OWNER_ID_PATTERN.test(id.trim()));
|
|
1051
|
+
if (!APP_ID_PATTERN.test(appId) || appSecret.length < 8 || appSecret.length > 256 || !ownersValid) {
|
|
1052
|
+
return {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
error: {
|
|
1055
|
+
code: 'chat/bad-request',
|
|
1056
|
+
message: 'bot.add 需要 { appId: "cli_…", appSecret, domain?, ownerOpenIds? };'
|
|
1057
|
+
+ `appSecret 是 8–256 个字符,ownerOpenIds 可省略(省略即"没有属主",1–${MAX_OWNERS} 个 ou_…)。`,
|
|
1058
|
+
details: { appIdOk: APP_ID_PATTERN.test(appId), ownersValid },
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
if (typeof deps.credentials?.set !== 'function') {
|
|
1063
|
+
return {
|
|
1064
|
+
ok: false,
|
|
1065
|
+
error: {
|
|
1066
|
+
code: 'feishu/credential-unwritable',
|
|
1067
|
+
message: '当前 Host 的凭据服务不支持写入,无法保存 App Secret(请在 Host 里配置凭据服务)。',
|
|
1068
|
+
details: {},
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
await configStore.load();
|
|
1073
|
+
const existing = configStore.list().find((bot) => bot.appId === appId);
|
|
1074
|
+
if (existing) {
|
|
1075
|
+
return {
|
|
1076
|
+
ok: false,
|
|
1077
|
+
error: {
|
|
1078
|
+
code: 'feishu/bot-exists',
|
|
1079
|
+
message: `这个应用已经在列表里了(${existing.botName ?? existing.id}),不必重复接入。`,
|
|
1080
|
+
details: { botId: existing.id },
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
const { botId, secretRef } = deriveFeishuIdentity(appId);
|
|
1085
|
+
// ① 探针:凭据不对就到此为止。
|
|
1086
|
+
let info;
|
|
1087
|
+
try {
|
|
1088
|
+
const sdk = await sdkLoader();
|
|
1089
|
+
info = await probeFactory({ appId, appSecret, domain, sdk, logger }).verify();
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
return {
|
|
1092
|
+
ok: false,
|
|
1093
|
+
error: {
|
|
1094
|
+
code: typeof error?.code === 'string' ? error.code : 'feishu/credential-check-failed',
|
|
1095
|
+
message: error?.message ?? String(error),
|
|
1096
|
+
details: {},
|
|
1097
|
+
},
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
// ② 记凭据 → ③ 落盘 → ④ 起连接(与扫码接入共用同一条流水线)。
|
|
1101
|
+
let record;
|
|
1102
|
+
try {
|
|
1103
|
+
({ record } = await storeAndStart({
|
|
1104
|
+
appId,
|
|
1105
|
+
appSecret,
|
|
1106
|
+
domain,
|
|
1107
|
+
ownerOpenIds: rawOwners.map((id) => id.trim()),
|
|
1108
|
+
botName: info?.botName ?? null,
|
|
1109
|
+
botOpenId: info?.botOpenId ?? null,
|
|
1110
|
+
}));
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
error: {
|
|
1115
|
+
code: typeof error?.code === 'string' ? error.code : 'feishu/bot-save-failed',
|
|
1116
|
+
message: error?.message ?? String(error),
|
|
1117
|
+
details: {},
|
|
1118
|
+
},
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
return { ok: true, value: { bot: botStatus(record) } };
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* 扫码接入(**新建机器人**那条路):向飞书申请一个一次性链接,
|
|
1126
|
+
* 用户用飞书扫一下(或在浏览器里打开)就自动创建应用并返回凭据;**扫码的人就是属主**。
|
|
1127
|
+
*
|
|
1128
|
+
* 三个方法:`start` 发起(同一个时刻只有一个进行中的尝试)、`status` 轮询
|
|
1129
|
+
* (回显二维码/链接、剩余秒数、失败原因、成功后的机器人)、`cancel` 取消。
|
|
1130
|
+
* 二维码由 host 转成 data URL(`qrcode` 是构建期外置依赖):拿不到就只回链接,
|
|
1131
|
+
* 前端照样能让人打开——**不静默**,状态里会写清为什么没有二维码。
|
|
1132
|
+
*/
|
|
1133
|
+
'bot.register.start': async () => ({
|
|
1134
|
+
ok: true,
|
|
1135
|
+
value: await provisionStatus(provision.start()),
|
|
1136
|
+
}),
|
|
1137
|
+
|
|
1138
|
+
'bot.register.status': async () => ({ ok: true, value: await provisionStatus(provision.status()) }),
|
|
1139
|
+
|
|
1140
|
+
'bot.register.cancel': async () => ({ ok: true, value: await provisionStatus(provision.cancel()) }),
|
|
1141
|
+
|
|
1142
|
+
'bot.delete': async (payload) => {
|
|
1143
|
+
if (typeof payload?.botId !== 'string' || payload.confirm !== true) {
|
|
1144
|
+
return {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
error: { code: 'chat/bad-request', message: '删除需要 botId 与 confirm=true。', details: {} },
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
await stopBot(payload.botId);
|
|
1150
|
+
runtimes.delete(payload.botId);
|
|
1151
|
+
await configStore.removeBot(payload.botId);
|
|
1152
|
+
return { ok: true, value: { removed: true, botId: payload.botId } };
|
|
1153
|
+
},
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* id → 名字(设置页画白名单用)。
|
|
1157
|
+
*
|
|
1158
|
+
* 白名单存的是平台 id;只显示 id 的话,一排 `ou_4f6a8c0e…` 里认不出是谁、
|
|
1159
|
+
* 也没法确认自己加错了人。查不到就**不放进结果**(界面退回显示 id),
|
|
1160
|
+
* 原因放在 `hint` 里,避免"名字没了却不知道为什么"。
|
|
1161
|
+
*/
|
|
1162
|
+
'names.resolve': async (payload) => {
|
|
1163
|
+
const raw = Array.isArray(payload?.ids) ? payload.ids : null;
|
|
1164
|
+
if (typeof payload?.botId !== 'string' || !payload.botId || raw === null) {
|
|
1165
|
+
return {
|
|
1166
|
+
ok: false,
|
|
1167
|
+
error: {
|
|
1168
|
+
code: 'chat/bad-request',
|
|
1169
|
+
message: 'names.resolve 需要 { botId, ids: string[] }。',
|
|
1170
|
+
details: {},
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
await configStore.load();
|
|
1175
|
+
if (!configStore.get(payload.botId)) {
|
|
1176
|
+
return {
|
|
1177
|
+
ok: false,
|
|
1178
|
+
error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} },
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const ids = [...new Set(raw
|
|
1182
|
+
.filter((id) => typeof id === 'string' && id.trim())
|
|
1183
|
+
.map((id) => id.trim()))];
|
|
1184
|
+
const limited = ids.slice(0, MAX_RESOLVE_IDS);
|
|
1185
|
+
const pairs = await Promise.all(
|
|
1186
|
+
limited.map(async (id) => [id, await resolveName(payload.botId, id)]),
|
|
1187
|
+
);
|
|
1188
|
+
return {
|
|
1189
|
+
ok: true,
|
|
1190
|
+
value: {
|
|
1191
|
+
names: Object.fromEntries(pairs.filter(([, name]) => Boolean(name))),
|
|
1192
|
+
// 名单特别长时只查前 N 个:界面据此说明"还有几个没查"。
|
|
1193
|
+
truncated: ids.length > limited.length,
|
|
1194
|
+
hint: nameCache.get(payload.botId)?.nameHint ?? null,
|
|
1195
|
+
},
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
|
|
1199
|
+
/** 任务过程展示:私聊/群聊两份,原子保存并立即生效。 */
|
|
1200
|
+
/**
|
|
1201
|
+
* 渠道自带的面板字段(hub 的 `panel.read` 调这里)。
|
|
1202
|
+
*
|
|
1203
|
+
* 只报**当前会话类型**那一份:卡片是发给某个会话的,同时暴露私聊+群聊两份会让人改错。
|
|
1204
|
+
*/
|
|
1205
|
+
'panel.fields': async (payload) => {
|
|
1206
|
+
if (typeof payload?.botId !== 'string' || !payload.botId) {
|
|
1207
|
+
return { ok: false, error: { code: 'chat/bad-request', message: 'panel.fields 需要 botId。', details: {} } };
|
|
1208
|
+
}
|
|
1209
|
+
await configStore.load();
|
|
1210
|
+
const bot = configStore.get(payload.botId);
|
|
1211
|
+
if (!bot) {
|
|
1212
|
+
return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1213
|
+
}
|
|
1214
|
+
const values = stepPushValues(bot);
|
|
1215
|
+
return {
|
|
1216
|
+
ok: true,
|
|
1217
|
+
value: {
|
|
1218
|
+
// 两份都列出来:改哪一份不由"卡在哪"决定,而由用户选的那个下拉决定。
|
|
1219
|
+
fields: STEP_PUSH_FIELDS.map((item) => ({
|
|
1220
|
+
field: item.field,
|
|
1221
|
+
label: item.label,
|
|
1222
|
+
value: values[item.scope],
|
|
1223
|
+
options: STEP_PUSH_FIELD_OPTIONS,
|
|
1224
|
+
})),
|
|
1225
|
+
},
|
|
1226
|
+
};
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* 渠道自带的**动作按钮**(hub 的 `panel.read` 调这里):卡片上多几个"点一下做事"的按钮。
|
|
1231
|
+
*
|
|
1232
|
+
* 只给属主:重连会断开并重建长连接(期间消息可能延迟),这是机器人级操作。
|
|
1233
|
+
*/
|
|
1234
|
+
'panel.actions': async (payload) => {
|
|
1235
|
+
if (typeof payload?.botId !== 'string' || !payload.botId) {
|
|
1236
|
+
return { ok: false, error: { code: 'chat/bad-request', message: 'panel.actions 需要 botId。', details: {} } };
|
|
1237
|
+
}
|
|
1238
|
+
if (payload.isOwner !== true) return { ok: true, value: { actions: [] } };
|
|
1239
|
+
await configStore.load();
|
|
1240
|
+
const bot = configStore.get(payload.botId);
|
|
1241
|
+
if (!bot) return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1242
|
+
return {
|
|
1243
|
+
ok: true,
|
|
1244
|
+
value: {
|
|
1245
|
+
actions: [{
|
|
1246
|
+
action: 'reconnect',
|
|
1247
|
+
label: '🔌 重连',
|
|
1248
|
+
type: 'default',
|
|
1249
|
+
/**
|
|
1250
|
+
* 重连会**亲手掐掉正在送回执的那条长连接**:真机上点完卡片显示"已重连",
|
|
1251
|
+
* 飞书却弹一句「目标回调服务超时未响应」——回执没能送出去。
|
|
1252
|
+
* 声明成 deferred:桥先应答,再执行(与 /history、/compact 同一条规矩)。
|
|
1253
|
+
*/
|
|
1254
|
+
deferred: true,
|
|
1255
|
+
// 原生二次确认:重连会短暂断开长连接,别让误触把机器人踢下线。
|
|
1256
|
+
confirm: {
|
|
1257
|
+
title: '重连这台机器人?',
|
|
1258
|
+
text: '会断开并重建长连接,几秒内收不到消息;刚开通的权限/群列表会重新读取。',
|
|
1259
|
+
},
|
|
1260
|
+
}],
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
},
|
|
1264
|
+
|
|
1265
|
+
/** 执行渠道自带的面板动作(hub 的 `panel.act` 调这里)。 */
|
|
1266
|
+
'panel.act': async (payload) => {
|
|
1267
|
+
if (typeof payload?.botId !== 'string' || !payload.botId || typeof payload?.action !== 'string') {
|
|
1268
|
+
return { ok: false, error: { code: 'chat/bad-request', message: 'panel.act 需要 botId 与 action。', details: {} } };
|
|
1269
|
+
}
|
|
1270
|
+
if (payload.isOwner !== true) {
|
|
1271
|
+
return {
|
|
1272
|
+
ok: false,
|
|
1273
|
+
error: { code: 'chat/owner-only', message: '重连是机器人级操作,只有属主能做。', details: {} },
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
if (payload.action !== 'reconnect') {
|
|
1277
|
+
return {
|
|
1278
|
+
ok: false,
|
|
1279
|
+
error: { code: 'chat/unknown-action', message: `飞书面板没有这个动作:${payload.action}`, details: {} },
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
await configStore.load();
|
|
1283
|
+
const bot = configStore.get(payload.botId);
|
|
1284
|
+
if (!bot) return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1285
|
+
await stopBot(bot.id);
|
|
1286
|
+
// 手动重连是用户"我刚去开了权限"的信号:清掉名字缓存与缺权限的退避(与 RPC 那条路同一套)。
|
|
1287
|
+
resetNameCache(bot.id);
|
|
1288
|
+
const record = await startBot(bot);
|
|
1289
|
+
const status = botStatus(record);
|
|
1290
|
+
return {
|
|
1291
|
+
ok: true,
|
|
1292
|
+
value: {
|
|
1293
|
+
action: 'reconnect',
|
|
1294
|
+
message: status?.connected === true
|
|
1295
|
+
? '已重连(长连接已重建)。'
|
|
1296
|
+
: `重连完成,但当前未连上${status?.errorMessage ? `:${status.errorMessage}` : ''}。`,
|
|
1297
|
+
},
|
|
1298
|
+
};
|
|
1299
|
+
},
|
|
1300
|
+
|
|
1301
|
+
/** 改渠道自带的面板字段(hub 的 `panel.apply` 调这里)。 */
|
|
1302
|
+
'panel.apply': async (payload) => {
|
|
1303
|
+
if (typeof payload?.botId !== 'string' || !payload.botId || typeof payload?.field !== 'string') {
|
|
1304
|
+
return { ok: false, error: { code: 'chat/bad-request', message: 'panel.apply 需要 botId 与 field。', details: {} } };
|
|
1305
|
+
}
|
|
1306
|
+
const target = stepPushTarget(payload.field, payload.conversationType);
|
|
1307
|
+
if (!target) {
|
|
1308
|
+
return {
|
|
1309
|
+
ok: false,
|
|
1310
|
+
error: { code: 'chat/unknown-field', message: `飞书面板不支持 ${payload.field}。`, details: {} },
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
const allowed = STEP_PUSH_FIELD_OPTIONS.map((item) => item.value);
|
|
1314
|
+
if (!allowed.includes(payload.value)) {
|
|
1315
|
+
return {
|
|
1316
|
+
ok: false,
|
|
1317
|
+
error: {
|
|
1318
|
+
code: 'chat/bad-request',
|
|
1319
|
+
message: `过程展示只能是 ${allowed.join(' / ')}。`,
|
|
1320
|
+
details: {},
|
|
1321
|
+
},
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
await configStore.load();
|
|
1325
|
+
const bot = configStore.get(payload.botId);
|
|
1326
|
+
if (!bot) {
|
|
1327
|
+
return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1328
|
+
}
|
|
1329
|
+
const next = {
|
|
1330
|
+
direct: target.scope === 'direct' ? payload.value : bot.stepPushDirect,
|
|
1331
|
+
group: target.scope === 'group' ? payload.value : bot.stepPushGroup,
|
|
1332
|
+
};
|
|
1333
|
+
const saved = await configStore.setStepPush(payload.botId, next);
|
|
1334
|
+
// 立刻生效:运行期那份 bot 对象要被就地改掉(与设置页那条路一致)。
|
|
1335
|
+
patchRuntime(payload.botId, {
|
|
1336
|
+
stepPushDirect: saved.stepPushDirect,
|
|
1337
|
+
stepPushGroup: saved.stepPushGroup,
|
|
1338
|
+
});
|
|
1339
|
+
const label = STEP_PUSH_FIELD_OPTIONS.find((item) => item.value === payload.value)?.label ?? payload.value;
|
|
1340
|
+
return {
|
|
1341
|
+
ok: true,
|
|
1342
|
+
value: {
|
|
1343
|
+
value: payload.value,
|
|
1344
|
+
message: `${target.scope === 'group' ? '群聊' : '私聊'}过程展示已设为「${label}」,立即生效。`,
|
|
1345
|
+
},
|
|
1346
|
+
};
|
|
1347
|
+
},
|
|
1348
|
+
|
|
1349
|
+
'bot.step-push.set': async (payload) => {
|
|
1350
|
+
const modes = payload?.stepPush;
|
|
1351
|
+
if (typeof payload?.botId !== 'string' || !payload.botId
|
|
1352
|
+
|| modes === null || typeof modes !== 'object' || Array.isArray(modes)
|
|
1353
|
+
|| Object.keys(modes).length !== 2
|
|
1354
|
+
|| typeof modes.direct !== 'string' || typeof modes.group !== 'string') {
|
|
1355
|
+
return {
|
|
1356
|
+
ok: false,
|
|
1357
|
+
error: {
|
|
1358
|
+
code: 'chat/bad-request',
|
|
1359
|
+
message: 'bot.step-push.set 需要 { botId, stepPush: { direct, group } }。',
|
|
1360
|
+
details: {},
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
const saved = await configStore.setStepPush(payload.botId, modes);
|
|
1365
|
+
patchRuntime(payload.botId, {
|
|
1366
|
+
stepPushDirect: saved.stepPushDirect,
|
|
1367
|
+
stepPushGroup: saved.stepPushGroup,
|
|
1368
|
+
});
|
|
1369
|
+
return {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
value: { stepPush: { direct: saved.stepPushDirect, group: saved.stepPushGroup } },
|
|
1372
|
+
};
|
|
1373
|
+
},
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* lark-cli 身份策略的**只读**体检:给设置页看"现在到底会是谁"。
|
|
1377
|
+
*
|
|
1378
|
+
* 只读:不建 profile、不写任何东西(profile 要等真正调用时才懒建)。
|
|
1379
|
+
*/
|
|
1380
|
+
'bot.lark-identity.get': async (payload) => {
|
|
1381
|
+
if (typeof payload?.botId !== 'string' || !payload.botId) {
|
|
1382
|
+
return { ok: false, error: { code: 'chat/bad-request', message: 'bot.lark-identity.get 需要 botId。', details: {} } };
|
|
1383
|
+
}
|
|
1384
|
+
await configStore.load();
|
|
1385
|
+
const bot = configStore.get(payload.botId);
|
|
1386
|
+
if (!bot) {
|
|
1387
|
+
return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1388
|
+
}
|
|
1389
|
+
const cli = larkCliFor(payload.botId);
|
|
1390
|
+
const info = await cli.inspect();
|
|
1391
|
+
return {
|
|
1392
|
+
ok: true,
|
|
1393
|
+
value: {
|
|
1394
|
+
policy: { mode: bot.larkUserIdentity, userOpenId: bot.larkUserOpenId },
|
|
1395
|
+
profile: info.profile,
|
|
1396
|
+
identity: info.identity,
|
|
1397
|
+
checkedAt: info.checkedAt,
|
|
1398
|
+
error: info.error ?? null,
|
|
1399
|
+
},
|
|
1400
|
+
};
|
|
1401
|
+
},
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* 设置"这台机器人能不能用 lark-cli 的用户身份",并钉住具体的人。
|
|
1405
|
+
*
|
|
1406
|
+
* **开启必须先确认**(照访问策略放宽那条口径):不带 `confirm: true` 就只回
|
|
1407
|
+
* `requiresConfirm` + 说明,**一个字节都不写**。关回 `bot-only` 是收窄,立即生效、不用确认。
|
|
1408
|
+
*
|
|
1409
|
+
* 钉住的用户由 lark-cli 自己回答(`whoami --as user`)——**不由我们猜**;
|
|
1410
|
+
* 探不到用户就如实拒绝开启:开了也是个用不了的开关。
|
|
1411
|
+
*/
|
|
1412
|
+
'bot.lark-identity.set': async (payload) => {
|
|
1413
|
+
const value = payload?.value;
|
|
1414
|
+
if (typeof payload?.botId !== 'string' || !payload.botId
|
|
1415
|
+
|| (value !== 'bot-only' && value !== 'user-allowed')) {
|
|
1416
|
+
return {
|
|
1417
|
+
ok: false,
|
|
1418
|
+
error: {
|
|
1419
|
+
code: 'chat/bad-request',
|
|
1420
|
+
message: 'bot.lark-identity.set 需要 { botId, value: bot-only | user-allowed }。',
|
|
1421
|
+
details: {},
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
await configStore.load();
|
|
1426
|
+
const bot = configStore.get(payload.botId);
|
|
1427
|
+
if (!bot) {
|
|
1428
|
+
return { ok: false, error: { code: 'feishu/unknown-bot', message: `未找到机器人 ${payload.botId}。`, details: {} } };
|
|
1429
|
+
}
|
|
1430
|
+
if (value === 'bot-only') {
|
|
1431
|
+
const saved = await configStore.setLarkIdentity(payload.botId, { value: 'bot-only' });
|
|
1432
|
+
patchRuntime(payload.botId, {
|
|
1433
|
+
larkUserIdentity: saved.larkUserIdentity,
|
|
1434
|
+
larkUserOpenId: saved.larkUserOpenId,
|
|
1435
|
+
});
|
|
1436
|
+
return {
|
|
1437
|
+
ok: true,
|
|
1438
|
+
value: {
|
|
1439
|
+
mode: saved.larkUserIdentity,
|
|
1440
|
+
userOpenId: null,
|
|
1441
|
+
message: '已改回「只用应用身份」——lark-cli 的用户授权不再被这台机器人使用。',
|
|
1442
|
+
},
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
let identity;
|
|
1446
|
+
try {
|
|
1447
|
+
identity = await larkCliFor(payload.botId).whoami({ as: 'user' });
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
return {
|
|
1450
|
+
ok: false,
|
|
1451
|
+
error: {
|
|
1452
|
+
code: typeof error?.code === 'string' ? error.code : 'feishu/lark-cli-failed',
|
|
1453
|
+
message: `读不到 lark-cli 里的用户身份:${error?.message ?? error}`,
|
|
1454
|
+
details: { hint: error?.hint ?? null },
|
|
1455
|
+
},
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
if (typeof identity?.appId === 'string' && identity.appId !== bot.appId) {
|
|
1459
|
+
return {
|
|
1460
|
+
ok: false,
|
|
1461
|
+
error: {
|
|
1462
|
+
code: 'feishu/lark-cli-app-mismatch',
|
|
1463
|
+
message: `lark-cli 生效的应用是 ${identity.appId},不是这台机器人的 ${bot.appId};已拒绝。`,
|
|
1464
|
+
details: {},
|
|
1465
|
+
},
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
const openId = typeof identity?.onBehalfOf?.openId === 'string' ? identity.onBehalfOf.openId : '';
|
|
1469
|
+
const userName = typeof identity?.onBehalfOf?.userName === 'string' ? identity.onBehalfOf.userName : null;
|
|
1470
|
+
if (!openId) {
|
|
1471
|
+
return {
|
|
1472
|
+
ok: false,
|
|
1473
|
+
error: {
|
|
1474
|
+
code: 'feishu/lark-cli-user-unavailable',
|
|
1475
|
+
message: 'lark-cli 里这台应用没有可用的用户登录,无法开启(开了也用不了)。',
|
|
1476
|
+
details: {
|
|
1477
|
+
hint: '先在命令行完成这台应用的用户授权:lark-cli auth login --profile '
|
|
1478
|
+
+ larkCliFor(payload.botId).profileName + ',再回来开启。',
|
|
1479
|
+
},
|
|
1480
|
+
},
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
if (payload.confirm !== true) {
|
|
1484
|
+
return {
|
|
1485
|
+
ok: true,
|
|
1486
|
+
value: {
|
|
1487
|
+
mode: bot.larkUserIdentity,
|
|
1488
|
+
userOpenId: bot.larkUserOpenId,
|
|
1489
|
+
requiresConfirm: true,
|
|
1490
|
+
candidate: { openId, userName },
|
|
1491
|
+
confirmPrompt: `开启后,这台机器人可以以「${userName ?? openId}」的身份调用 lark-cli`
|
|
1492
|
+
+ '(能读写这个人自己的云文档、日历等个人资源)。确定要开吗?',
|
|
1493
|
+
message: `需要确认:将允许以 ${userName ?? openId} 的用户身份调用 lark-cli。`,
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
const saved = await configStore.setLarkIdentity(payload.botId, { value: 'user-allowed', userOpenId: openId });
|
|
1498
|
+
patchRuntime(payload.botId, {
|
|
1499
|
+
larkUserIdentity: saved.larkUserIdentity,
|
|
1500
|
+
larkUserOpenId: saved.larkUserOpenId,
|
|
1501
|
+
});
|
|
1502
|
+
return {
|
|
1503
|
+
ok: true,
|
|
1504
|
+
value: {
|
|
1505
|
+
mode: saved.larkUserIdentity,
|
|
1506
|
+
userOpenId: saved.larkUserOpenId,
|
|
1507
|
+
userName,
|
|
1508
|
+
message: `已允许以「${userName ?? openId}」的用户身份调用 lark-cli;调用时会核对 appId 与这个人,对不上就拒绝。`,
|
|
1509
|
+
},
|
|
1510
|
+
};
|
|
1511
|
+
},
|
|
1512
|
+
}),
|
|
1513
|
+
});
|
|
1514
|
+
}
|