@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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * 飞书「扫码接入」:应用注册(device registration)的状态机。
3
+ *
4
+ * 这是"新建机器人"那条路:向飞书申请一个一次性链接,用户用飞书扫一下(或在浏览器里打开)
5
+ * 就**自动创建一个飞书应用**并返回它的 App ID / App Secret,扫描的人就是这台机器人的属主。
6
+ * 另一条路是「手动接入已有的机器人」(设置页填 App ID + App Secret)。
7
+ *
8
+ * 出处:dsh-im 的 `src/channels/feishu/registration-manager.mjs`(MIT,v4.21.1)——
9
+ * 同一套状态、同一个 SDK 入口(`registerApp({ onQRCodeReady, onStatusChange, signal })`)
10
+ * 与同样的"Secret 只经回调交出去、不进任何状态"的做法。按本仓库口径**收窄重写**:
11
+ * - 只做"新建"(不做上游那条"用扫码给已有应用增量补权限"的路,见 UPSTREAM.md);
12
+ * - 状态快照只留设置页要用的字段,不做 publicError 那层包裹;
13
+ * - 一次只允许一个进行中的尝试(新的开始会作废旧的,旧的 poll 结果一律忽略)。
14
+ *
15
+ * @module dsh-chat-feishu/provision
16
+ */
17
+
18
+ /** 还在进行中的状态(这些状态下才回显二维码与剩余时间)。 */
19
+ export const PROVISION_ACTIVE_STATES = Object.freeze([
20
+ 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
21
+ ]);
22
+
23
+ /** SDK 会回报的轮询状态(其余一律忽略,避免把 SDK 的内部状态泄漏成我们的状态机)。 */
24
+ const SDK_POLLING_STATES = Object.freeze(['polling', 'slow_down', 'domain_switched']);
25
+
26
+ function positiveSeconds(value) {
27
+ const seconds = Number(value);
28
+ if (!Number.isFinite(seconds) || seconds <= 0) {
29
+ throw new TypeError('registerApp 的 onQRCodeReady 没给合法的 expireIn。');
30
+ }
31
+ return seconds;
32
+ }
33
+
34
+ /**
35
+ * 创建扫码接入管理器。
36
+ *
37
+ * @param options - {
38
+ * registerApp: (options) => Promise<{ client_id, client_secret, user_info }>,
39
+ * onCredentials: ({ appId, appSecret, userInfo }) => Promise<void>,
40
+ * logger, now?, setTimeout?, clearTimeout?,
41
+ * }。
42
+ * @returns `{ start, status, cancel, dispose }`。
43
+ */
44
+ export function createProvisionManager({
45
+ registerApp,
46
+ onCredentials,
47
+ logger = console,
48
+ now = Date.now,
49
+ setTimeout: setTimeoutFn = globalThis.setTimeout,
50
+ clearTimeout: clearTimeoutFn = globalThis.clearTimeout,
51
+ } = {}) {
52
+ if (typeof registerApp !== 'function') throw new TypeError('扫码接入需要 registerApp。');
53
+ if (typeof onCredentials !== 'function') throw new TypeError('扫码接入需要 onCredentials。');
54
+
55
+ let attempt = 0;
56
+ let active = null;
57
+ let snapshot = { state: 'idle', attempt: 0, updatedAt: now(), error: null };
58
+
59
+ function isCurrent(run) {
60
+ return active === run;
61
+ }
62
+
63
+ function clearTimer(run) {
64
+ if (run?.expiryTimer) {
65
+ clearTimeoutFn(run.expiryTimer);
66
+ run.expiryTimer = null;
67
+ }
68
+ }
69
+
70
+ function makeSnapshot(run, state, extra = {}) {
71
+ const next = {
72
+ state,
73
+ attempt: run?.id ?? attempt,
74
+ updatedAt: now(),
75
+ error: null,
76
+ ...extra,
77
+ };
78
+ if (state === 'succeeded' && run?.bot) next.bot = run.bot;
79
+ if (run?.qrCodeUrl && PROVISION_ACTIVE_STATES.includes(state)) {
80
+ next.qrCodeUrl = run.qrCodeUrl;
81
+ next.expiresAt = run.expiresAt;
82
+ }
83
+ return next;
84
+ }
85
+
86
+ function setState(run, state, extra = {}) {
87
+ if (!isCurrent(run)) return;
88
+ snapshot = makeSnapshot(run, state, extra);
89
+ }
90
+
91
+ function finish(run, state, extra = {}) {
92
+ if (!isCurrent(run)) return;
93
+ clearTimer(run);
94
+ snapshot = makeSnapshot(run, state, extra);
95
+ active = null;
96
+ }
97
+
98
+ function expire(run) {
99
+ if (!isCurrent(run)) return;
100
+ finish(run, 'expired', {
101
+ error: { code: 'expired_token', message: '二维码/授权链接已失效,请重新生成。' },
102
+ });
103
+ run.controller.abort();
104
+ }
105
+
106
+ function publicError(error) {
107
+ const code = error?.code === 'abort' || error?.code === 'expired_token'
108
+ ? error.code
109
+ : (typeof error?.code === 'string' ? error.code : 'registration_failed');
110
+ const messages = {
111
+ abort: '已取消。',
112
+ expired_token: '授权已失效,请重新生成。',
113
+ };
114
+ return {
115
+ code,
116
+ message: messages[code] ?? (error?.message ?? '扫码接入失败。'),
117
+ };
118
+ }
119
+
120
+ function onQrCodeReady(run, info) {
121
+ if (!isCurrent(run)) return;
122
+ if (typeof info?.url !== 'string' || !info.url) {
123
+ throw new TypeError('registerApp 的 onQRCodeReady 没给 URL。');
124
+ }
125
+ const seconds = positiveSeconds(info.expireIn);
126
+ run.qrCodeUrl = info.url;
127
+ run.expiresAt = now() + seconds * 1000;
128
+ clearTimer(run);
129
+ run.expiryTimer = setTimeoutFn(() => expire(run), seconds * 1000);
130
+ run.expiryTimer?.unref?.();
131
+ setState(run, 'qr_ready');
132
+ }
133
+
134
+ function onStatusChange(run, info) {
135
+ if (!isCurrent(run) || !SDK_POLLING_STATES.includes(info?.status)) return;
136
+ setState(run, info.status);
137
+ }
138
+
139
+ async function onSucceeded(run, result) {
140
+ if (!isCurrent(run)) return;
141
+ // 凭据形状:SDK 给 `client_id` / `client_secret`(也兼容 appId/appSecret 的写法)。
142
+ const appId = result?.client_id ?? result?.appId;
143
+ const appSecret = result?.client_secret ?? result?.appSecret;
144
+ if (typeof appId !== 'string' || !appId || typeof appSecret !== 'string' || !appSecret) {
145
+ finish(run, 'error', {
146
+ error: { code: 'invalid_credentials', message: '飞书返回的应用凭据不完整。' },
147
+ });
148
+ return;
149
+ }
150
+ // 凭据到手之后二维码就作废了:链接先从状态里撤掉,再落盘(`saving` 期间不该还能扫)。
151
+ clearTimer(run);
152
+ run.qrCodeUrl = null;
153
+ run.expiresAt = null;
154
+ setState(run, 'saving');
155
+ try {
156
+ const userInfo = result?.user_info ?? result?.userInfo ?? null;
157
+ const saved = await onCredentials({ appId, appSecret, userInfo });
158
+ if (isCurrent(run)) {
159
+ run.bot = saved ?? null;
160
+ finish(run, 'succeeded');
161
+ }
162
+ } catch (error) {
163
+ logger.warn?.(`[dsh-chat-feishu] 扫码接入落盘失败:${error?.message ?? error}`);
164
+ if (isCurrent(run)) {
165
+ finish(run, 'error', { error: publicError(error) });
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 开始一次尝试(**不等待**那个长轮询:调用方随后轮 `status()`)。
172
+ *
173
+ * @returns 当前状态快照。
174
+ */
175
+ function start(options = {}) {
176
+ // 上一次还没结束就作废它(旧的回调一律被 isCurrent 挡掉)。
177
+ if (active) {
178
+ clearTimer(active);
179
+ const previous = active;
180
+ active = null;
181
+ previous.controller.abort();
182
+ }
183
+ const run = {
184
+ id: ++attempt,
185
+ controller: new AbortController(),
186
+ qrCodeUrl: null,
187
+ expiresAt: null,
188
+ expiryTimer: null,
189
+ bot: null,
190
+ };
191
+ active = run;
192
+ snapshot = makeSnapshot(run, 'starting');
193
+
194
+ const registerOptions = {
195
+ ...options,
196
+ signal: run.controller.signal,
197
+ onQRCodeReady: (info) => onQrCodeReady(run, info),
198
+ onStatusChange: (info) => onStatusChange(run, info),
199
+ };
200
+ // 放到微任务里:同步抛与 Promise 拒绝走同一条路,start() 本身不阻塞。
201
+ const task = Promise.resolve().then(() => registerApp(registerOptions));
202
+ void task.then(
203
+ (result) => onSucceeded(run, result),
204
+ (error) => {
205
+ if (!isCurrent(run)) return;
206
+ const mapped = publicError(error);
207
+ logger.warn?.(`[dsh-chat-feishu] 扫码接入失败:${mapped.code} ${mapped.message}`);
208
+ finish(run, mapped.code === 'expired_token' ? 'expired' : 'error', { error: mapped });
209
+ },
210
+ );
211
+ return status();
212
+ }
213
+
214
+ /** @returns 当前状态快照(含剩余秒数;过期就地结算)。 */
215
+ function status() {
216
+ if (active?.expiresAt !== null && active?.expiresAt !== undefined && now() >= active.expiresAt) {
217
+ expire(active);
218
+ }
219
+ const out = { ...snapshot };
220
+ if (out.error) out.error = { ...out.error };
221
+ if (active && active.expiresAt !== null && PROVISION_ACTIVE_STATES.includes(out.state)) {
222
+ out.remainingSeconds = Math.max(0, Math.ceil((active.expiresAt - now()) / 1000));
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** 取消当前尝试(没有进行中的就原样返回)。 */
228
+ function cancel() {
229
+ const run = active;
230
+ if (!run) return status();
231
+ finish(run, 'cancelled', { error: { code: 'abort', message: '已取消。' } });
232
+ run.controller.abort();
233
+ return status();
234
+ }
235
+
236
+ /** 停机:结束进行中的尝试,不再计时。 */
237
+ function dispose() {
238
+ if (active) {
239
+ const run = active;
240
+ clearTimer(run);
241
+ active = null;
242
+ run.controller.abort();
243
+ }
244
+ }
245
+
246
+ return Object.freeze({ start, status, cancel, dispose });
247
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 飞书机器人的会话状态存储。
3
+ *
4
+ * 沿用旧实现的 `bots/<botId>/state.json`:会话键 → DSH Session 的映射与已处理消息 id。
5
+ * 加载后由 hub 的会话桥 `adopt()` 接管,因此升级不丢会话。
6
+ *
7
+ * @module dsh-chat-feishu/state-store
8
+ */
9
+
10
+ import { randomBytes } from 'node:crypto';
11
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+
14
+ /** 去重集合上限(超出丢弃最旧的)。 */
15
+ /** 卡片→会话映射最多留多少条(卡片消息是短命的,留最近的就够)。 */
16
+ const MAX_CARDS = 200;
17
+ const MAX_SEEN = 1_000;
18
+
19
+ function isPlainObject(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ }
22
+
23
+ function normalizeDocument(value) {
24
+ const source = isPlainObject(value) ? value : {};
25
+ const sessions = {};
26
+ if (isPlainObject(source.sessions)) {
27
+ for (const [key, sessionId] of Object.entries(source.sessions)) {
28
+ if (typeof sessionId === 'string' && sessionId) sessions[key] = sessionId;
29
+ }
30
+ }
31
+ const seen = Array.isArray(source.seenMessageIds)
32
+ ? source.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-MAX_SEEN)
33
+ : [];
34
+ /**
35
+ * 卡片消息 → 会话键(`p2p:…` / `group:…`)。
36
+ *
37
+ * 卡片回调里只有 chat_id,而群与私聊的 chat_id 长得一样;这个映射是判对会话的唯一可靠依据。
38
+ * **必须落盘**:进程重启(改 host 代码就要重启)后如果只剩绑定推断,群里点卡片会被判成私聊,
39
+ * 动作就落到操作者的私聊会话上了。
40
+ */
41
+ const cards = {};
42
+ if (isPlainObject(source.cardConversations)) {
43
+ for (const [messageId, key] of Object.entries(source.cardConversations)) {
44
+ if (typeof messageId === 'string' && messageId && typeof key === 'string' && key) cards[messageId] = key;
45
+ }
46
+ }
47
+ return { version: 1, sessions, seenMessageIds: seen, cardConversations: cards };
48
+ }
49
+
50
+ /**
51
+ * 创建状态存储。
52
+ *
53
+ * @param options - { path, logger }。
54
+ * @returns 状态 API。
55
+ */
56
+ export function createFeishuStateStore({ path, logger = console } = {}) {
57
+ if (typeof path !== 'string' || !path.trim()) throw new TypeError('state store 需要 path。');
58
+ let document = { version: 1, sessions: {}, seenMessageIds: [], cardConversations: {} };
59
+ let loaded = false;
60
+ let queue = Promise.resolve();
61
+ const seen = new Set();
62
+ const seenOrder = [];
63
+
64
+ async function persist() {
65
+ await mkdir(dirname(path), { recursive: true });
66
+ const temporary = `${path}.tmp-${randomBytes(6).toString('hex')}`;
67
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
68
+ await rename(temporary, path);
69
+ }
70
+
71
+ function enqueue(task) {
72
+ const next = queue.then(task, task);
73
+ queue = next.then(() => undefined, () => undefined);
74
+ return next;
75
+ }
76
+
77
+ /** 记住"这张卡片属于哪个会话"(按插入顺序截断,避免无限增长)。 */
78
+ function rememberCard(messageId, key) {
79
+ if (typeof messageId !== 'string' || !messageId) return;
80
+ if (typeof key !== 'string' || !key) return;
81
+ const entries = Object.entries(document.cardConversations).filter(([id]) => id !== messageId);
82
+ entries.push([messageId, key]);
83
+ const kept = Object.fromEntries(entries.slice(-MAX_CARDS));
84
+ document = { version: 1, sessions: document.sessions, seenMessageIds: [...seenOrder], cardConversations: kept };
85
+ void enqueue(persist).catch((error) => {
86
+ logger.warn?.(`[dsh-chat-feishu] 写入 ${path} 失败:${error?.message ?? error}`);
87
+ });
88
+ }
89
+
90
+ return {
91
+ path,
92
+
93
+ /** @returns 这张卡片属于哪个会话键;不认识(不是我们发的卡/太久远)时返回 null。 */
94
+ cardConversation(messageId) {
95
+ if (typeof messageId !== 'string' || !messageId) return null;
96
+ return document.cardConversations[messageId] ?? null;
97
+ },
98
+
99
+ /** 记住"这张卡片属于哪个会话"。 */
100
+ rememberCard,
101
+
102
+ /**
103
+ * 等待已排队的写盘落定(去重集合是异步落盘的,停机前要等它写完,
104
+ * 否则重启后会重复处理刚收过的消息)。
105
+ */
106
+ async flush() {
107
+ await queue;
108
+ },
109
+
110
+ async load() {
111
+ if (loaded) return this;
112
+ try {
113
+ document = normalizeDocument(JSON.parse(await readFile(path, 'utf8')));
114
+ } catch (error) {
115
+ if (error?.code !== 'ENOENT') {
116
+ logger.warn?.(`[dsh-chat-feishu] 读取 ${path} 失败:${error?.message ?? error}`);
117
+ }
118
+ document = { version: 1, sessions: {}, seenMessageIds: [], cardConversations: {} };
119
+ }
120
+ for (const id of document.seenMessageIds) {
121
+ if (seen.has(id)) continue;
122
+ seen.add(id);
123
+ seenOrder.push(id);
124
+ }
125
+ loaded = true;
126
+ return this;
127
+ },
128
+
129
+ /** @returns 旧的会话绑定快照(交给 hub 的会话桥 adopt)。 */
130
+ sessions() {
131
+ return Object.freeze({ ...document.sessions });
132
+ },
133
+
134
+ /**
135
+ * 去重:第一次见到返回 true,重复返回 false。
136
+ *
137
+ * @param messageId - 平台消息 id。
138
+ */
139
+ markSeen(messageId) {
140
+ if (typeof messageId !== 'string' || !messageId) return true;
141
+ if (seen.has(messageId)) return false;
142
+ seen.add(messageId);
143
+ seenOrder.push(messageId);
144
+ while (seenOrder.length > MAX_SEEN) {
145
+ const oldest = seenOrder.shift();
146
+ seen.delete(oldest);
147
+ }
148
+ document = {
149
+ version: 1,
150
+ sessions: document.sessions,
151
+ seenMessageIds: [...seenOrder],
152
+ cardConversations: document.cardConversations,
153
+ };
154
+ void enqueue(persist).catch((error) => {
155
+ logger.warn?.(`[dsh-chat-feishu] 写入 ${path} 失败:${error?.message ?? error}`);
156
+ });
157
+ return true;
158
+ },
159
+ };
160
+ }