@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,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书机器人配置存储。
|
|
3
|
+
*
|
|
4
|
+
* 沿用 dsh-im 的 `~/.dsh/integrations/dsh-feishu/config.json`(version 2)
|
|
5
|
+
* 与凭据引用,因此用户现有机器人零重绑即可继续使用。
|
|
6
|
+
*
|
|
7
|
+
* 本插件在机器人上新增的字段:
|
|
8
|
+
* - `larkUserIdentity` / `larkUserOpenId`:这台机器人调 lark-cli 时**能不能用用户身份**、
|
|
9
|
+
* 以及钉住的是哪个用户(默认 `bot-only`;见 `host/lark-cli.mjs` 的"绝不用别人的授权")。
|
|
10
|
+
*
|
|
11
|
+
* 取代原来的"全局一份"过程展示:
|
|
12
|
+
* - `stepPushDirect`:私聊过程展示
|
|
13
|
+
* - `stepPushGroup`:群聊过程展示
|
|
14
|
+
* 取值 `off | post | streaming_card`;旧字段 `stepPush` + `stepPushMode`
|
|
15
|
+
* 读取时迁移到这两个字段(两者同值),保存后旧字段消失。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-chat-feishu/config-store
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from 'node:crypto';
|
|
21
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { normalizeLarkUserIdentity } from './lark-cli.mjs';
|
|
25
|
+
|
|
26
|
+
/** 旧实现里默认 Secret 引用名(手工配置的机器人沿用)。 */
|
|
27
|
+
export const LEGACY_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|
28
|
+
|
|
29
|
+
/** 过程展示三态。 */
|
|
30
|
+
export const STEP_PUSH_MODES = Object.freeze(['off', 'post', 'streaming_card']);
|
|
31
|
+
|
|
32
|
+
const DEFAULT_STEP_PUSH = 'off';
|
|
33
|
+
|
|
34
|
+
function cleanString(value) {
|
|
35
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeId(value) {
|
|
39
|
+
const id = cleanString(value);
|
|
40
|
+
return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 归一化过程展示取值:非法值一律回落 `off`(永不阻塞机器人启动)。
|
|
45
|
+
*
|
|
46
|
+
* @param value - 任意历史值。
|
|
47
|
+
* @returns 'off' | 'post' | 'streaming_card'。
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeStepPushMode(value) {
|
|
50
|
+
return STEP_PUSH_MODES.includes(value) ? value : DEFAULT_STEP_PUSH;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeOwners(value) {
|
|
54
|
+
const candidates = Array.isArray(value?.ownerOpenIds) ? value.ownerOpenIds : [value?.ownerOpenId];
|
|
55
|
+
return [...new Set(candidates.map(cleanString).filter(Boolean))];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 归一化一个机器人条目(含旧过程展示字段的迁移)。
|
|
60
|
+
*
|
|
61
|
+
* @param value - 磁盘上的机器人条目。
|
|
62
|
+
* @param options - { legacy }:允许沿用旧默认 Secret 引用。
|
|
63
|
+
* @returns 冻结的机器人配置,或 null(信息不足以启动)。
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeBot(value, { legacy = false } = {}) {
|
|
66
|
+
if (!value || typeof value !== 'object') return null;
|
|
67
|
+
const appId = cleanString(value.appId);
|
|
68
|
+
const ownerOpenIds = normalizeOwners(value);
|
|
69
|
+
const id = safeId(value.id) ?? (legacy && appId
|
|
70
|
+
? `legacy_${appId.replace(/[^A-Za-z0-9]/g, '').slice(0, 24)}`
|
|
71
|
+
: null);
|
|
72
|
+
const secretRef = cleanString(value.secretRef) ?? (legacy ? LEGACY_SECRET_REF : null);
|
|
73
|
+
if (!appId || ownerOpenIds.length === 0 || !id || !secretRef) return null;
|
|
74
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null;
|
|
75
|
+
|
|
76
|
+
// 过程展示:新字段优先,否则从旧的全局字段迁移。
|
|
77
|
+
const hasNewFields = value.stepPushDirect !== undefined || value.stepPushGroup !== undefined;
|
|
78
|
+
const migrated = normalizeStepPushMode(value.stepPushMode);
|
|
79
|
+
const legacyMode = value.stepPush === true ? migrated : DEFAULT_STEP_PUSH;
|
|
80
|
+
|
|
81
|
+
// 身份策略:保守方向——缺省/写坏一律 `bot-only`;只有显式允许时才记住钉住的用户。
|
|
82
|
+
const larkUserIdentity = normalizeLarkUserIdentity(value.larkUserIdentity);
|
|
83
|
+
const larkUserOpenId = larkUserIdentity === 'user-allowed' ? cleanString(value.larkUserOpenId) : null;
|
|
84
|
+
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
id,
|
|
87
|
+
appId,
|
|
88
|
+
secretRef,
|
|
89
|
+
ownerOpenIds: Object.freeze(ownerOpenIds),
|
|
90
|
+
domain: value.domain === 'lark' ? 'lark' : 'feishu',
|
|
91
|
+
botName: cleanString(value.botName),
|
|
92
|
+
botOpenId: cleanString(value.botOpenId),
|
|
93
|
+
groupResponseMode: value.groupResponseMode === 'all' ? 'all' : 'mention',
|
|
94
|
+
groupTopicReply: value.groupTopicReply === true,
|
|
95
|
+
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
96
|
+
stepPushDirect: hasNewFields
|
|
97
|
+
? normalizeStepPushMode(value.stepPushDirect)
|
|
98
|
+
: legacyMode,
|
|
99
|
+
stepPushGroup: hasNewFields
|
|
100
|
+
? normalizeStepPushMode(value.stepPushGroup)
|
|
101
|
+
: legacyMode,
|
|
102
|
+
larkUserIdentity,
|
|
103
|
+
larkUserOpenId,
|
|
104
|
+
connectedAt: cleanString(value.connectedAt),
|
|
105
|
+
createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeDocument(value) {
|
|
110
|
+
if (value && typeof value === 'object' && value.version === 2 && Array.isArray(value.bots)) {
|
|
111
|
+
const bots = value.bots.map((bot) => normalizeBot(bot));
|
|
112
|
+
if (bots.some((bot) => bot === null)) {
|
|
113
|
+
throw new Error('dsh-feishu config.json 含无法识别的机器人条目');
|
|
114
|
+
}
|
|
115
|
+
return { version: 2, bots };
|
|
116
|
+
}
|
|
117
|
+
// version 1:单个机器人对象。
|
|
118
|
+
const legacyBot = normalizeBot(value, { legacy: true });
|
|
119
|
+
return legacyBot ? { version: 2, bots: [legacyBot] } : { version: 2, bots: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 飞书机器人配置存储。
|
|
124
|
+
*
|
|
125
|
+
* @param options - { path, logger }。
|
|
126
|
+
* @returns 存储 API。
|
|
127
|
+
*/
|
|
128
|
+
export function createFeishuConfigStore({ path, logger = console } = {}) {
|
|
129
|
+
if (typeof path !== 'string' || !path.trim()) throw new TypeError('config store 需要 path。');
|
|
130
|
+
let document = { version: 2, bots: [] };
|
|
131
|
+
let loaded = false;
|
|
132
|
+
let queue = Promise.resolve();
|
|
133
|
+
|
|
134
|
+
async function persist() {
|
|
135
|
+
await mkdir(dirname(path), { recursive: true });
|
|
136
|
+
const temporary = `${path}.tmp-${randomBytes(6).toString('hex')}`;
|
|
137
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
|
138
|
+
await rename(temporary, path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function enqueue(task) {
|
|
142
|
+
const next = queue.then(task, task);
|
|
143
|
+
queue = next.then(() => undefined, () => undefined);
|
|
144
|
+
return next;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
path,
|
|
149
|
+
|
|
150
|
+
async load() {
|
|
151
|
+
if (loaded) return this;
|
|
152
|
+
try {
|
|
153
|
+
document = normalizeDocument(JSON.parse(await readFile(path, 'utf8')));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error?.code !== 'ENOENT') {
|
|
156
|
+
logger.warn?.(`[dsh-chat-feishu] 读取 ${path} 失败:${error?.message ?? error}`);
|
|
157
|
+
}
|
|
158
|
+
document = { version: 2, bots: [] };
|
|
159
|
+
}
|
|
160
|
+
loaded = true;
|
|
161
|
+
return this;
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/** @returns 全部机器人(冻结)。 */
|
|
165
|
+
list() {
|
|
166
|
+
return Object.freeze([...document.bots]);
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/** @returns 指定机器人,未配置时 undefined。 */
|
|
170
|
+
get(botId) {
|
|
171
|
+
return document.bots.find((bot) => bot.id === botId);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/** 写入(合并)一个机器人;不存在则按 id 追加。 */
|
|
175
|
+
async saveBot(patch) {
|
|
176
|
+
const id = safeId(patch?.id);
|
|
177
|
+
if (!id) throw new TypeError('saveBot 需要合法 id。');
|
|
178
|
+
return enqueue(async () => {
|
|
179
|
+
await this.load();
|
|
180
|
+
const index = document.bots.findIndex((bot) => bot.id === id);
|
|
181
|
+
const merged = normalizeBot({
|
|
182
|
+
...(index >= 0 ? document.bots[index] : {}),
|
|
183
|
+
...patch,
|
|
184
|
+
});
|
|
185
|
+
if (!merged) throw new TypeError('saveBot 的信息不完整(appId / ownerOpenIds / secretRef 必填)。');
|
|
186
|
+
const bots = [...document.bots];
|
|
187
|
+
if (index >= 0) bots[index] = merged;
|
|
188
|
+
else bots.push(merged);
|
|
189
|
+
document = { version: 2, bots };
|
|
190
|
+
await persist();
|
|
191
|
+
return merged;
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 设置任务过程展示(私聊/群聊两份,原子保存)。
|
|
197
|
+
*
|
|
198
|
+
* @param botId - 机器人 id。
|
|
199
|
+
* @param modes - { direct, group },各自为三态之一。
|
|
200
|
+
* @returns 写入后的机器人配置。
|
|
201
|
+
*/
|
|
202
|
+
async setStepPush(botId, modes) {
|
|
203
|
+
const direct = normalizeStepPushMode(modes?.direct);
|
|
204
|
+
const group = normalizeStepPushMode(modes?.group);
|
|
205
|
+
return this.saveBot({ id: botId, stepPushDirect: direct, stepPushGroup: group });
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 设置"这台机器人调 lark-cli 时允不允许用用户身份",以及钉住哪个用户。
|
|
210
|
+
*
|
|
211
|
+
* 关回 `bot-only` 时把钉住的用户一起清掉(留着只会在下次误用时更迷惑)。
|
|
212
|
+
*
|
|
213
|
+
* @param botId - 机器人 id。
|
|
214
|
+
* @param options - { value, userOpenId }。
|
|
215
|
+
* @returns 写入后的机器人配置。
|
|
216
|
+
*/
|
|
217
|
+
async setLarkIdentity(botId, { value, userOpenId } = {}) {
|
|
218
|
+
const mode = normalizeLarkUserIdentity(value);
|
|
219
|
+
const pinned = mode === 'user-allowed' ? cleanString(userOpenId) : null;
|
|
220
|
+
if (mode === 'user-allowed' && pinned && !/^ou_[A-Za-z0-9_-]{4,64}$/.test(pinned)) {
|
|
221
|
+
throw new TypeError(`钉住的用户 open_id 形状不对:${pinned}`);
|
|
222
|
+
}
|
|
223
|
+
return this.saveBot({ id: botId, larkUserIdentity: mode, larkUserOpenId: pinned });
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/** 删除一个机器人。 */
|
|
227
|
+
async removeBot(botId) {
|
|
228
|
+
return enqueue(async () => {
|
|
229
|
+
await this.load();
|
|
230
|
+
const bots = document.bots.filter((bot) => bot.id !== botId);
|
|
231
|
+
const removed = bots.length !== document.bots.length;
|
|
232
|
+
if (removed) {
|
|
233
|
+
document = { version: 2, bots };
|
|
234
|
+
await persist();
|
|
235
|
+
}
|
|
236
|
+
return removed;
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|